← Back to all posts

EF Core Migration Stratejileri — Production'da Şema Değişikliği Nasıl Yapılır?

EF Core Migration Stratejileri — Production'da Şema Değişikliği Nasıl Yapılır?

EF Core Migration Stratejileri — Production'da Şema Değişikliği Nasıl Yapılır?

Production'da Migration Neden Risklidir?

Development ortamında migration uygulamak basittir:

dotnet ef migrations add AddProductStockColumn
dotnet ef database update

İki komut, iş bitti. Ama production'da aynı işlem şu soruları doğurur:

  • Migration çalışırken yeni gelen istekler ne olacak?
  • 50 milyon satırlık tabloya kolon eklemek ne kadar sürer?
  • Migration yarıda kalırsa geri dönebilir miyiz?
  • Birden fazla uygulama instance'ı varsa hangisi migration'ı çalıştıracak?
  • Deployment başarısız olursa eski kod yeni şemayla çalışabilir mi?

Bu soruların cevabı planlanmadan yapılan her production migration'ı potansiyel bir kriz noktasıdır.


Migration Temelleri — Doğru Kurulum

Migration Projesi Ayrı Tutun

src/
├── MyApp.Domain/
├── MyApp.Application/
├── MyApp.Infrastructure/        ← DbContext burada
│   └── Migrations/              ← Migration'lar burada
└── MyApp.API/
// MyApp.Infrastructure/AppDbContext.cs
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();
}

// AppDbContextFactory — CLI için design-time factory
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
    public AppDbContext CreateDbContext(string[] args)
    {
        var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
        optionsBuilder.UseSqlServer(
            "Server=localhost;Database=MyAppDb;Trusted_Connection=true;",
            sql => sql.MigrationsAssembly("MyApp.Infrastructure"));

        return new AppDbContext(optionsBuilder.Build());
    }
}
# Migration oluştur — Infrastructure projesini hedef al
dotnet ef migrations add InitialCreate \
  --project src/MyApp.Infrastructure \
  --startup-project src/MyApp.API

# Migration'ı uygula
dotnet ef database update \
  --project src/MyApp.Infrastructure \
  --startup-project src/MyApp.API

Migration Dosyasını Her Zaman İnceleyin

Otomatik üretilen migration'ı körü körüne uygulamayın. Her zaman gözden geçirin:

// 20240115120000_AddProductStockColumn.cs
public partial class AddProductStockColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // ✅ Kontrol edin — beklediğiniz bu mu?
        migrationBuilder.AddColumn<int>(
            name: "StockQuantity",
            table: "Products",
            type: "int",
            nullable: false,
            defaultValue: 0);  // ← Default value production'da önemli
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        // ✅ Down migration çalışıyor mu? Test edin
        migrationBuilder.DropColumn(
            name: "StockQuantity",
            table: "Products");
    }
}

Migration Uygulama Stratejileri

Strateji 1 — Program.cs'de Otomatik Migration (Küçük Projeler)

// Program.cs
var app = builder.Build();

// Migration'ları uygulama başlarken otomatik çalıştır
using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

    try
    {
        // Pending migration'lar varsa uygula
        if (context.Database.GetPendingMigrations().Any())
        {
            context.Database.Migrate();
            Log.Information("Migration'lar başarıyla uygulandı.");
        }
    }
    catch (Exception ex)
    {
        Log.Fatal(ex, "Migration uygulanırken hata oluştu.");
        throw;
    }
}

app.Run();

Ne zaman kullanılır:

✅ Tek instance çalışan küçük uygulamalar
✅ Downtime kabul edilebilir
✅ Migration'lar hızlı tamamlanıyor
❌ Birden fazla instance — race condition riski
❌ Büyük tablolar — uygulama başlamadan önce migration bitmeli

Strateji 2 — Ayrı Migration Aracı (Production Önerisi)

Migration'ı uygulama deployment'ından bağımsız bir adım olarak çalıştırın:

# Migration runner — ayrı bir CLI aracı
dotnet tool install --global dotnet-ef

# Production connection string ile migration uygula
dotnet ef database update \
  --project src/MyApp.Infrastructure \
  --startup-project src/MyApp.API \
  --connection "Server=prod-db;Database=MyAppDb;..." \
  --verbose

CI/CD pipeline'a entegrasyon:

# .github/workflows/deploy.yml
jobs:
  migrate:
    name: Database Migration
    runs-on: ubuntu-latest
    needs: build
    environment: production

    steps:
      - name: Kodu İndir
        uses: actions/checkout@v4

      - name: .NET Kurulumu
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: EF Tools Kur
        run: dotnet tool install --global dotnet-ef

      - name: Pending Migration Kontrolü
        run: |
          PENDING=$(dotnet ef migrations list \
            --project src/MyApp.Infrastructure \
            --startup-project src/MyApp.API \
            --connection "${{ secrets.DB_CONNECTION_STRING }}" \
            --json | jq '[.[] | select(.applied == false)] | length')

          echo "Bekleyen migration sayısı: $PENDING"
          echo "PENDING_COUNT=$PENDING" >> $GITHUB_ENV

      - name: Migration Uygula
        if: env.PENDING_COUNT > 0
        run: |
          dotnet ef database update \
            --project src/MyApp.Infrastructure \
            --startup-project src/MyApp.API \
            --connection "${{ secrets.DB_CONNECTION_STRING }}" \
            --verbose

  deploy:
    name: Deploy Application
    needs: migrate      # Migration başarılı olursa deploy et
    runs-on: windows-latest
    # ...

Strateji 3 — SQL Script ile Migration (Enterprise)

Migration'ı SQL script'e çevirin, DBA onayından geçirin, sonra uygulayın:

# Tüm migration'ları tek SQL script'e çevir
dotnet ef migrations script \
  --project src/MyApp.Infrastructure \
  --startup-project src/MyApp.API \
  --output migrations.sql \
  --idempotent    # Her migration için IF NOT EXISTS kontrolü ekler
  --verbose
-- migrations.sql — otomatik üretilen idempotent script
IF NOT EXISTS(SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20240115_AddProductStockColumn')
BEGIN
    ALTER TABLE [Products] ADD [StockQuantity] int NOT NULL DEFAULT 0;
    INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
    VALUES (N'20240115_AddProductStockColumn', N'8.0.0');
END;
GO
# Belirli bir migration aralığı için script üret
dotnet ef migrations script 20240101_Initial 20240115_AddStock \
  --project src/MyApp.Infrastructure \
  --output partial-migration.sql \
  --idempotent

Zero-Downtime Migration

Büyük tablolarda şema değişikliği uygulamanızı durdurabilir. Zero-downtime için expand-contract pattern kullanılır.

Expand-Contract Pattern

Şema değişikliği tek adımda değil, üç ayrı deployment'ta yapılır:

Adım 1 — Expand (Genişlet):
  Yeni yapıyı ekle, eskiyi koru
  Hem eski hem yeni kod çalışabilir

Adım 2 — Migrate (Taşı):
  Veriyi eski yapıdan yeniye kopyala
  Her iki yapı da kullanımda

Adım 3 — Contract (Daralt):
  Eski yapıyı kaldır
  Sadece yeni kod çalışıyor

Örnek — Kolon Yeniden Adlandırma

// ❌ Yanlış — tek migration, downtime kaçınılmaz
migrationBuilder.RenameColumn("CustomerName", "Users", "FullName");
// Eski kod "CustomerName" arar → hata
// Yeni kod "FullName" arar → hata

// ✅ Doğru — Expand-Contract

Deployment 1 — Expand:

// Migration 1: Yeni kolonu ekle, eskiyi koru
public partial class AddFullNameColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>(
            name: "FullName",
            table: "Users",
            nullable: true);    // Başlangıçta nullable

        // Mevcut veriyi kopyala
        migrationBuilder.Sql(
            "UPDATE Users SET FullName = CustomerName WHERE FullName IS NULL");
    }
}
// Uygulama kodu: Her iki kolonu da yaz
public class User
{
    public string CustomerName { get; set; } = string.Empty;  // Eski
    public string FullName { get; set; } = string.Empty;      // Yeni
}

// Repository: Her iki kolonu da güncelle
public async Task UpdateAsync(User user)
{
    user.CustomerName = user.FullName; // Eski kodu kırmamak için
    _context.Users.Update(user);
    await _context.SaveChangesAsync();
}

