Настройка Entity Framework для .NET веб-приложения
Entity Framework Core — основной ORM для .NET 6+. В отличие от классического EF, EF Core написан с нуля, поддерживает PostgreSQL через Npgsql, работает без GAC и устанавливается как NuGet-пакет.
Установка пакетов
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Design # для миграций
dotnet tool install --global dotnet-ef
DbContext
// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using MyApp.Models;
namespace MyApp.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users => Set<User>();
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Конфигурации вынесены в отдельные классы
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
// Глобальный query filter для soft delete
modelBuilder.Entity<Product>()
.HasQueryFilter(p => !p.IsDeleted);
}
}
Регистрация в DI
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseNpgsql(
builder.Configuration.GetConnectionString("Default"),
npgsqlOptions =>
{
npgsqlOptions.CommandTimeout(30);
npgsqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorCodesToAdd: null
);
}
);
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});
EnableRetryOnFailure — встроенная стратегия retry для transient failures (разрыв соединения, временная недоступность БД). В production это обязательная опция.
Модели и конфигурация
// Models/Product.cs
namespace MyApp.Models;
public class Product
{
public int Id { get; set; }
public string Title { get; set; } = default!;
public string Slug { get; set; } = default!;
public decimal Price { get; set; }
public ProductStatus Status { get; set; } = ProductStatus.Draft;
public bool IsDeleted { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = default!;
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public enum ProductStatus { Draft, Published, Archived }
// Data/Configurations/ProductConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MyApp.Data.Configurations;
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("products");
builder.HasKey(p => p.Id);
builder.Property(p => p.Title).HasMaxLength(500).IsRequired();
builder.Property(p => p.Slug).HasMaxLength(520).IsRequired();
builder.HasIndex(p => p.Slug).IsUnique();
builder.Property(p => p.Price)
.HasPrecision(12, 2)
.IsRequired();
builder.Property(p => p.Status)
.HasConversion<string>()
.HasMaxLength(20);
builder.Property(p => p.CreatedAt)
.HasDefaultValueSql("NOW()")
.ValueGeneratedOnAdd();
builder.Property(p => p.UpdatedAt)
.HasDefaultValueSql("NOW()")
.ValueGeneratedOnAddOrUpdate();
builder.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasMany(p => p.Tags)
.WithMany(t => t.Products)
.UsingEntity(j => j.ToTable("product_tags"));
builder.HasIndex(p => new { p.Status, p.CreatedAt });
builder.HasIndex(p => new { p.CategoryId, p.Status });
}
}
Разделение конфигурации по классам IEntityTypeConfiguration<T> — единственный правильный подход для проектов с более чем 5 сущностями. OnModelCreating в DbContext превращается в нечитаемый монолит.
Запросы
// Repositories/ProductRepository.cs
public class ProductRepository
{
private readonly AppDbContext _db;
public ProductRepository(AppDbContext db) => _db = db;
public async Task<List<Product>> GetPublishedAsync(
int categoryId,
int page = 1,
int pageSize = 24,
CancellationToken ct = default)
{
return await _db.Products
.AsNoTracking() // только чтение — не нужен change tracking
.Include(p => p.Category)
.Include(p => p.Tags)
.Where(p => p.CategoryId == categoryId && p.Status == ProductStatus.Published)
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
}
public async Task<Product?> GetBySlugAsync(string slug, CancellationToken ct = default)
{
return await _db.Products
.Include(p => p.Category)
.Include(p => p.Tags)
.FirstOrDefaultAsync(p => p.Slug == slug, ct);
}
}
AsNoTracking() для read-only операций убирает overhead change tracker — на больших наборах данных это заметно.
Транзакции
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
try
{
var order = new Order { UserId = userId, Status = OrderStatus.Pending };
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
foreach (var item in items)
{
_db.OrderItems.Add(new OrderItem
{
OrderId = order.Id,
ProductId = item.ProductId,
Quantity = item.Quantity,
});
// Уменьшаем остаток
await _db.Products
.Where(p => p.Id == item.ProductId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - item.Quantity), ct);
}
await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
ExecuteUpdateAsync (EF Core 7+) — bulk UPDATE без загрузки сущностей в память. Критично для обновления остатков при оформлении заказа.
Миграции
dotnet ef migrations add CreateProducts
dotnet ef database update
# Генерация SQL без применения (для review в CI):
dotnet ef migrations script --idempotent --output migrations.sql
--idempotent генерирует SQL с проверкой IF NOT EXISTS для каждой миграции — безопасно применять повторно.
Сроки
Начальная настройка EF Core для нового ASP.NET проекта: 1 день (DbContext, модели, конфигурации, миграции). Оптимизация существующего проекта (AsNoTracking, устранение N+1, compiled queries, ExecuteUpdate): 1–2 дня.







