Mert Özen Her Satırda Daha İleri
Veritabanı Entity Framework Core — #32

EF Core Migration'ları Pratikte: Rename mi Drop mu, Data Migration, View/SP'yi Migration'da Yönetmek, Ekipte Çakışmalar ve Squash

Mert Özen 15 Kas 2026 12 dk 12 görüntülenme
EF Core Migration'ları Pratikte: Rename mi Drop mu, Data Migration, View/SP'yi Migration'da Yönetmek, Ekipte Çakışmalar ve Squash

Üretilen migration her zaman okunmalı: EF Core FullName'i FirstName/LastName'e bölerken veriyi yanlış kolona taşıyan bir RenameColumn üretebilir. Veri taşıyan adımlar migrationBuilder.Sql ve EXEC ile, view ve function'lar migration'da ya da custom MigrationOperation ile yazılır; ekipte ayrışan migration'lar yeniden üretilir, uzun geçmiş elle Baseline'a squash edilir.

Sürüm notu: Bu yazıdaki kodlar EF Core (Entity Framework Core) 10 ve SQL Server 2025 ile yazılıp denenmiştir; EF Core 11 notları EF Core 11.0.0 ile denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) müşterinin adı tek bir FullName kolonunda duruyor. Fatura ekranı ad ve soyadı ayrı istiyor, sen de model'i değiştiriyorsun:

public class Customer
{
    public int Id { get; set; }
    public string FirstName { get; set; } = null!;
    public string LastName { get; set; } = null!;
    public string Email { get; set; } = null!;
    public List<Order> Orders { get; set; } = [];
}

Tabloda "Ada Lovelace", "Alan Turing", "Grace Hopper" ve "Linus" var. dotnet ef migrations add SplitCustomerName çalıştırıp çıkan migration'ı okumadan uygularsan tabloda ne görürsün? 07. yazıda veri kaybı uyarısıyla gelen bir DropColumn görmüştük; bu kez öyle olmuyor. Cevap ilk bölümde.

07. yazıda migration'ın temellerini kurduk, 31. yazıda Bookstore'un ilk gerçek migration'ını ürettik. Bu yazı, migration'ların dolu bir veritabanına ve kalabalık bir ekibe gittiği andaki işleri anlatıyor: üretilen kodu okumak ve düzeltmek, veri taşımak, view ve function'ları sürümlemek, ekipteki çakışmalar ve geçmişi sıkıştırmak.

Rename mi, Drop mu?

Açılıştaki migration'ı EF Core 10.0.15 şöyle üretti, hiçbir uyarı vermeden:

migrationBuilder.RenameColumn(
    name: "FullName",
    table: "Customers",
    newName: "LastName");

migrationBuilder.AddColumn<string>(
    name: "FirstName",
    table: "Customers",
    type: "nvarchar(200)",
    maxLength: 200,
    nullable: false,
    defaultValue: "");

EF Core, kaybolan FullName ile yeni gelen LastName'in tipinin ve uzunluğunun aynı olduğunu gördü ve bunun bir yeniden adlandırma olduğunu tahmin etti. Uyguladığımda tablo şu hâle geldi:

Id          FirstName LastName
----------- --------- ------------
          1           Ada Lovelace
          2           Alan Turing
          3           Grace Hopper
          4           Linus

Veri kaybolmadı ama yanlış kolona gitti: herkesin soyadı tam adı oldu, adı boş. Veri kaybı uyarısı olmadığı için bu hata ancak biri faturaya baktığında fark edilir. Geri almak için database update InitialCreate ile Down'ı çalıştırıp migrations remove ile migration'ı sildim.

Bu tahmin mekanizması bazen tam istediğin şeyi yapar. Customer.Email'i EmailAddress yaptığımda üretilen migration bir RenameColumn ve index için bir RenameIndex'ti; veri yerinde kaldı. Ama 07. yazıda gördüğümüz gibi adla birlikte tipi de değiştirirsen tahmin tutmaz ve DropColumn + AddColumn gelir. Kural basit: migration'ı her zaman oku. EF Core neyin değiştiğini bilir, niyetini bilemez.

Veriyi Koruyan Migration: FullName'i Bölmek

Doğru migration'ı elle yazıyoruz. Microsoft'un önerdiği sıra şu: hedef kolonları NULL olabilir olarak ekle, veriyi kopyala, sonra zorunlu yap, en son eski kolonu sil. Up:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AddColumn<string>(
        name: "FirstName",
        table: "Customers",
        type: "nvarchar(200)",
        maxLength: 200,
        nullable: true);

    migrationBuilder.AddColumn<string>(
        name: "LastName",
        table: "Customers",
        type: "nvarchar(200)",
        maxLength: 200,
        nullable: true);

    // EXEC: idempotent script'te yeni kolonlar derleme anında henüz yok.
    migrationBuilder.Sql("""
        EXEC(N'
        UPDATE [Customers]
        SET [FirstName] = CASE WHEN CHARINDEX(N'' '', [FullName]) > 0
                               THEN LEFT([FullName], CHARINDEX(N'' '', [FullName]) - 1)
                               ELSE [FullName] END,
            [LastName]  = CASE WHEN CHARINDEX(N'' '', [FullName]) > 0
                               THEN LTRIM(SUBSTRING([FullName], CHARINDEX(N'' '', [FullName]) + 1, 200))
                               ELSE N'''' END;
        ');
        """);

    migrationBuilder.AlterColumn<string>(
        name: "FirstName",
        table: "Customers",
        type: "nvarchar(200)",
        maxLength: 200,
        nullable: false,
        oldClrType: typeof(string),
        oldType: "nvarchar(200)",
        oldMaxLength: 200,
        oldNullable: true);

    // LastName için aynı AlterColumn ...

    migrationBuilder.DropColumn(
        name: "FullName",
        table: "Customers");
}