Deployment 2 — Migrate:

// Migration 2: FullName'i NOT NULL yap, veriyi tamamla
public partial class MakeFullNameRequired : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Kalan boş değerleri doldur
        migrationBuilder.Sql(
            "UPDATE Users SET FullName = CustomerName WHERE FullName IS NULL OR FullName = ''");

        migrationBuilder.AlterColumn<string>(
            name: "FullName",
            table: "Users",
            nullable: false,
            defaultValue: "");
    }
}

Deployment 3 — Contract:

// Migration 3: Eski kolonu kaldır
public partial class RemoveCustomerNameColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn("CustomerName", "Users");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>(
            name: "CustomerName",
            table: "Users",
            nullable: true);
    }
}

Büyük Tablolarda Migration

50 milyon satırlık tabloya ALTER TABLE çalıştırmak tablonuzu lock'lar ve dakikalarca sürebilir. Bu sürede uygulama yanıt veremez.

Kolon Ekleme — Doğru Yol

// ❌ Yanlış — büyük tabloda lock
migrationBuilder.AddColumn<string>(
    name: "Description",
    table: "Products",
    nullable: false,
    defaultValue: "");
// 50M satır için her satırı günceller → dakikalarca sürer

// ✅ Doğru — nullable ekle, sonra dolduр
public partial class AddProductDescription : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Adım 1: Nullable kolon ekle — hızlı, lock yok
        migrationBuilder.AddColumn<string>(
            name: "Description",
            table: "Products",
            nullable: true);

        // Adım 2: Veriyi batch'ler halinde doldur — lock yok
        migrationBuilder.Sql(@"
            DECLARE @BatchSize INT = 5000;
            DECLARE @Offset INT = 0;

            WHILE 1 = 1
            BEGIN
                UPDATE TOP (@BatchSize) Products
                SET Description = ''
                WHERE Description IS NULL;

                IF @@ROWCOUNT = 0 BREAK;

                WAITFOR DELAY '00:00:00.100'; -- 100ms bekle, I/O'ya nefes aldır
            END");
    }
}

Index Oluşturma — Online

// ❌ Yanlış — büyük tabloda table lock
migrationBuilder.CreateIndex(
    name: "IX_Orders_CustomerId",
    table: "Orders",
    column: "CustomerId");

// ✅ Doğru — ONLINE index, table erişilebilir kalır
migrationBuilder.Sql(@"
    CREATE NONCLUSTERED INDEX IX_Orders_CustomerId
    ON Orders (CustomerId)
    WITH (ONLINE = ON,         -- Tablo erişilebilir kalır
          MAXDOP = 2,          -- Maksimum 2 CPU kullan
          FILLFACTOR = 80);    -- %20 boşluk bırak
");

Kolon Silme — Önce Uygulama, Sonra DB

Yanlış sıra:
  1. Migration: Kolon sil
  2. Deploy: Yeni uygulama kodu
  → Eski kod kolon arar → hata

Doğru sıra:
  1. Deploy: Kolonu kullanmayan kod
  2. Migration: Kolonu sil
  → Kimse kolonu aramıyor, güvenle silinebilir

Veri Migrasyonu

Şema değişikliğiyle birlikte mevcut veriyi dönüştürmek gerektiğinde:

Küçük Tablolar — Migration İçinde

public partial class SplitFullNameToFirstLastName : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Yeni kolonları ekle
        migrationBuilder.AddColumn<string>("FirstName", "Users", nullable: true);
        migrationBuilder.AddColumn<string>("LastName", "Users", nullable: true);

        // Veriyi dönüştür
        migrationBuilder.Sql(@"
            UPDATE Users
            SET
                FirstName = CASE
                    WHEN CHARINDEX(' ', FullName) > 0
                    THEN LEFT(FullName, CHARINDEX(' ', FullName) - 1)
                    ELSE FullName
                END,
                LastName = CASE
                    WHEN CHARINDEX(' ', FullName) > 0
                    THEN SUBSTRING(FullName, CHARINDEX(' ', FullName) + 1, LEN(FullName))
                    ELSE ''
                END");
    }
}

Büyük Tablolar — Background Job

// Migration sadece şemayı değiştirir
public partial class AddUserNameColumns : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>("FirstName", "Users", nullable: true);
        migrationBuilder.AddColumn<string>("LastName", "Users", nullable: true);
        migrationBuilder.AddColumn<bool>(
            "NameMigrated", "Users",
            nullable: false, defaultValue: false);
    }
}

// Veri dönüşümü background job ile yapılır
public class UserNameMigrationJob
{
    private readonly AppDbContext _context;
    private readonly ILogger<UserNameMigrationJob> _logger;

    public UserNameMigrationJob(AppDbContext context, ILogger<UserNameMigrationJob> logger)
    {
        _context = context;
        _logger = logger;
    }

    public async Task RunAsync(CancellationToken ct)
    {
        int processed = 0;
        int batchSize = 1000;

        while (true)
        {
            // Henüz migrate edilmemiş kayıtları al
            var users = await _context.Users
                .Where(u => !u.NameMigrated)
                .Take(batchSize)
                .ToListAsync(ct);

            if (!users.Any()) break;

            foreach (var user in users)
            {
                var parts = user.FullName?.Split(' ', 2) ?? [];
                user.FirstName = parts.Length > 0 ? parts[0] : string.Empty;
                user.LastName = parts.Length > 1 ? parts[1] : string.Empty;
                user.NameMigrated = true;
            }

            await _context.SaveChangesAsync(ct);
            processed += users.Count;

            _logger.LogInformation(
                "Kullanıcı adı migrasyonu: {Processed} kayıt işlendi.",
                processed);

            // Veritabanına nefes aldır
            await Task.Delay(100, ct);
        }

        _logger.LogInformation(
            "Kullanıcı adı migrasyonu tamamlandı. Toplam: {Total}", processed);
    }
}

Geri Alma — Rollback Stratejisi

Down Migration Limitleri

EF Core'un Down() metodu her zaman güvenli değildir:

// Down migration çalışmayabilir:
// 1. Silinen kolonun verisi artık yok
// 2. Tablo yapısı değiştiyse uyumsuzluk olabilir
// 3. Constraint'ler down migration'ı engelleyebilir

Güvenli Rollback Yaklaşımı

# Belirli bir migration'a geri dön
dotnet ef database update 20240101_PreviousMigration \
  --project src/MyApp.Infrastructure \
  --startup-project src/MyApp.API \
  --connection "..."

# Son migration'ı geri al
dotnet ef database update \
  --project src/MyApp.Infrastructure \
  --target-migration 20240114_BeforeLastChange

Production Rollback Planı

Her migration deployment'ı öncesi:

1. Veritabanı yedeği al
   BACKUP DATABASE MyAppDb
   TO DISK = 'C:\Backups\MyAppDb_20240115_before_migration.bak'

2. Migration script'ini DBA ile gözden geçir

3. Test ortamında rollback test et

4. Deployment penceresi belirle

5. Rollback tetikleyicileri tanımla:
   - Migration 5 dakikadan uzun sürerse
   - Hata oranı %1'i geçerse
   - Health check başarısız olursa

6. Rollback komutu hazır beklesin
-- Acil durum rollback script'i hazır olsun
-- Deployment öncesi hazırlayın, gerekirse çalıştırın

-- Migration'ı geri al
BEGIN TRANSACTION;

    -- Şema değişikliğini geri al
    ALTER TABLE Products DROP COLUMN StockQuantity;

    -- Migration kaydını sil
    DELETE FROM [__EFMigrationsHistory]
    WHERE MigrationId = '20240115120000_AddProductStockColumn';

COMMIT;

Çoklu Instance — Race Condition Önleme

Birden fazla uygulama instance'ı aynı anda migration çalıştırmaya çalışırsa ne olur?

// Distributed lock ile tek instance migration
public class MigrationRunner
{
    private readonly AppDbContext _context;
    private readonly IDistributedLock _lock;
    private readonly ILogger<MigrationRunner> _logger;

    public MigrationRunner(
        AppDbContext context,
        IDistributedLock @lock,
        ILogger<MigrationRunner> logger)
    {
        _context = context;
        _lock = @lock;
        _logger = logger;
    }

