Padrões do Entity Framework Core
Quando Usar Este Skill
Use este skill quando:
- Configurando o EF Core em um novo projeto
- Otimizando a performance de consultas
- Gerenciando migrações de banco de dados
- Integrando o EF Core com .NET Aspire
- Depurando problemas de rastreamento de alterações
- Carregando múltiplas coleções de navegação de forma eficiente (divisão de consultas)
Princípios Fundamentais
- NoTracking por Padrão - A maioria das consultas é somente leitura; opte pelo rastreamento apenas quando necessário
- Nunca Edite Migrações Manualmente - Sempre use comandos da CLI
- Serviço Dedicado de Migração - Separe a execução de migrações da inicialização da aplicação
- ExecutionStrategy para Repetições - Lide com falhas transitórias de banco de dados
- Atualizações Explícitas - Com NoTracking, marque entidades explicitamente para atualização
Padrão 1: NoTracking por Padrão
Configure seu DbContext para desabilitar o rastreamento de alterações por padrão. Isso melhora a performance para cargas de trabalho com muitas leituras.
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
// Desabilita o rastreamento de alterações por padrão para melhor performance em consultas somente leitura
// Use .AsTracking() explicitamente para consultas que precisam rastrear alterações
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
}
Quando o NoTracking Está Ativo
Consultas somente leitura funcionam normalmente:
// ✅ Leitura rápida - sem overhead de rastreamento
var orders = await dbContext.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync();
Gravações requerem tratamento explícito:
// ❌ ERRADO - Entidade não rastreada, SaveChanges não faz nada
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // Nada acontece!
// ✅ CORRETO - Marcar entidade explicitamente para atualização
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
dbContext.Orders.Update(order); // Marca toda a entidade como modificada
await dbContext.SaveChangesAsync();
// ✅ TAMBÉM CORRETO - Use AsTracking() para a consulta
var order = await dbContext.Orders
.AsTracking()
.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // Funciona!
Quando Usar Rastreamento
| Cenário | Usar Rastreamento? | Por que |
|---|---|---|
| Exibir dados na UI | Não | Somente leitura, sem atualizações |
| Endpoints GET da API | Não | Retornando dados, sem mutações |
| Atualizar entidade única | Sim ou Update() explícito | Precisa salvar alterações |
| Atualização complexa com navegação | Sim | Rastreamento gerencia relacionamentos |
| Operações em lote | Não + ExecuteUpdate | Mais eficiente |
Padrão de Adição/Atualização Explícita
public class OrderService
{
private readonly ApplicationDbContext _db;
// CREATE - Sempre use Add (funciona independentemente do rastreamento)
public async Task<Order> CreateOrderAsync(Order order)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return order;
}
// UPDATE - Marcar explicitamente como modificado
public async Task UpdateOrderStatusAsync(Guid orderId, OrderStatus newStatus)
{
var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId)
?? throw new NotFoundException($"Pedido {orderId} não encontrado");
order.Status = newStatus;
order.UpdatedAt = DateTimeOffset.UtcNow;
// Marcar explicitamente como modificado já que DbContext usa NoTracking por padrão
_db.Orders.Update(order);
await _db.SaveChangesAsync();
}
// DELETE - Anexar e remover
public async Task DeleteOrderAsync(Guid orderId)
{
var order = new Order { Id = orderId };
_db.Orders.Remove(order);
await _db.SaveChangesAsync();
}
}
Padrão 2: Nunca Edite Migrações Manualmente
CRÍTICO: Sempre use comandos da CLI do EF Core para gerenciar migrações. Nunca:
- Edite arquivos de migração manualmente (exceto para SQL personalizado em
Up()/Down()) - Exclua arquivos de migração diretamente
- Renomeie arquivos de migração
- Copie migrações entre projetos
Criando Migrações
# Criar uma nova migração
dotnet ef migrations add AddCustomerTable \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# Com um DbContext específico (se você tiver vários)
dotnet ef migrations add AddCustomerTable \
--context ApplicationDbContext \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
Removendo Migrações
# Remover a última migração (se ainda não aplicada)
dotnet ef migrations remove \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# NUNCA faça isso:
# rm Migrations/20240101_AddCustomerTable.cs # ❌ RUIM!
Aplicando Migrações
# Aplicar todas as migrações pendentes
dotnet ef database update \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# Aplicar até uma migração específica
dotnet ef database update AddCustomerTable \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# Reverter para uma migração anterior
dotnet ef database update PreviousMigrationName \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
Gerando Scripts SQL
# Gerar script SQL para todas as migrações
dotnet ef migrations script \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api \
--output migrations.sql
# Gerar script idempotente (seguro para executar várias vezes)
dotnet ef migrations script \
--idempotent \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
Padrão 3: Serviço Dedicado de Migração com Aspire
Separe a execução de migrações da sua aplicação principal usando um serviço de migração dedicado. Isso garante:
- Migrações concluídas antes do aplicativo iniciar
- Separação clara de responsabilidades
- Semeadura controlada em ambientes de teste
Estrutura de Projeto
src/
├── MyApp.AppHost/ # Orquestração Aspire
├── MyApp.Api/ # Aplicação principal
├── MyApp.Infrastructure/ # DbContext e migrações
└── MyApp.MigrationService/ # Executor de migração dedicado
Program.cs do MigrationService
using MyApp.Infrastructure.Data;
using MyApp.MigrationService;
using Microsoft.EntityFrameworkCore;
var builder = Host.CreateApplicationBuilder(args);
// Adiciona defaults de serviço Aspire
builder.AddServiceDefaults();
// Adiciona DbContext PostgreSQL
var connectionString = builder.Configuration.GetConnectionString("appdb")
?? throw new InvalidOperationException("String de conexão 'appdb' não encontrada.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString, npgsqlOptions =>
npgsqlOptions.MigrationsAssembly("MyApp.Infrastructure")));
// Adiciona o worker de migração
builder.Services.AddHostedService<MigrationWorker>();
var host = builder.Build();
host.Run();
MigrationWorker.cs
public class MigrationWorker : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILogger<MigrationWorker> _logger;
public MigrationWorker(
IServiceProvider serviceProvider,
IHostApplicationLifetime hostApplicationLifetime,
ILogger<MigrationWorker> logger)
{
_serviceProvider = serviceProvider;
_hostApplicationLifetime = hostApplicationLifetime;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Serviço de migração iniciando...");
try
{
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await RunMigrationsAsync(dbContext, stoppingToken);
_logger.LogInformation("Serviço de migração concluído com sucesso.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Serviço de migração falhou: {Error}", ex.Message);
throw;
}
finally
{
// Interrompe a aplicação após as migrações serem concluídas
_hostApplicationLifetime.StopApplication();
}
}
private async Task RunMigrationsAsync(ApplicationDbContext dbContext, CancellationToken ct)
{
// Usa estratégia de execução para lidar com falhas transitórias
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync(ct);
if (pendingMigrations.Any())
{
_logger.LogInformation("Aplicando {Count} migrações pendentes...",
pendingMigrations.Count());
await dbContext.Database.MigrateAsync(ct);
_logger.LogInformation("Migrações aplicadas com sucesso.");
}
else
{
_logger.LogInformation("Nenhuma migração pendente. Banco de dados está atualizado.");
}
});
}
}
Configuração do AppHost
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres");
var db = postgres.AddDatabase("appdb");
// Migrações executam primeiro, depois encerram
var migrations = builder.AddProject<Projects.MyApp_MigrationService>("migrations")
.WaitFor(db)
.WithReference(db);
// API aguarda a conclusão das migrações
var api = builder.AddProject<Projects.MyApp_Api>("api")
.WaitForCompletion(migrations) // Chave: aguarda as migrações terminarem
.WithReference(db);
Padrão 4: ExecutionStrategy para Falhas Transitórias
Sempre use CreateExecutionStrategy() para operações que podem falhar transitoriamente:
public async Task UpdateWithRetryAsync(Guid id, Action<Order> update)
{
var strategy = _dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var order = await _dbContext.Orders
.AsTracking()
.FirstOrDefaultAsync(o => o.Id == id);
if (order is null) return;
update(order);
await _dbContext.SaveChangesAsync();
});
}
Importante: Você não pode usar CreateExecutionStrategy() com transações iniciadas pelo usuário. Se precisar de transações com retry:
var strategy = _dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
// A transação deve estar DENTRO do callback da estratégia
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
// ... suas operações ...
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
});
Padrão 5: Operações em Lote com ExecuteUpdate/ExecuteDelete
Para operações em lote, use ExecuteUpdateAsync e ExecuteDeleteAsync do EF Core 7+ em vez de carregar entidades:
// ❌ LENTO - Carrega todas as entidades na memória
var expiredOrders = await _db.Orders
.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
.ToListAsync();
foreach (var order in expiredOrders)
{
order.Status = OrderStatus.Expired;
}
await _db.SaveChangesAsync();
// ✅ RÁPIDO - Única instrução SQL UPDATE
await _db.Orders
.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, OrderStatus.Expired)
.SetProperty(o => o.UpdatedAt, DateTimeOffset.UtcNow));
// ✅ RÁPIDO - Única instrução SQL DELETE
await _db.Orders
.Where(o => o.Status == OrderStatus.Cancelled && o.CreatedAt < cutoffDate)
.ExecuteDeleteAsync();
Armadilhas Comuns
1. Esquecer de Atualizar Quando NoTracking
// ❌ Falha silenciosa - entidade não rastreada
var customer = await _db.Customers.FindAsync(id);
customer.Name = "Novo Nome";
await _db.SaveChangesAsync(); // Não faz nada!
// ✅ Atualização explícita
var customer = await _db.Customers.FindAsync(id);
customer.Name = "Novo Nome";
_db.Customers.Update(customer);
await _db.SaveChangesAsync();
2. Problema de Consulta N+1
// ❌ Consultas N+1 - uma consulta por pedido
var customers = await _db.Customers.ToListAsync();
foreach (var customer in customers)
{
var orders = customer.Orders; // Carga lenta dispara consulta
}
// ✅ Carga antecipada - consulta única
var customers = await _db.Customers
.Include(c => c.Orders)
.ToListAsync();
3. Conflitos de Rastreamento com Múltiplas Instâncias de DbContext
// ❌ Conflito de rastreamento - entidade rastreada por contexto diferente
var order1 = await _db1.Orders.AsTracking().FindAsync(id);
var order2 = await _db2.Orders.AsTracking().FindAsync(id);
order2.Status = OrderStatus.Shipped;
await _db2.SaveChangesAsync(); // Pode lançar exceção ou se comportar inesperadamente
// ✅ Use contexto único ou desanexe primeiro
_db1.Entry(order1).State = EntityState.Detached;
4. Não Usar Async Consistentemente
// ❌ Chamada bloqueante em contexto async
var orders = _db.Orders.ToList(); // Bloqueia a thread
// ✅ Async em toda a cadeia
var orders = await _db.Orders.ToListAsync();
5. Consultar Dentro de Loops
// ❌ Consulta por iteração
foreach (var orderId in orderIds)
{
var order = await _db.Orders.FindAsync(orderId);
// processar pedido
}
// ✅ Consulta única
var orders = await _db.Orders
.Where(o => orderIds.Contains(o.Id))
.ToListAsync();
Tempo de Vida do DbContext na Injeção de Dependência
ASP.NET Core (Escopo por Padrão)
// Escopo = uma instância por requisição HTTP
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString));