Migration'ların Deploy'daki Yeri: Expand-Contract, EF Core Migration Bundle ve Geriye Uyumlu Şema
Bir kolonu iki sürüm yan yana çalışırken güvenle bölmek üç deploy ister: ekle ve çift yaz, yeni kolonlara geç, eskiyi kaldır. PostgreSQL'de 300.000 satırla gerçek koşular: v1 ve v2'nin aynı şemada birlikte çalışması, CONCURRENTLY index'in transaction hatası, Npgsql'in transaction'a bağlı migration kilidi yüzünden paralel bundle'larda deadlock, 8 sn kilitleyen UPDATE yerine 135 ms'lik batch'ler ve DROP COLUMN'u durduran review gate.
Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.402), EF (Entity Framework) Core 10.0.14, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 ve PostgreSQL 18.6 ile denenmiştir.
Önce Tahmin Et
Bookstore'un Customers tablosunda 300.000 satır var ve FullName kolonunu FirstName ile LastName'e bölmek istiyorsun. Uygulama blue-green ile deploy ediliyor, yani eski ve yeni sürüm bir süre aynı veritabanını kullanacak. Kaç deploy gerekir: bir mi, iki mi, üç mü?
İkinci soru: EF Core 9'dan beri Migrate() ve migration bundle'ları bir veritabanı kilidi alıyor. Aynı bundle'ı üç pipeline aynı anda çalıştırırsa ne olur? Kilit onları her durumda sıraya sokar mı?
Neden Startup'ta Database.Migrate() Değil?
En kolay yol, uygulama açılırken await db.Database.MigrateAsync() çağırmaktır. Tek instance'lı bir geliştirme ortamında sorun yok, ama deploy stratejileriyle birlikte dört problemi var. EF Core'un "Applying Migrations" dokümanı da bunları sıralıyor.
Birincisi eşzamanlılık. Rolling ya da blue-green'de birden fazla instance aynı anda açılır ve hepsi migration'ı uygulamaya çalışır. EF Core 9 bunu bir kilitle çözüyor, ama birazdan göreceğimiz gibi bu kilidin bir istisnası var. İkincisi yetki. Şemayı değiştirebilmek için uygulamanın veritabanı kullanıcısının DDL (Data Definition Language) yetkisi olması gerekir. Normalde uygulamanın yalnız okuma ve yazma yetkisi olmalı. Üçüncüsü süre. Büyük bir tabloda index oluşturmak dakikalar sürer; o sırada instance readiness'e geçemez ve orkestratör onu "açılamadı" diye öldürebilir. Dördüncüsü kontrol. Migration'ın SQL'i (Structured Query Language) kimse görmeden production'da çalışır ve sorun çıkarsa onu durdurmanın yolu process'i öldürmektir.
Doküman bunun yerine iki yol öneriyor: otomatik deploy için migration bundle, review gereken yerlerde SQL script. Bundle, CI'da (Continuous Integration) üretilen tek dosyalık bir executable. Çalışırken SDK'ya, EF araçlarına ya da kaynak koda ihtiyaç duymaz. EF Core serisinin "EF Core Migrations: migrations add, database update, Script ve Bundle" yazısında komutlarını görmüştük. Burada sorumuz, bu adımın deploy'un neresine konacağı.
Deploy Sırası: Önce Expand Migration, Sonra Kod
Kural basit: migration, kodu deploy edilecek sürümün ihtiyacını karşılar ama hâlâ çalışan sürümü bozmaz. Bu yüzden pipeline önce migration'ı, sonra kodu çıkarır. Yeni kolon eklemek bu kurala uyar: eski kod o kolonu görmez, yeni kod kullanır. Kolon silmek ya da yeniden adlandırmak uymaz. Eski kod hâlâ o kolonu okuyorsa migration çalıştığı an hata vermeye başlar.
Danilo Sato'nun "ParallelChange" yazısı bu fikre expand-contract adını veriyor: önce yeni yapıyı eskisinin yanına ekle (expand), herkesi yeni yapıya taşı (migrate), en son eskisini kaldır (contract). Microservices serisinin "Service Contract'ları ve Versioning" yazısında aynı yaklaşımı API (Application Programming Interface) contract'ları için görmüştük; veritabanında da aynı şekilde işliyor.