    public async Task RunAsync()
    {
        // Distributed lock al — sadece bir instance çalıştırır
        await using var lockHandle = await _lock.TryAcquireAsync(
            "db-migration-lock",
            TimeSpan.FromMinutes(10));

        if (lockHandle is null)
        {
            _logger.LogInformation(
                "Migration başka bir instance tarafından çalıştırılıyor. Bekleniyor...");

            // Migration tamamlanana kadar bekle
            await WaitForMigrationsAsync();
            return;
        }

        var pending = _context.Database.GetPendingMigrations().ToList();
        if (!pending.Any())
        {
            _logger.LogInformation("Bekleyen migration yok.");
            return;
        }

        _logger.LogInformation(
            "{Count} migration uygulanacak: {Migrations}",
            pending.Count,
            string.Join(", ", pending));

        _context.Database.Migrate();

        _logger.LogInformation("Migration'lar başarıyla tamamlandı.");
    }

    private async Task WaitForMigrationsAsync()
    {
        var maxWait = TimeSpan.FromMinutes(5);
        var elapsed = TimeSpan.Zero;
        var interval = TimeSpan.FromSeconds(5);

        while (elapsed < maxWait)
        {
            await Task.Delay(interval);
            elapsed += interval;

            var pending = _context.Database.GetPendingMigrations();
            if (!pending.Any())
            {
                _logger.LogInformation("Migration tamamlandı, devam ediliyor.");
                return;
            }
        }

        throw new TimeoutException("Migration timeout — 5 dakika içinde tamamlanamadı.");
    }
}

Migration Naming Kuralları

İsimlendirme tutarsızlığı zamanla migration geçmişini okunaksız kılar:

# ❌ Kötü isimlendirme
dotnet ef migrations add Migration1
dotnet ef migrations add fix
dotnet ef migrations add UpdateDb
dotnet ef migrations add Changes

# ✅ İyi isimlendirme — ne yapıldığı anlaşılıyor
dotnet ef migrations add AddProductStockQuantityColumn
dotnet ef migrations add CreateOrdersTable
dotnet ef migrations add AddIndexOnOrdersCustomerId
dotnet ef migrations add DropLegacyCustomerNameColumn
dotnet ef migrations add SeedInitialCategories
dotnet ef migrations add AlterProductPriceColumnPrecision

# Format: [Eylem][Entity][Alan/Detay]
# Add, Create, Drop, Alter, Rename, Seed, Add Index

Denetim Listesi

✅ Her migration dosyası Up() ve Down() çalışıyor mu?
✅ Büyük tablolarda ONLINE index ve batch update kullanıldı
✅ Kolon silmeden önce uygulama kodu güncellendi
✅ Kolon ekleme nullable ile başlıyor, sonra NOT NULL yapılıyor
✅ Zero-downtime gereken yerlerde expand-contract uygulandı
✅ CI/CD'de migrate adımı deploy adımından önce çalışıyor
✅ İdempotent SQL script üretildi, DBA onayından geçti
✅ Migration öncesi veritabanı yedeği alındı
✅ Rollback script'i hazırlandı ve test edildi
✅ Çoklu instance için distributed lock uygulandı
✅ Migration isimlendirme kuralı tutarlı
✅ Test ortamında migration sonrası smoke test yapıldı

Temel Çıkarımlar

  • Production migration'ı development'taki kadar basit değildir — her adım planlanmalıdır
  • Büyük tablolarda ONLINE index ve batch update downtime'ı önler
  • Expand-contract pattern sıfır downtime migration için altın standarttır
  • Kolon silme sırasını tersine çevirin — önce uygulamayı güncelleyin, sonra DB'yi
  • Her migration öncesi yedek alın, rollback script hazır beklesin
  • Çoklu instance ortamında distributed lock olmadan migration tehlikelidir
  • Migration ismi anlamlı olmalıdır — geçmiş bir hikaye anlatmalıdır

Veritabanı şeması uygulamanın sözleşmesidir. Bu sözleşmeyi değiştirmek ciddiye alınması gereken bir operasyondur — aceleyle değil, planlı ve geri dönüşü garantilenmiş şekilde yapılmalıdır.