Down aynı adımları tersinden atıyor: FullName'i NULL olabilir ekle, TRIM([FirstName] + N' ' + [LastName]) ile doldur, zorunlu yap, iki kolonu sil. EXEC'in nedenini bir sonraki bölümde göreceğiz. İki yönü de dolu veritabanında denedim (komutlar, aşağıda anlatacağım design-time factory eklendikten sonraki hâliyle; ikinci migration'ı da bu yazıda ekliyoruz):

dotnet ef database update --project src/Bookstore.Data
Applying migration '20261114221150_SplitCustomerName'.
Applying migration '20261114221408_AddReportingObjects'.

Id          FirstName LastName
----------- --------- --------
          1 Ada       Lovelace
          2 Alan      Turing
          3 Grace     Hopper
          4 Linus

dotnet ef database update InitialCreate --project src/Bookstore.Data
Reverting migration '20261114221408_AddReportingObjects'.
Reverting migration '20261114221150_SplitCustomerName'.

Id          FullName
----------- ------------
          1 Ada Lovelace
          2 Alan Turing
          3 Grace Hopper
          4 Linus

Tek kelimelik "Linus" boş soyadıyla geldi ve geri dönüşte TRIM sayesinde sondaki boşluk olmadan "Linus" oldu. Bu dönüşüm her zaman bilgi kaybetmeden geri alınamayabilir; "Mary Ann Evans" gibi bir ad ileri giderken "Mary" ve "Ann Evans" olur, geri gelirken doğru birleşir ama kuralı bozan başka veriler olabilir. Geri alınamayacak bir dönüşümde Down'ı sessizce yanlış yazmak yerine exception fırlatmak ve backup'tan dönmeyi plana yazmak daha dürüst.

Idempotent Script ve EXEC

07. yazıda production için --idempotent script'i önermiştik. İlk sürümde UPDATE'i EXEC'siz yazmıştım. Uygulama üzerinden database update sorunsuz çalıştı, ama aynı migration'ın idempotent script'i InitialCreate'teki bir veritabanında şunu verdi:

Msg 207, Level 16, State 1, Line 24
Invalid column name 'FirstName'.
Msg 207, Level 16, State 1, Line 27
Invalid column name 'LastName'.

Script, her adımı IF NOT EXISTS (SELECT * FROM [__EFMigrationsHistory] WHERE ...) bloğuna sarıp hepsini tek batch'te gönderiyor. SQL Server batch'i çalıştırmadan önce derliyor ve o anda FirstName kolonu henüz yok; ALTER TABLE ... ADD daha çalışmadı. database update'te sorun yoktu, çünkü EF Core her komutu ayrı gönderiyor. EXEC(N'...') içindeki SQL ise ancak çalıştığı anda derleniyor. Düzeltilmiş script'i boş bir veritabanında iki kez arka arkaya çalıştırdım; ikisi de hatasız bitti, ikincisi hiçbir şey yapmadı. Ders şu: production'a script ya da bundle gidecekse migration'ı yalnız database update ile değil, o script'le de dene.

View, Function ve Custom MigrationOperation

30. yazıda fn_DiscountedPrice ve fn_BestSellers function'larını ayrı bir SQL dosyasıyla kurmuştuk, 31. yazıda da raporlama modelini bir view'a bağlayacağımızı söylemiştik. Model'de değişiklik olmadığı için migrations add AddReportingObjects boş bir migration üretti; içini kendim doldurdum. Function'lar için migrationBuilder.Sql yeterli:

migrationBuilder.Sql("""
    EXEC(N'
    CREATE OR ALTER FUNCTION dbo.fn_DiscountedPrice (@price decimal(10,2), @percent int)
    RETURNS decimal(10,2)
    AS
    BEGIN
        RETURN ROUND(@price * (100 - @percent) / 100.0, 2);
    END');
    """);

CREATE FUNCTION ve CREATE VIEW batch'in ilk komutu olmak zorunda; idempotent script'in IF bloğunda bu kural bozulacağı için yine EXEC. CREATE OR ALTER da, function'ı daha önce SQL dosyasıyla kurmuş veritabanlarında migration'ın hata vermeden geçmesini sağlıyor.

View'ları birden fazla migration'da yöneteceksem her seferinde aynı EXEC ve tırnak kaçırma işini yazmak istemiyorum. EF Core'un ikinci yolu, kendi MigrationOperation'ını tanımlamak ve SQL'i provider'ın generator'ına ürettirmek:

public class CreateOrAlterViewOperation : MigrationOperation
{
    public required string Schema { get; init; }
    public required string Name { get; init; }
    public required string Definition { get; init; }
}

public static OperationBuilder<CreateOrAlterViewOperation> CreateOrAlterView(
    this MigrationBuilder migrationBuilder, string schema, string name, string definition)
{
    var operation = new CreateOrAlterViewOperation { Schema = schema, Name = name, Definition = definition };
    migrationBuilder.Operations.Add(operation);
    return new OperationBuilder<CreateOrAlterViewOperation>(operation);
}

public class BookstoreMigrationsSqlGenerator(
    MigrationsSqlGeneratorDependencies dependencies,
    ICommandBatchPreparer commandBatchPreparer)
    : SqlServerMigrationsSqlGenerator(dependencies, commandBatchPreparer)
{
    protected override void Generate(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder)
    {
        var sql = Dependencies.SqlGenerationHelper;
        switch (operation)
        {
            case CreateOrAlterViewOperation view:
                // CREATE VIEW batch'te tek başına olmalı; EXEC idempotent script'i de geçerli tutar.
                var statement = $"CREATE OR ALTER VIEW {sql.DelimitIdentifier(view.Name, view.Schema)} AS {view.Definition}";
                builder
                    .Append("EXEC(N'")
                    .Append(statement.Replace("'", "''"))
                    .Append("')")
                    .AppendLine(sql.StatementTerminator)
                    .EndCommand();
                break;

            // DropViewOperation: DROP VIEW IF EXISTS ...

            default:
                base.Generate(operation, model, builder);
                break;
        }
    }
}

Generator'ı context'te optionsBuilder.ReplaceService<IMigrationsSqlGenerator, BookstoreMigrationsSqlGenerator>() ile kaydettim. Migration artık okunur bir satır:

migrationBuilder.CreateOrAlterView("catalog", "vw_BookSales", """
    SELECT b.Id AS BookId, b.Title, SUM(oi.Quantity) AS Units
    FROM catalog.Books AS b
    JOIN dbo.OrderItems AS oi ON oi.BookId = b.Id
    GROUP BY b.Id, b.Title
    """);

İlk denemede literal'i EF Core'un kendi GenerateSqlLiteral method'una ürettirmiştim; o da çok satırlı string'i CONCAT(..., nchar(10), ...) parçalarına böldü ve SQL Server EXEC(CONCAT(...))'i Incorrect syntax near 'CONCAT' diye reddetti. Bu yüzden tırnakları kendim kaçırıyorum. Down'da DropView ve DROP FUNCTION IF EXISTS var; geri alıp tekrar uyguladım, üç nesne de silinip yeniden oluştu. BookSalesSummary da artık ToSqlQuery yerine ToView("vw_BookSales", "catalog") ile bu view'dan okuyor.

Design-Time: Factory, Birden Fazla Context ve dotnet-ef.json

dotnet ef context'i nasıl oluşturuyor? 31. yazıda startup project olarak Bookstore.Api'yi verdik; araç uygulamanın host'unu kurdu ve context'i DI'dan (dependency injection; bağımlılıkların dışarıdan verilmesi) aldı. API'de iki context olduğu için --context'siz komut şunu söylemişti:

More than one DbContext was found. Specify which one to use. Use the '-Context' parameter for PowerShell commands and the '--context' parameter for dotnet commands.

Host'u kurmak, connection string'i bulmak için uygulamanın bütün açılış kodunu çalıştırmak demek. Bunun yerine context'in yanına bir design-time factory koydum:

// Yalnız dotnet-ef tasarım anında kullanır; uygulama AddDbContext kullanıyor.
public class BookstoreContextFactory : IDesignTimeDbContextFactory<BookstoreContext>
{
    public BookstoreContext CreateDbContext(string[] args)
    {
        var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__Bookstore")
            ?? "Server=localhost;Database=BookstoreDb;Trusted_Connection=True;TrustServerCertificate=True";

        var options = new DbContextOptionsBuilder<BookstoreContext>()
            .UseSqlServer(connectionString)
            .Options;

        return new BookstoreContext(options);
    }
}

Araç bir factory bulduğunda diğer yolları denemiyor. Microsoft.EntityFrameworkCore.Design paketini de Bookstore.Data'ya ekleyince class library tek başına yetti:

dotnet ef migrations list --project src/Bookstore.Data
Build started...
Build succeeded.
20261114220531_InitialCreate
20261114221150_SplitCustomerName
20261114221408_AddReportingObjects

Okuma modeli ReportingContext'in migration'ı olmadığı için (29. yazı) migration komutlarında karışıklık bitti. Bir context'i farklı provider'larla (ör. SQL Server ve PostgreSQL) kullanıyorsan, migration'larını MigrationsAssembly ile provider başına ayrı projelere ayırmak gerekir; EF Core bir projede tek bir migration zinciri tutar.

EF Core 11 notu: Her komuta --project, --startup-project, --context yazmak yerine EF Core 11'in araç sürümü .config/dotnet-ef.json dosyasını okuyor:

{
  "project": "src/Bookstore.Data",
  "startupProject": "src/Bookstore.Data",
  "context": "BookstoreContext"
}

Bu dosyayla dotnet ef migrations list çıplak hâliyle çalıştı; src klasörünün içinden çalıştırdığımda da araç dosyayı üst klasörlerde arayıp buldu. Komut satırında verdiğin seçenek her zaman dosyadakini eziyor.

Ekipte İki Branch, İki Migration

Emre feature/book-subtitle branch'inde Book.Subtitle'ı, Ayşe feature/customer-phone'da Customer.Phone'u ekliyor; ikisi de migration üretiyor. Önce Emre'ninki, sonra Ayşe'ninki main'e merge ediliyor. EF Core 10 ile:

Auto-merging src/Bookstore.Data/Migrations/BookstoreContextModelSnapshot.cs
Merge made by the 'ort' strategy.

Çakışma yok, snapshot'ta iki property de var, her şey yolunda görünüyor. Ama Ayşe'nin AddCustomerPhone.Designer.cs dosyasındaki model fotoğrafında Subtitle yok; o migration Emre'ninkinden habersiz üretildi. Migration'lar sıralı bir zincir ve her halka bir öncekinin üstüne kurulmuş olmalı. Bu ayrışma bugün sorun çıkarmaz, ama ileride migration silmek ya da geri almak gerektiğinde yanlış bir modele dönersin.

32-migration-branches-tr.png

EF Core 11 aynı senaryoyu görünür kılıyor. Snapshot'a son migration'ın id'sini yazıyor; iki branch bu satırı farklı değiştirdiği için git çakışma veriyor:

CONFLICT (content): Merge conflict in src/Bookstore.Data/Migrations/BookstoreContextModelSnapshot.cs

    // If you encounter a merge conflict in the line below, it means you need to
    // discard one of the migration branches and recreate its migrations on top of
    // the other branch. See https://aka.ms/efcore-docs-migrations-conflicts for more info.
<<<<<<< HEAD
    public override string LastMigrationId => "20261114221625_AddBookSubtitle";
=======
    public override string LastMigrationId => "20261114221628_AddCustomerPhone";
>>>>>>> feature/customer-phone

Çözüm, çakışmayı satır satır düzeltmek değil; dokümanın tarif ettiği gibi Ayşe'nin migration'ını Emre'ninkinin üstünde yeniden üretmek. Ayşe'nin branch'inde yaptığım adımlar:

git merge --abort
git checkout feature/customer-phone
git rm src/Bookstore.Data/Migrations/*_AddCustomerPhone*.cs
git checkout $(git merge-base HEAD main) -- src/Bookstore.Data/Migrations/BookstoreContextModelSnapshot.cs
git commit -m "Drop AddCustomerPhone, keep model change"
git merge main
dotnet ef migrations add AddCustomerPhone --project src/Bookstore.Data

Customer.Phone property'si yerinde kaldı, yalnız migration dosyaları ve snapshot geri alındı. Yeni AddCustomerPhone artık Subtitle'ı içeren bir modelin üstüne kuruldu ve main'e fast-forward ile girdi. İlk denemede ikinci adımda git yerine dotnet ef migrations remove --offline (EF Core 11'in veritabanına bağlanmayan seçeneği) kullandım; araç snapshot'ı bir önceki migration'a göre yeniden yazdığı için main ile birleştirirken yine çakıştı. Doküman da snapshot'ı kaynak kontrolünden geri almayı söylüyor. EF Core 10'da çakışma çıkmasa da aynı adımları uygulaman gerekiyor; fark yalnız sorunun kendini göstermesi.

Squash: Geçmişi Tek Baseline'a İndirmek

Yıllar içinde yüzlerce migration birikebilir; build yavaşlar, yeni bir veritabanı kurmak bütün tarihi baştan oynatmak demek olur. EF Core'un otomatik squash komutu yok; elle yapılıyor. Kopya bir çalışma klasöründe denedim.

Önce veritabanında geçmiş tablosunu boşalttım, sonra Migrations klasöründeki migration dosyalarını ve snapshot'ı sildim, yeni bir başlangıç migration'ı üretip veritabanına "uygulandı" diye kaydettim:

DELETE FROM [__EFMigrationsHistory];

dotnet ef migrations add Baseline --project src/Bookstore.Data

INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20261114221749_Baseline', N'10.0.15');
dotnet ef migrations list --project src/Bookstore.Data
20261114221749_Baseline

dotnet ef migrations has-pending-model-changes --project src/Bookstore.Data
No changes have been made to the model since the last migration.

Var olan veritabanında hiçbir şema komutu çalışmadı; yalnız geçmiş tablosuna "Baseline uygulandı" satırı yazıldı. Yeni bir veritabanı ise Baseline'dan kurulacak. Burada bir tuzak var: Baseline'da fn_ ya da vw_ ile başlayan tek satır yoktu. Elle yazdığın her şey (function'lar, view, FullName bölme gibi veri adımları) eski migration'larla birlikte gitti. Function'ları ve view'ı Baseline'ın Up'ına elle taşımazsan yeni ortamlar eksik kurulur. Custom operation'larımı Migrations/Operations altında tuttuğum için klasörü toptan silmek onları da götürecekti; yalnız migration dosyalarını sildim. Squash'tan önce bütün ortamların aynı migration'da olduğundan emin ol ve backup al.

Sık Düşülen Tuzaklar

Birincisi, production'da dotnet ef database update çalıştırmak. Araç kaynak kod ve SDK ister, şema değiştirme yetkisini geliştiricinin makinesine taşır, ne çalıştırdığını kimse önceden okumaz. Production için idempotent script ya da bundle üret, script'i gözden geçirilecek bir pipeline adımı yap. Deployment Stratejileri serisinin "Migration'ların Deploy'daki Yeri" yazısı, bunun expand-contract (önce genişlet, sonra daralt) yaklaşımıyla nasıl birleştiğini anlatıyor. FullName bölme işi de oradaki gibi iki deploy'a ayrılmalı: eski kod FullName'i okumaya devam ederken kolonu hemen silmek, deploy anında uygulamayı kırar.

İkincisi, Down'ı hiç çalıştırmamak. Üretilen Down genelde doğrudur; elle yazdığın Down ise ancak denersen doğrudur. Her veri migration'ını dolu bir veritabanında ileri ve geri çalıştır.

Üçüncüsü, migration'da BookstoreContext ya da entity class'larını kullanmak. Bugün derlenen bir migration, altı ay sonra Customer class'ı değiştiğinde derlenmez ya da farklı davranır. Migration'lar geçmişe aittir; veriyi SQL ile ya da InsertData/UpdateData ile taşı.

Dördüncüsü, uygulanmış bir migration'ı silmek ya da dosya adını değiştirerek sırasını düzeltmeye çalışmak. Paylaşılan bir veritabanına gitmiş migration kalır; düzeltme yeni bir migration'dır.

Küçük Bir Deneme

★☆☆ Book.Title'ı Name yap ve üretilen migration'ın RenameColumn mı, DropColumn + AddColumn mı olduğuna bak. Sonra aynı anda HasMaxLength(300) ver ve tekrar bak.

★★☆ SplitCustomerName'in idempotent script'ini EXEC'siz üret, InitialCreate'teki bir veritabanında çalıştır ve hatayı gör. Sonra EXEC'li hâlini aynı veritabanında iki kez çalıştır.

★★★ CreateOrAlterView gibi bir CreateOrAlterProcedure operation'ı yaz; usp_CustomerOrders(@customerId int) stored procedure'ünü bir migration'la oluştur, Down'da sil ve iki yönü de dene.

Migration'larla şema hazır ve production'a güvenle gidiyor. Ama uygulama çalışırken veritabanı her zaman orada olmayacak: failover olur, connection kopar, sorgu yavaşlar. Bir sonraki yazıda EF Core'da connection resiliency, logging ve diagnostics'i konuşacağız: execution strategy, command timeout, TagWith, event'ler ve metric'ler.