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.