Entity Framework Core .NET Banco de Dados SQL Server PostgreSQL DevOps

EF Core Migrations avançadas: estratégias para produção, múltiplos DbContexts e rollback seguro

Além do add-migration básico: bundles de migration, múltiplos DbContexts, migração sem downtime, seed de dados idempotente e rollback seguro em produção.

N
Neryx Digital Architects
2 de novembro de 2025
13 min de leitura
260 profissionais leram
Categoria: .NET Público: Times de plataforma e operação Etapa: Decisão

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-database em produção — use Migration Bundles ou scripts SQL gerados
  • Implemente sempre o método Down() — mesmo que seja apenas um throw new NotSupportedException documentado
  • 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) ou ONLINE = ON (SQL Server)
  • Seed idempotente: verifique existência antes de inserir, prefira ExecuteUpdateAsync para 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.

Quer sair do modo reativo e priorizar o que mais importa?

O diagnóstico de maturidade ajuda a transformar sintomas operacionais em um plano mais claro de evolução.

Avaliar maturidade

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.