Adım 1: Expand ve Çift Yazma
Başlangıç modeli tek kolonlu:
public class Customer
{
public int Id { get; set; }
public required string FullName { get; set; }
public required string Email { get; set; }
}
Yeni kolonları nullable olarak ekliyoruz. Eski sürüm INSERT yaparken bu kolonlara değer vermeyecek; NOT NULL olsalardı eski sürümün INSERT'leri patlardı:
public class Customer
{
public int Id { get; set; }
public required string FullName { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public required string Email { get; set; }
}
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FirstName",
table: "Customers",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastName",
table: "Customers",
type: "text",
nullable: true);
}
Migration'ı bir bundle'a çevirip çalıştırmak:
dotnet ef migrations add AddCustomerNameParts --project Bookstore.Api
dotnet ef migrations bundle --project Bookstore.Api --output artifacts/efbundle-r2 --force
./artifacts/efbundle-r2 --connection "$DB_CONNECTION"
Aynı deploy'da yeni kod (v2) da çıkıyor. v2 iki yere birden yazar ve okurken yeni kolonlar boşsa eskisine düşer:
app.MapGet("/api/customers/{id:int}", async (int id, BookstoreDbContext db) =>
{
Customer? customer = await db.Customers.FindAsync(id);
if (customer is null)
{
return Results.NotFound();
}
// yeni kolonlar doluysa onları, değilse eski FullName'i kullan
(string first, string last) = customer.FirstName is not null
? (customer.FirstName, customer.LastName ?? "")
: SplitFullName(customer.FullName);
return Results.Ok(new { customer.Id, FirstName = first, LastName = last, customer.Email });
});
app.MapPost("/api/customers", async (CreateCustomer request, BookstoreDbContext db) =>
{
// çift yazma: v1 hâlâ FullName okuyor, v2 yeni kolonları
Customer customer = new()
{
FirstName = request.FirstName,
LastName = request.LastName,
FullName = $"{request.FirstName} {request.LastName}",
Email = request.Email,
};
db.Customers.Add(customer);
await db.SaveChangesAsync();
return Results.Created($"/api/customers/{customer.Id}", new { customer.Id });
});
Expand migration'dan sonra v1 ve v2'yi aynı veritabanına bağlayıp ikisinden de yazdım ve okudum:
v1 POST: {"id":300001}
v2 POST: {"id":300002}
v1 GET 1: {"id":1,"fullName":"Alan Lovelace","email":"user1@example.com"}
v2 GET 1: {"id":1,"firstName":"Alan","lastName":"Lovelace","email":"user1@example.com"}
v2 GET 300001: {"id":300001,"firstName":"Edsger","lastName":"Dijkstra","email":"edsger@example.com"}
v1 GET 300002: {"id":300002,"fullName":"Margaret Hamilton","email":"margaret@example.com"}
Id | FullName | FirstName | LastName
--------+-------------------+-----------+----------
1 | Alan Lovelace | |
300001 | Edsger Dijkstra | |
300002 | Margaret Hamilton | Margaret | Hamilton
Her iki sürüm de birbirinin yazdığını okuyabiliyor. v1'in yazdığı satırda yeni kolonlar boş ama v2 onu FullName'den çözüyor. v2'nin yazdığı satırda FullName dolu olduğu için v1 de okuyabiliyor. Rolling, blue-green ya da canary sırasında iki sürümün yan yana yaşayabilmesi tam olarak bu demek. Bu adımda geri dönüş de güvenli: v1'e dönersen yeni kolonlar sadece kullanılmayan iki kolon olarak kalır.
Index'ler ve Migration Kilidinin Sınırı
LastName'e göre arama yapacağımız için bir index ekleyelim. 300.000 satırlık tabloda normal CREATE INDEX tabloya yazmayı index bitene kadar kilitler. PostgreSQL'deki CREATE INDEX CONCURRENTLY ise yazmaları engellemeden çalışır:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""CREATE INDEX CONCURRENTLY "IX_Customers_LastName" ON "Customers" ("LastName");""");
}
Bundle'ı çalıştırınca:
Applying migration '20261108034511_AddCustomerNameParts'.
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
Applying migration '20261108034523_AddCustomerLastNameIndex'.
Failed executing DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
CREATE INDEX CONCURRENTLY "IX_Customers_LastName" ON "Customers" ("LastName");
Npgsql.PostgresException (0x80004005): 25001: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
MigrationId
-------------------------------------
20261108034426_InitialCreate
20261108034511_AddCustomerNameParts
(2 rows)
İki ders var. Birincisi, EF Core her migration'ı kendi transaction'ı içinde çalıştırıyor ve CONCURRENTLY transaction içinde çalışamıyor. İkincisi daha önemli: ilk migration commit edilmiş, ikincisi düşmüş. Veritabanı yarım kalmış bir deploy'un ortasında duruyor. Expand migration'ı geriye uyumlu olduğu için bu durum zararsız. Yıkıcı bir migration'ın yarıda kalması ise geri dönüşü olmayan bir duruma yol açabilir. Her migration'ı tek başına uygulanabilir ve tek başına güvenli tut.
Çözüm, bu komutu transaction dışında çalıştırmak:
migrationBuilder.Sql(
"""CREATE INDEX CONCURRENTLY "IX_Customers_LastName" ON "Customers" ("LastName");""",
suppressTransaction: true);
Şimdi baştaki ikinci soruya geçelim. Aynı bundle'ı üç pipeline'ın aynı anda çalıştırdığını taklit ettim:
for n in 1 2 3; do
./artifacts/efbundle-r2 --connection "$DB_CONNECTION" 2>&1 | sed "s/^/[run $n] /" &
done
wait
[run 1] Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
[run 1] Applying migration '20261108034523_AddCustomerLastNameIndex'.
[run 1] ---> Npgsql.PostgresException (0x80004005): 40P01: deadlock detected
[run 2] Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
[run 2] Applying migration '20261108034523_AddCustomerLastNameIndex'.
[run 2] ---> Npgsql.PostgresException (0x80004005): 40P01: deadlock detected
[run 3] Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
[run 3] Applying migration '20261108034523_AddCustomerLastNameIndex'.
[run 3] Done.
Çıktı kısaltıldı. Üç process de migration'ı uygulamaya başladı, ikisi PostgreSQL'in deadlock tespitine takıldı. Npgsql'in kaynak koduna bakınca sebep görünüyor. Npgsql, EF Core 9'un migration kilidini şu komutla uyguluyor:
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
Kilidin bırakılma davranışı Transaction: kilit migration'ın transaction'ıyla yaşıyor ve onunla bitiyor. suppressTransaction: true dediğin migration'da ise kilidi taşıyacak bir transaction yok. Kontrol için transactional bir migration'ı (bir sonraki adımdakini) aynı şekilde üç kez paralel çalıştırdım:
[run 1] Applying migration '20261108034827_MakeNamePartsRequired'.
[run 1] Done.
[run 2] No migrations were applied. The database is already up to date.
[run 2] Done.
[run 3] No migrations were applied. The database is already up to date.
[run 3] Done.
Bu kez kilit beklendiği gibi çalıştı: biri uyguladı, ikisi bekleyip "zaten güncel" dedi. Sonuç şu: EF Core 9'un kilidi, migration'ların transaction içinde çalıştığı sürece eşzamanlı uygulamayı engelliyor. Transaction dışı adımlar için tek koruma, migration'ı pipeline'da tek bir yerden ve sırayla çalıştırmak. Bunun en basit yolu GitHub Actions'ta concurrency grubu ya da Jenkins'te lock adımı. EF Core serisinin "EF Core 9 Yenilikleri: UseSeeding, Migration Locking ve PendingModelChangesWarning" yazısında kilidin kendisini anlatmıştık.
Backfill: Büyük Tabloda Küçük Adımlar
Yeni kolonlar eski satırlarda boş. Doldurmanın akla gelen ilk yolu tek bir UPDATE:
BEGIN;
UPDATE "Customers"
SET "FirstName" = split_part("FullName", ' ', 1),
"LastName" = substr("FullName", length(split_part("FullName", ' ', 1)) + 2)
WHERE "FirstName" IS NULL;
Bu UPDATE'i bir transaction içinde başlatıp bir buçuk saniye sonra başka bir oturumdan tek bir satırı güncellemeye çalıştım:
SET lock_timeout = '1s';
UPDATE "Customers" SET "Email" = 'alan@example.com' WHERE "Id" = 1;
ERROR: canceling statement due to lock timeout
CONTEXT: while updating tuple (0,1) in relation "Customers"
Büyük UPDATE'in kendisi şu kadar sürdü:
Time: 7976.373 ms (00:07.976)
Sekiz saniye boyunca güncellenen her satır kilitli. O sırada müşteri e-postasını değiştirmek isteyen her istek ya bekler ya da (lock timeout varsa) hata alır. Satır sayısı on katına çıkınca bu süre dakikalara uzar. Yapılacak şey işi küçük ve ayrı transaction'lara bölmek:
#!/usr/bin/env bash
# backfill.sh: FullName'i FirstName/LastName'e küçük ve ayrı transaction'larla kopyalar
set -euo pipefail
batch=20000
max_id=$(psql -qtAX -c 'SELECT coalesce(max("Id"), 0) FROM "Customers"')
from=0
while [ "$from" -lt "$max_id" ]; do
to=$((from + batch))
result=$(psql -qtAX -c "\timing on" -c "
UPDATE \"Customers\"
SET \"FirstName\" = split_part(\"FullName\", ' ', 1),
\"LastName\" = substr(\"FullName\", length(split_part(\"FullName\", ' ', 1)) + 2)
WHERE \"Id\" > $from AND \"Id\" <= $to AND \"FirstName\" IS NULL")
echo "ids $((from + 1))-$to: $(echo "$result" | tr '\n' ' ')"
from=$to
sleep 0.2
done
ids 1-20000: Time: 87.293 ms
ids 20001-40000: Time: 62.599 ms
ids 40001-60000: Time: 108.794 ms
ids 60001-80000: Time: 79.123 ms
ids 160001-180000: Time: 120.531 ms
ids 200001-220000: Time: 132.343 ms
ids 280001-300000: Time: 114.703 ms
ids 300001-320000: Time: 2.518 ms
./backfill.sh 0.28s user 0.20s system 8% cpu 5.668 total
Çıktı kısaltıldı. Her batch 60 ile 135 ms arasında sürdü, yani hiçbir satır o süreden uzun kilitli kalmadı. Toplam süre de 5,7 saniye; batch'ler arasındaki 0,2 saniyelik nefes payıyla birlikte. Id aralığıyla ilerlemek (keyset), her batch'te WHERE "FirstName" IS NULL LIMIT 20000 ile tabloyu baştan taramaktan çok daha ucuz. Script'i tekrar çalıştırmak güvenli, çünkü dolu satırlara dokunmuyor.
Backfill'in zamanlaması ince bir nokta. Backfill bittikten sonra hâlâ çalışan bir v1 instance'ı yeni bir müşteri yazarsa ne olur?
v1 POST: {"id":300003}
missing
---------
1
(1 row)
v1 yeni kolonları bilmediği için boş bıraktı. Bu yüzden backfill'i, bütün instance'lar v2'ye geçtikten sonra çalıştırırsın ya da contract'tan önce bir kez daha koşturursun. Bir sonraki adıma geçmeden önce de "boş kalan satır sayısı 0 mı?" kontrolünü bir pipeline adımı yaparsın. Ben de v1'i durdurup script'i tekrar çalıştırdım ve sayı 0'a indi.
Adım 2: Yeni Kolonlara Geç
İkinci deploy'da kod (v3) yalnız yeni kolonları okuyup yazıyor. Migration da yeni kolonları NOT NULL yapıyor ve FullName'i nullable'a çeviriyor; böylece v3 onu yazmasa da INSERT'ler kırılmıyor. Bu migration'ı eklerken EF yine "veri kaybı olabilir" uyarısı verdi. Neden verdiğini idempotent script gösteriyor:
dotnet ef migrations script AddCustomerLastNameIndex MakeNamePartsRequired --idempotent --project Bookstore.Api
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261108034827_MakeNamePartsRequired') THEN
UPDATE "Customers" SET "LastName" = '' WHERE "LastName" IS NULL;
ALTER TABLE "Customers" ALTER COLUMN "LastName" SET NOT NULL;
ALTER TABLE "Customers" ALTER COLUMN "LastName" SET DEFAULT '';
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261108034827_MakeNamePartsRequired') THEN
ALTER TABLE "Customers" ALTER COLUMN "FullName" DROP NOT NULL;
END IF;
END $EF$;
Script'in bir kısmı yukarıda. AlterColumn ile NOT NULL'a geçerken EF, defaultValue: "" yüzünden önce boş satırları sessizce boş string ile dolduruyor. Backfill'i tamamlamadan bu migration'ı çalıştırsaydın, v1'in yazdığı müşterinin adı hata vermeden kaybolacaktı. Migration'ın ürettiği SQL'i okumak tam olarak bu yüzden önemli. Bir not daha: PostgreSQL'de SET NOT NULL doğrulama için tabloyu baştan sona tarar ve bu sırada tabloyu kilitler. Çok büyük tablolarda önce bir CHECK ("LastName" IS NOT NULL) NOT VALID constraint'i ekleyip onu ayrı bir adımda VALIDATE etmek bu kilidi kısaltır.
Adım 3: Contract ve Destructive Migration Gate'i
Bütün instance'lar v3'te ve hiçbir kod FullName'e dokunmuyorsa üçüncü deploy kolonu siler:
dotnet ef migrations add DropCustomerFullName --project Bookstore.Api
An operation was scaffolded that may result in the loss of data. Please review the migration for accuracy.
Done. To undo this action, use 'ef migrations remove'
Kolon silmek geri alınamaz, bu yüzden pipeline'ın bunu bir insanın onayı olmadan yapmaması gerekir. Basit bir gate, yalnız bu release'te uygulanacak migration'ların script'ini üretip yıkıcı komutları arar:
#!/usr/bin/env bash
# review-gate.sh SCRIPT: migration script'i yıkıcı SQL içeriyorsa pipeline'ı durdurur
set -euo pipefail
pattern='DROP (TABLE|COLUMN)|TRUNCATE|ALTER COLUMN .* TYPE|SET NOT NULL'
if grep -nE "$pattern" "$1"; then
if [ "${ALLOW_DESTRUCTIVE_MIGRATION:-false}" != "true" ]; then
echo "Destructive migration found. Get a review and set ALLOW_DESTRUCTIVE_MIGRATION=true." >&2
exit 1
fi
echo "Destructive migration approved."
fi
dotnet ef migrations script MakeNamePartsRequired --idempotent --project Bookstore.Api --output artifacts/release.sql
./review-gate.sh artifacts/release.sql
ALLOW_DESTRUCTIVE_MIGRATION=true ./review-gate.sh artifacts/release.sql
6: ALTER TABLE "Customers" DROP COLUMN "FullName";
Destructive migration found. Get a review and set ALLOW_DESTRUCTIVE_MIGRATION=true.
exit=1
6: ALTER TABLE "Customers" DROP COLUMN "FullName";
Destructive migration approved.
exit=0
Script'i MakeNamePartsRequired'dan başlatmak önemli. Bütün geçmişin idempotent script'ini tarasaydım, aylar önce uygulanmış bir SET NOT NULL her deploy'u durdururdu. Gate, insanların "bunu gerçekten istiyor muyuz?" sorusunu sorması için var; bir değişkenle onaylanması da bu sorunun cevabının kayıt altında olması demek. Onaydan sonra migration'ı uyguladım. Bu sırada hâlâ çalışan v2 ve yeni v3'ün cevapları:
Applying migration '20261108034901_DropCustomerFullName'.
Done.
v2 GET 1 after contract: 500
Npgsql.PostgresException (0x80004005): 42703: column c.FullName does not exist
v3 GET 1: {"id":1,"firstName":"Alan","lastName":"Lovelace","email":"user1@example.com"}
Contract'ın neden ayrı bir deploy olduğunu bu çıktı gösteriyor. v2 her sorguda FullName'i seçtiği için kolon silindiği an 500 vermeye başladı. Contract migration'ı v3 deploy'uyla aynı adımda çalışsaydı, rolling ya da blue-green süresince hâlâ ayakta olan v2 instance'ları kullanıcılara hata dönecekti. Baştaki ilk sorunun cevabı da bu yüzden üç deploy: ekle ve çift yaz, yeni kolonlara geç, eskiyi kaldır. Backfill ise birinci ile ikinci arasında çalışan bir job.
Üretimde Dikkat
Migration'ları ölç. Her migration'ın süresini ve uygulandığı anda tablonun boyutunu kaydet. Bir sonraki büyük değişiklikte "bu ne kadar sürer?" sorusunun cevabı orada. Production'a çıkmadan önce migration'ı production boyutunda bir kopya üzerinde çalıştırmak, staging'deki 200 satırlık tabloyla yapılan provadan çok daha fazla şey söyler.
Lock timeout kullan. Migration oturumunda SET lock_timeout = '5s' gibi bir sınır, uzun süren bir sorgunun arkasında bekleyen bir ALTER TABLE'ın bütün tabloyu kilitlemesini engeller. PostgreSQL'de bekleyen bir ACCESS EXCLUSIVE kilit isteği, arkasından gelen okuma sorgularını bile sıraya sokar. Migration hata verip tekrar denenir, uygulama ise durmaz.
Migration ile kod deploy'unu ayrı job'lar yap ama aynı pipeline'da tut. Migration job'u başarısız olursa kod deploy'u başlamamalı. Kod deploy'u başarısız olursa expand migration geride kalabilir, çünkü eski kod onunla çalışır. Serinin son yazısındaki pipeline'da bu tam olarak yaşandı.
Sık Düşülen Tuzaklar
- Kolon adını tek bir migration'da değiştirmek.
RenameColumneski sürümün bütün sorgularını aynı anda kırar; adı değişen kolon da expand-contract ister. - Yeni kolonu
NOT NULLve default'suz eklemek. Hâlâ çalışan eski sürüm o kolonu bilmediği için her INSERT'i patlar. - Backfill'i tek bir UPDATE ile yapmak. Tablo büyüdükçe kilit süresi de büyür ve deploy sırasında yazma işlemleri bekler.
- EF'in ürettiği SQL'i okumamak.
NOT NULL'a geçişte boş satırları sessizce dolduran UPDATE gibi yan etkiler yalnız script'te görünür. - Migration kilidine sonsuz güvenmek.
suppressTransactionile çalışan adımlar kilitsiz kalır; pipeline seviyesinde de tekilleştirme gerekir. - Contract'ı unutmak. Expand edip hiç contract etmeyen ekipler, yıllar sonra kimsenin kullanıp kullanmadığından emin olmadığı kolonlarla kalır. Contract'ı backlog'a deploy'la aynı gün yaz.
Küçük Bir Deneme
★☆☆ backfill.sh'deki batch boyutunu 2.000 ve 100.000 yaparak çalıştır. Batch başına süreyle toplam süre nasıl değişiyor? Hangisi uygulamanın yazma isteklerine daha az dokunur?
★★☆ review-gate.sh'ye RENAME COLUMN ve DROP INDEX kurallarını ekle ve onaylı bir RenameColumn migration'ı için gate'in ne yazdırdığını gör.
★★★ SET NOT NULL yerine CHECK ... NOT VALID + VALIDATE CONSTRAINT + SET NOT NULL adımlarını iki ayrı migration olarak yaz. 300.000 satırlık tabloda iki yaklaşımın kilit süresini ölç (pg_locks ile bir yandan izle).
Expand-contract, şema değişikliğini deploy'lara yayarak geri dönüşü her adımda güvenli tutuyor. Ama "bu özellik ne zaman açılacak?" sorusunu hâlâ deploy'a bağlıyor. Bir sonraki yazıda bu bağı da koparıyoruz: feature flag'ler, Microsoft.FeatureManagement ve OpenFeature ile deploy'u release'ten ayırmak.