O fluxo básico de EF Core Migrations — add-migration, update-database — funciona bem em desenvolvimento. Em produção, porém, ele esconde armadilhas: locks de tabela durante deploy, rollback impossível após dados terem sido migrados, migrations conflitantes em times grandes e contextos que crescem descontrolados. Este artigo cobre as estratégias que evitam esses problemas.
1. Migration Bundles: o jeito certo de fazer deploy em produção
Desde o EF Core 6, o comando dotnet ef migrations bundle gera um executável autocontido que aplica as migrations. É a alternativa correta ao perigoso update-database dentro do pipeline de CI/CD.
# Gerar o bundle (inclui runtime embutido)
dotnet ef migrations bundle \
--project src/Infrastructure \
--startup-project src/Api \
--output ./artifacts/efbundle \
--self-contained \
--runtime linux-x64
# No pipeline de deploy (exemplo GitHub Actions)
- name: Apply Migrations
run: |
./artifacts/efbundle \
--connection "${{ secrets.CONNECTION_STRING }}" \
--verbose
O bundle é idempotente: só aplica migrations ainda não registradas na tabela __EFMigrationsHistory. Ele também retorna código de saída não-zero em caso de falha, interrompendo o pipeline automaticamente.
2. Múltiplos DbContexts: organização e deploy independente
Projetos maiores precisam de DbContexts separados por domínio. A armadilha comum é misturar as migrations no mesmo diretório. A convenção correta:
// src/Infrastructure/Contexts/OrderingContext.cs
public class OrderingContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
}
// src/Infrastructure/Contexts/CatalogContext.cs
public class CatalogContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
}
# Migrations em pastas separadas por contexto
dotnet ef migrations add InitialCreate \
--context OrderingContext \
--output-dir Migrations/Ordering \
--project src/Infrastructure \
--startup-project src/Api
dotnet ef migrations add InitialCreate \
--context CatalogContext \
--output-dir Migrations/Catalog \
--project src/Infrastructure \
--startup-project src/Api
# Bundle separado por contexto
dotnet ef migrations bundle \
--context OrderingContext \
--output ./artifacts/efbundle-ordering
dotnet ef migrations bundle \
--context CatalogContext \
--output ./artifacts/efbundle-catalog
Registrar os contextos com connection strings independentes permite deploys e rollbacks por domínio sem afetar outros contextos:
// Program.cs
builder.Services.AddDbContext<OrderingContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Ordering")));
builder.Services.AddDbContext<CatalogContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Catalog")));
3. Aplicar migrations na inicialização da aplicação (com cuidado)
A abordagem de aplicar migrations no Program.cs durante o startup é tentadora, mas problemática em múltiplas instâncias. O padrão correto usa um mecanismo de lock distribuído ou um job de inicialização separado:
// Padrão seguro para aplicar migrations no startup
// Funciona com uma instância — para múltiplas, use Migration Bundle no pipeline
public static async Task ApplyMigrationsAsync(IHost host)
{
using var scope = host.Services.CreateScope();
var services = scope.ServiceProvider;
var logger = services.GetRequiredService<ILogger<Program>>();
try
{
var contexts = new DbContext[]
{
services.GetRequiredService<OrderingContext>(),
services.GetRequiredService<CatalogContext>()
};
foreach (var context in contexts)
{
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
if (pendingMigrations.Any())
{
logger.LogInformation(
"Aplicando {Count} migrations em {Context}",
pendingMigrations.Count(),
context.GetType().Name);
await context.Database.MigrateAsync();
logger.LogInformation("Migrations aplicadas com sucesso em {Context}",
context.GetType().Name);
}
}
}
catch (Exception ex)
{
logger.LogCritical(ex, "Falha ao aplicar migrations. Aplicação será encerrada.");
throw; // Encerra o processo — melhor que subir com schema desatualizado
}
}
// Program.cs
var app = builder.Build();
await ApplyMigrationsAsync(app);
await app.RunAsync();
4. Migrations sem downtime: técnicas de zero-downtime deployment
O maior risco em produção é uma migration que bloqueia tabelas enquanto há tráfego ativo. As técnicas abaixo evitam isso:
4.1 Adicionar colunas como nullable primeiro
// ❌ Errado: adicionar coluna NOT NULL diretamente popula toda a tabela em lock
migrationBuilder.AddColumn<string>(
name: "TaxId",
table: "Customers",
nullable: false, // LOCK de tabela inteira em tabelas grandes!
defaultValue: "");
// ✅ Correto: 3-fase deploy
// Fase 1 (Migration A): Adicionar nullable
migrationBuilder.AddColumn<string>(
name: "TaxId",
table: "Customers",
nullable: true);
// Fase 2 (deploy do código): preencher dados via job em background
// UPDATE Customers SET TaxId = '00000000000' WHERE TaxId IS NULL
// Fase 3 (Migration B, próximo deploy): adicionar constraint NOT NULL
migrationBuilder.AlterColumn<string>(
name: "TaxId",
table: "Customers",
nullable: false);
4.2 Renomear colunas sem quebrar a aplicação em produção
// ❌ Errado: renomear diretamente quebra código que ainda usa o nome antigo
migrationBuilder.RenameColumn("NomeAntigo", "Customers", "NomeNovo");
// ✅ Correto: estratégia de 3 fases
// Fase 1: Adicionar coluna nova, manter antiga
migrationBuilder.AddColumn<string>("NomeNovo", "Customers", nullable: true);
// Fase 2: Sync de dados e código adaptado para escrever em ambas as colunas
// UPDATE Customers SET NomeNovo = NomeAntigo WHERE NomeNovo IS NULL
// Fase 3: Remover coluna antiga após todos os deploys estarem na nova versão
migrationBuilder.DropColumn("NomeAntigo", "Customers");
4.3 Índices sem lock com SQL raw
// Para tabelas grandes, criar índice com ONLINE = ON (SQL Server) ou CONCURRENTLY (PostgreSQL)
public partial class AddIndexOrdersByCustomer : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// SQL Server — não bloqueia leituras/escritas durante criação do índice
migrationBuilder.Sql(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS " +
"IX_Orders_CustomerId ON Orders (CustomerId);",
suppressTransaction: true); // suppressTransaction é obrigatório para CONCURRENTLY
// SQL Server equivalente:
// CREATE INDEX IX_Orders_CustomerId ON Orders (CustomerId) WITH (ONLINE = ON);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex("IX_Orders_CustomerId", "Orders");
}
}
5. Seed de dados idempotente
O método HasData() do EF Core parece conveniente, mas tem limitações sérias em produção: ele compara por chave primária e pode apagar dados reais se você alterar os seeds. A alternativa é um seeder explícito e idempotente:
// src/Infrastructure/Seeders/CatalogSeeder.cs
public class CatalogSeeder
{
private readonly CatalogContext _context;
private readonly ILogger<CatalogSeeder> _logger;
public CatalogSeeder(CatalogContext context, ILogger<CatalogSeeder> logger)
{
_context = context;
_logger = logger;
}
public async Task SeedAsync()
{
await SeedCategoriesAsync();
await SeedDefaultProductsAsync();
}
private async Task SeedCategoriesAsync()
{
var categories = new[]
{
new { Id = Guid.Parse("..."), Name = "Software", Slug = "software" },
new { Id = Guid.Parse("..."), Name = "Infraestrutura", Slug = "infraestrutura" }
};
foreach (var cat in categories)
{
// Idempotente: só insere se não existir
var exists = await _context.Categories
.AnyAsync(c => c.Id == cat.Id);
if (!exists)
{
_context.Categories.Add(new Category
{
Id = cat.Id,
Name = cat.Name,
Slug = cat.Slug,
CreatedAt = DateTime.UtcNow
});
_logger.LogInformation("Categoria '{Name}' adicionada via seed", cat.Name);
}
}
await _context.SaveChangesAsync();
}
private async Task SeedDefaultProductsAsync()
{
// Usa ExecuteUpdateAsync para atualizar dados existentes sem recriar
// Garante que dados de configuração críticos sempre estejam corretos
await _context.Products
.Where(p => p.IsSystemDefault)
.ExecuteUpdateAsync(s => s
.SetProperty(p => p.Price, 0m)
.SetProperty(p => p.IsActive, true));
}
}
// Registrar e executar no startup
builder.Services.AddScoped<CatalogSeeder>();
// Após ApplyMigrationsAsync:
using var scope = app.Services.CreateScope();
var seeder = scope.ServiceProvider.GetRequiredService<CatalogSeeder>();
await seeder.SeedAsync();
6. Rollback de migrations: a realidade dura
EF Core suporta rollback via update-database <MigrationAnterior>, mas ele só funciona se o método Down() estiver implementado E se os dados permitirem. Na prática:
// ✅ Implemente sempre o método Down() — não deixe vazio
public partial class AddTaxIdToCustomers : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "TaxId",
table: "Customers",
maxLength: 14,
nullable: true);
}
// Rollback possível: DROP COLUMN não perde dados se foi adicionada
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "TaxId", table: "Customers");
}
}
// ❌ Migrations com destruição de dados são irreversíveis
public partial class RemoveOldLogsTable : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "OldLogs"); // Dados perdidos para sempre
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Impossível recriar os dados que foram deletados
// Documente isso explicitamente:
throw new NotSupportedException(
"Esta migration é irreversível. Restaure o backup para reverter.");
}
}
Estratégia de rollback real em produção
# 1. Verificar migration atual
dotnet ef migrations list \
--project src/Infrastructure \
--startup-project src/Api
# 2. Gerar script SQL do rollback (revisar antes de executar!)
dotnet ef migrations script \
MigrationAtual TargetMigration \
--idempotent \
--project src/Infrastructure \
--startup-project src/Api \
--output rollback.sql
# 3. Revisar o SQL gerado, executar em staging primeiro
# 4. Executar em produção com monitoramento
# Bundle: gerar bundle que vai para a versão anterior
dotnet ef migrations bundle \
--target-migration MigrationAnterior \
--output ./artifacts/efbundle-rollback
7. Detectar migrations pendentes na healthcheck
// HealthCheck que detecta migrations não aplicadas
public class PendingMigrationsHealthCheck<TContext> : IHealthCheck
where TContext : DbContext
{
private readonly TContext _context;
public PendingMigrationsHealthCheck(TContext context)
=> _context = context;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var pendingMigrations = await _context.Database
.GetPendingMigrationsAsync(cancellationToken);
var migrationsList = pendingMigrations.ToList();
if (migrationsList.Count == 0)
return HealthCheckResult.Healthy("Schema atualizado");
return HealthCheckResult.Degraded(
$"{migrationsList.Count} migration(s) pendente(s): {string.Join(", ", migrationsList)}");
}
}
// Registrar no Program.cs
builder.Services
.AddHealthChecks()
.AddCheck<PendingMigrationsHealthCheck<OrderingContext>>("ordering-migrations")
.AddCheck<PendingMigrationsHealthCheck<CatalogContext>>("catalog-migrations");
app.MapHealthChecks("/health/migrations", new HealthCheckOptions
{
Predicate = check => check.Name.EndsWith("-migrations")
});
8. Gerar scripts SQL idempotentes para auditoria
Em ambientes com DBAs ou regulações que exigem aprovação de DDL antes de executar em produção, gere scripts SQL ao invés de aplicar diretamente:
# Gerar script idempotente de TODAS as migrations não aplicadas
dotnet ef migrations script \
--idempotent \
--project src/Infrastructure \
--startup-project src/Api \
--output migrations-$(date +%Y%m%d).sql
# Gerar script entre duas migrations específicas
dotnet ef migrations script \
"20260315_AddTaxId" "20260401_AddOrderStatus" \
--idempotent \
--output migrations-partial.sql
O flag --idempotent envolve cada migration em um IF NOT EXISTS verificando a tabela __EFMigrationsHistory, tornando seguro re-executar o script.
Checklist de migrations para produção
- Nunca use
update-databaseem produção — use Migration Bundles ou scripts SQL gerados - Implemente sempre o método
Down()— mesmo que seja apenas umthrow new NotSupportedExceptiondocumentado - Colunas NOT NULL com dados existentes: sempre em 3 fases (nullable → populate → constraint)
- Renomear colunas: sempre em 3 deploys (add → sync → drop)
- Índices em tabelas grandes: use
CONCURRENTLY(PostgreSQL) ouONLINE = ON(SQL Server) - Seed idempotente: verifique existência antes de inserir, prefira
ExecuteUpdateAsyncpara configurações - HealthCheck de migrations pendentes: alerte antes que vire problema em produção
- Scripts SQL para auditoria: gere e revise o SQL antes de executar em produção crítica
Migrations são DDL aplicado em produção — merecem o mesmo rigor que qualquer outro deploy. Com bundles, scripts idempotentes e as técnicas de zero-downtime acima, você torna esse processo repetível, auditável e revertível.
Na Neryx, aplicamos essas práticas em todos os projetos .NET com banco de dados relacional. Se o seu time está com dificuldades em gerenciar o schema em produção ou precisa de um processo de deploy mais confiável, entre em contato para uma conversa técnica.