Veri ve Contract Değişirken Refactoring: API Alanını, EF Core Property'sini ve Angular Modelini Birlikte Yeniden Adlandırmak
Bir property'nin adı değişirken dış contract'lar (JSON alanı, veritabanı kolonu, CSV başlığı, cache'teki şekil) koddan ayrılmalı ve expand-contract ile adım adım taşınmalıdır. Yazıda Book.Price'ın ListPrice'a dönüşmesi ve DiscountRate'in ayrı tabloya taşınması .NET, EF Core, SQL Server ve Angular'da yürütülüyor; kırılan view, sıfır okunan cache, tolerant reader ve dual write'ı atlayan script gerçek çıktılarla gösteriliyor.
Sürüm notu: Bu yazıdaki C# kodları C# 14 ve .NET 10 (SDK 10.0.402) ile, Entity Framework Core 10.0.15 ve SQL Server 2025 üzerinde, testler xUnit.net v3 4.0.3 ile; Angular kodu Angular 22.2, TypeScript 6.0 (Angular 22.2 projeleri TypeScript 6.0 istiyor) ve Vitest 5.1 ile derlenip denenmiştir.
Önce Tahmin Et: "Sadece Bir Rename"
Bookstore'da bir kitabın fiyatı yıllardır Book.Price. Kampanya fiyatları gelince ekip "liste fiyatı" ile "satış fiyatı"nı karıştırmaya başladı ve karar verildi: property'nin adı ListPrice olacak. Katalog API'sinin Book entity'si basit, endpoint de entity'yi olduğu gibi JSON olarak dönüyor:
public class Book
{
public int Id { get; set; }
public required string Title { get; set; }
public decimal Price { get; set; }
public decimal DiscountRate { get; set; }
}
IDE'de rename yapınca (F2) bütün referanslar güncelleniyor. Diff küçük, build yeşil:
$ git diff --stat
src/Bookstore.Catalog/Book.cs | 2 +-
src/Bookstore.Catalog/CatalogDbContext.cs | 2 +-
src/Bookstore.Catalog/Program.cs | 2 +-
tests/Bookstore.Catalog.Tests/CatalogApp.cs | 4 ++--
4 files changed, 5 insertions(+), 5 deletions(-)
$ dotnet build
Build succeeded.
Derleyici bir şey söylemiyor, çünkü derleyicinin gördüğü her yer güncellendi. Soru şu: derleyicinin görmediği yerlerde bu rename neyi değiştirdi? Devam etmeden önce bir liste yap.
Cevap: Derleyicinin Görmediği Dört Contract
Birincisi veritabanı. Entity Framework Core (EF Core; .NET'in veritabanı erişim kütüphanesi) property adından kolon adı türettiği için yeni migration bir kolon adı değişikliği üretiyor:
$ dotnet ef migrations add RenameBookPrice
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Price",
table: "Books",
newName: "ListPrice");
}
Bu migration uygulandığı an, eski sürümle çalışan her instance Price kolonunu arar ve bulamaz; rolling deploy sırasında birkaç dakika boyunca iki sürüm aynı veritabanına bağlıdır. Uygulama dışındaki okuyucular da var. Finans ekibinin rapor aracı yıllardır kendi yazdığı bir view'dan okuyor ve migration'dan sonra view şöyle cevap veriyor:
Msg 207, Level 16, State 1, Server b8be943222fa, Procedure vw_BookNetPrices, Line 1
Invalid column name 'Price'.
Msg 4413, Level 16, State 1, Server b8be943222fa, Line 1
Could not use view or function 'dbo.vw_BookNetPrices' because of binding errors.
İkincisi API. Katalog servisinin JSON'unu sabitleyen bir contract testi vardı; Angular istemcisi price alanını okuyor:
failed Bookstore.Catalog.Tests.ContractTests.Book_json_keeps_its_field_names (139ms)
Assert.Equal() Failure: Strings differ
↓ (pos 24)
Expected: "{"id":1,"title":"Dune","price":450.00,"discountRat"···
Actual: "{"id":1,"title":"Dune","listPrice":450.00,"discoun"···
Üçüncüsü dışa aktarılan dosya. Finansın her ay Excel'e aldığı CSV (virgülle ayrılmış metin; burada ayraç noktalı virgül) dosyasının başlığı property adlarından reflection ile üretiliyordu; artık Id;Title;ListPrice;DiscountRate yazıyor ve kolonu adıyla arayan makro boş dönüyor.
Dördüncüsü en sessiz olanı: cache. Endpoint, kitabı JSON olarak paylaşılan bir distributed cache'e yazıyor. Deploy anında cache'te eski sürümün yazdığı kayıtlar duruyor. Eski sürümün yazdığı bir kaydı yeni Book tipine okuyan küçük bir program:
// Deploy'dan önce eski sürümün paylaşılan cache'e yazdığı kayıt.
string cached = """{"Id":1,"Title":"Dune","Price":450.00,"DiscountRate":0.10}""";
Book book = JsonSerializer.Deserialize<Book>(cached)!;
Console.WriteLine($"{book.Title}: ListPrice={book.ListPrice}, DiscountRate={book.DiscountRate}");
Dune: ListPrice=0, DiscountRate=0.10
Exception yok, log yok; cache süresi dolana kadar Dune'un fiyatı sıfır. System.Text.Json tanımadığı Price alanını varsayılan olarak sessizce atlıyor. Bütün bu değişikliği geri alıp (dotnet ef database update Initial, sonra git restore) işi baştan, contract'ları tek tek ayırarak yaptık.
Kod İçindeki Adı Dış Contract'tan Ayırmak
İlk adımda yalnız kod içindeki ad değişiyor. Dış dünyanın gördüğü adlar açıkça yazılıyor: JSON alanı [JsonPropertyName] ile, kolon HasColumnName ile.
public class Book
{
public int Id { get; set; }
public required string Title { get; set; }
// Ad yalnız kodda değişti; JSON alanı ve kolon hâlâ "price"/"Price".
[JsonPropertyName("price")]
public decimal ListPrice { get; set; }
public decimal DiscountRate { get; set; }
}
// CatalogDbContext.OnModelCreating
book.Property(b => b.ListPrice).HasColumnName("Price").HasPrecision(10, 2);
Modelin veritabanı tarafında gerçekten değişmediğini EF Core 8 ile gelen bir komutla doğrulamak mümkün:
$ dotnet ef migrations has-pending-model-changes
No changes have been made to the model since the last migration.
CSV için çözüm, başlığı property adlarından türetmeyi bırakmak. Finansın dosyası da bir contract; kolonlar açıkça yazılıyor:
// Finans ekibinin dosyası da bir contract: başlık property adlarından değil, açıkça yazılır.
public static string WriteBooks(IEnumerable<Book> books) => Write(books,
("Id", b => b.Id),
("Title", b => b.Title),
("Price", b => b.ListPrice),
("DiscountRate", b => b.DiscountRate));
Cache tarafında bir sorun daha çıktı. Bu sürümde de aynı küçük program ListPrice=0 yazdı. Cache'e yazan kod JsonSerializer'ı varsayılan ayarlarla çağırıyor ve eski kayıtlardaki alan adı Price; attribute ise artık price diyor. Varsayılan ayarlarda okuma büyük-küçük harfe duyarlı olduğu için alan yine eşleşmiyor. ASP.NET Core'un API'de kullandığı web varsayılanları duyarsız olduğundan bu fark yalnız cache'te görünüyor. Çözüm, cache'lenen şeklin değiştiği her seferde key'e bir sürüm eklemek: book:v2:{id}. Eski kayıtlar artık hiç okunmuyor, süreleri dolunca kendiliğinden siliniyor. Bunu, eski şekilde bir kaydı book:1 key'ine yazıp API'nin doğru fiyatı döndürdüğünü kontrol eden bir testle sabitledik.
Expand-Contract'ı Kodda Yürütmek: API Alanı
Kod içinde ListPrice diyoruz ama API hâlâ price diyor. API alanının adını da değiştirmek istiyorsak, istemcileri kırmadan yapmanın yolu expand-contract: önce ikisini birden yay (expand), istemcileri taşı, sonra eskisini kaldır (contract). Danilo Sato'nun 2014 tarihli ParallelChange yazısı aynı üç adımı anlatıyor; üçüncü yazıda method imzası için kullandığımız teknik, burada bir contract'a uygulanıyor. Bunun için entity'yi doğrudan dönmeyi de bıraktık; API'nin şekli artık ayrı bir record:
public sealed record BookResponse(int Id, string Title, decimal ListPrice, decimal DiscountRate)
{
// Expand: hâlâ "price" okuyan istemciler çalışmaya devam eder. Contract adımında kalkar.
public decimal Price => ListPrice;
public static BookResponse From(Book book) => new(book.Id, book.Title, book.ListPrice, book.DiscountRate);
}
// Tolerant reader: eski istemciler "price", yeniler "listPrice" gönderir.
public sealed record PriceChange(decimal? ListPrice, decimal? Price)
{
public bool UsesLegacyField => ListPrice is null && Price is not null;
public decimal? Value => ListPrice ?? Price;
}
Expand sürümünde GET /books/1 şu JSON'u dönüyor: {"id":1,"title":"Dune","listPrice":450.00,"discountRate":0.10,"price":450.00}. Fiyat değişikliği endpoint'i iki alanı da kabul ediyor ve eski alan geldiğinde User-Agent'ı log'a yazıyor. Fowler'ın 2011 tarihli TolerantReader yazısındaki fikir bu: okuyan taraf yalnız ihtiyacı olan alanlara bakar ve tanımadığı ya da eksik alanlara karşı esnek davranır. Contract adımının zamanını tahmin değil bu log belirliyor; birkaç gün boyunca eski alanı kullanan istemci görünmeyince Price, BookResponse'tan ve PriceChange'ten kalktı. O commit'in başlığı refactor: değil feat!:, çünkü dışarıdan görünen bir contract değişiyor. Cache key de yine bir sürüm atladı.
Angular İstemcisini Geçişe Dahil Etmek
Angular tarafında Book interface'indeki alanı listPrice yaptığımızda, template type checking açık olduğu için template'teki eski kullanım derlemede yakalanıyor:
✘ [ERROR] TS2339: Property 'price' does not exist on type 'Book'. [plugin angular-compiler]
src/app/catalog/book-price.ts:9:34:
9 │ ...<span class="price">{{ book().price * (1 - book().discountRate) ...
Template'i düzeltince build yeşil. Ama asıl risk derleyicinin göremediği yerde: http.get<Book>(...) bir tip dönüşümü değil, derleyiciye verilen bir söz. API hâlâ yalnız price gönderiyorsa (henüz expand deploy edilmediyse ya da API geri alındıysa), runtime'da listPrice alanı yok. Bunu HttpTestingController ile eski API'nin cevabını taklit eden bir test gösteriyor:
FAIL |bookstore-web| src/app/catalog/book-api.spec.ts > BookApi > reads a book from an API that still sends only "price"
AssertionError: expected undefined to be 450 // Object.is equality
Template'te bu, ekranda NaN demek. İstemci tarafında da tolerant reader kuruyoruz: HTTP üzerinden gelen şekil (BookDto) ile uygulamanın kullandığı şekil (Book) ayrı tipler oluyor ve aradaki dönüşüm geçişi taşıyor:
// API taşınırken HTTP üzerinden gelen şekil: eski API sürümleri yalnız "price" gönderir.
export interface BookDto {
id: number;
title: string;
listPrice?: number;
price?: number;
discountRate: number;
}
export function toBook(dto: BookDto): Book {
const listPrice = dto.listPrice ?? dto.price;
if (listPrice === undefined) {
throw new Error(`Book ${dto.id} has neither listPrice nor price.`);
}
return { id: dto.id, title: dto.title, listPrice, discountRate: dto.discountRate };
}
Servis artık http.get<BookDto>(...).pipe(map(toBook)) yapıyor ve fiyat değişikliğinde { listPrice } gönderiyor. Testler üç durumu da kapsıyor: yalnız price, ikisi birden ve yeni alanla yazma. Sıralama önemli: Angular uygulaması API'nin expand sürümünden sonra deploy edilmeli, çünkü yazarken yeni alanı gönderiyor; okurken ise iki sürümle de çalışıyor. API'nin contract adımı en son geliyor. BookDto'daki price alanı da API contract'ı tamamlandıktan sonra silinmeyi bekleyen bir geçiş artığı. Template ve signal tarafındaki daha geniş modernleşme bir sonraki yazının konusu.
Veriyi Taşımak: DiscountRate'i Ayrı Tabloya
İkinci değişiklik bir rename değil, verinin yerinin değişmesi: indirimler tarih ve kampanya bilgisiyle büyüyecek, bu yüzden Books.DiscountRate kolonu BookDiscounts tablosuna taşınıyor. Kod tarafındaki expand-contract dört adım. İlk adımda tablo ekleniyor ve mevcut veri kopyalanıyor; migration'ı EF Core üretiyor, backfill'i biz ekliyoruz:
// Backfill: mevcut indirimleri yeni tabloya kopyala.
migrationBuilder.Sql("""
INSERT INTO dbo.BookDiscounts (BookId, Rate)
SELECT Id, DiscountRate FROM dbo.Books WHERE DiscountRate <> 0;
""");
Aynı sürümde yazan kod iki yere birden yazıyor; okuyan kod hâlâ eski kolondan okuyor:
// Dual write: eski kolon kaldırılana kadar kolon ve tablo eşit kalır.
public void SetDiscount(decimal rate)
{
DiscountRate = rate;
if (Discount is null)
Discount = new BookDiscount { BookId = Id, Rate = rate };
else
Discount.Rate = rate;
}
Okuyucuları taşımadan önce iki kaynağın gerçekten eşit olduğunu bir sorguyla kontrol ettik. İlk koşuda sıfır fark vardı. Ertesi gün aynı sorgu bir fark buldu:
SELECT COUNT(*) AS Mismatches
FROM dbo.Books b LEFT JOIN dbo.BookDiscounts d ON d.BookId = b.Id
WHERE b.DiscountRate <> ISNULL(d.Rate, 0);
Mismatches
----------
1
Pazarlama ekibinin kampanya script'i indirimleri doğrudan UPDATE dbo.Books SET DiscountRate = ... ile yazıyordu. Dual write yalnız uygulamanın kodunu kapsar; veriye uygulama dışından yazan herkes ayrıca bulunmalı. Script'i API'ye yönlendirdik ve farkı bir MERGE ile kapattık; sorgu tekrar 0 döndü.
Üçüncü adımda okuyucular taşındı: BookResponse.From ve CSV export'u indirimi book.Discount?.Rate ?? 0m'dan okuyor. Bu commit'te contract testi bir kez daha kırmızıya döndü:
failed Bookstore.Catalog.Tests.ContractTests.Book_json_has_only_listPrice_after_the_migration (155ms)
Expected: ···"le":"Dune","listPrice":450.00,"discountRate":0.10}"
Actual: ···"le":"Dune","listPrice":450.00,"discountRate":0}"
Uygulama kodu doğruydu; test verisini hazırlayan kod DiscountRate'i doğrudan set ediyordu ve migration'daki backfill test veritabanında tablo henüz boşken çalışmıştı. Test seed'i de bir writer. Seed'i SetDiscount üzerinden yazacak şekilde düzeltince yeşil oldu. Dördüncü adım contract: DiscountRate property'si silindi, EF Core migrationBuilder.DropColumn(name: "DiscountRate", table: "Books") üretti. Bu migration, kolonu okuyan eski sürüm tamamen kalktıktan sonraki bir deploy'da çalışır. Finansın view'ı da kolona bağlıydı; view önce BookDiscounts'a join yapacak şekilde güncellendi, kolon sonra silindi. Migration'ların hangi deploy'da çalışacağı ve geriye uyumlu şema kuralları Deployment Stratejileri serisinin "Migration'ların Deploy'daki Yeri: Expand-Contract, EF Core Migration Bundle ve Geriye Uyumlu Şema" yazısında; servisler arası contract'larda aynı fikir Microservices serisinin "Service Contract'ları ve Versioning: Expand-Contract, Tolerant Reader ve Consumer-Driven Contract Test" yazısında anlatılıyor.
Bütün Geçiş Commit Commit
$ git log --oneline
33bbf6d refactor!: contract discounts: drop Books.DiscountRate (deploy after the old version is gone)
feaa3a3 refactor: read discounts from BookDiscounts (API and CSV); test seed writes through SetDiscount
b627c42 feat: expand discounts: BookDiscounts table with backfill, discount changes write both
47346d0 feat: PUT /books/{id}/discount
4f1476e feat!: contract the book API: price removed from responses and price changes
7a37197 feat: expand the book contract: responses carry listPrice and price, price changes accept both
8059a94 refactor: rename Book.Price to ListPrice in code only; JSON field, column and CSV header unchanged, cache key v2
17be803 legacy: catalog API with Book.Price and Book.DiscountRate
Açılıştaki "sadece rename" beş satırlık bir diff'ti; aynı niyetin güvenli hâli yedi commit, Angular tarafında iki commit ve en az üç ayrı deploy. Fark, hiçbir adımda istemcinin, raporun ya da cache'in kırılmaması. Commit başlıklarındaki ! işareti, dışarıdan görünen bir contract'ın değiştiğini reviewer'a ve release notlarına söylüyor.
Sık Düşülen Tuzaklar
Cache key'ini unutmak. Serileştirilmiş bir nesnenin şekli değiştiğinde eski kayıtlar exception üretmez, sessizce yanlış değer üretir. Bu yazıda iki kez gördük: bir kez property adı, bir kez yalnız attribute'un harf büyüklüğü değişti. Cache'lenen şeklin değiştiği her commit, key'in sürümünü de değiştirmeli.
Raporları ve view'ları uygulamanın parçası saymamak. Veritabanına bağlanan BI (Business Intelligence; raporlama ve veri analizi) araçları, başka ekiplerin view'ları ve gece çalışan SQL job'ları migration'ları görmez. Kolon adı ya da yeri değişmeden önce sys.dm_sql_referencing_entities('dbo.Books', 'OBJECT') ile tabloya bağlı view, procedure ve function'ları listelemek ve sahipleriyle konuşmak gerekir; bizim veritabanımızda bu sorgu vw_BookNetPrices'ı hemen gösteriyor.
Dışa aktarılan dosyaları property adlarına bağlamak. Reflection ile üretilen CSV ya da Excel başlıkları her rename'de değişir. Başkasının okuduğu bir dosyanın kolonları açıkça yazılmalı ve bir testle sabitlenmeli.
Dual write'ı yalnız uygulama koduyla sınırlı sanmak. Kampanya script'i ve test seed'i aynı hatayı farklı yerlerde yaptı. İki kaynağı karşılaştıran sorguyu okuyucular taşınmadan önce ve bir süre boyunca düzenli çalıştır.
Contract adımını takvime göre yapmak. Eski alanı "iki sprint sonra" kaldırmak yerine, eski alanın kullanımını log'la ölç ve sıfıra inince kaldır. Mobil uygulama gibi güncellenmesi kullanıcıya bağlı istemciler varsa bu süre aylar sürebilir.
Küçük Bir Deneme
★☆☆ Kendi projende entity'yi doğrudan JSON olarak dönen bir endpoint bul. Bir property'yi rename et ve dotnet ef migrations add ile ne üretildiğine, API çıktısının nasıl değiştiğine bak. Sonra HasColumnName ve [JsonPropertyName] ile aynı rename'i yap ve dotnet ef migrations has-pending-model-changes'in ne dediğini gör.
★★☆ Paylaşılan cache'e nesne yazan bir kod bul. Eski şekilde bir JSON'u yeni tipe okuyan bir test yaz ve sonucu gör. Ardından cache key'lerine sürüm ekle ve aynı testin artık eski kaydı hiç okumadığını doğrula.
★★★ Bir kolonu ayrı bir tabloya taşımayı expand-contract ile planla: migration'lar, dual write, iki kaynağı karşılaştıran sorgu, okuyucuların taşınması ve kolonu silen migration. Her adımın hangi deploy'da çalışacağını yaz ve veritabanına uygulama dışından yazan herkesi listele.
Bu yazıda Angular tarafında yalnız bir modeli ve bir servisi değiştirdik. Bir sonraki yazıda Angular kodunun kendisini modernleştiriyoruz: NgModule'e bağlı, *ngIf ve iç içe subscribe kullanan eski CheckoutComponent'i Angular'ın resmi migration schematic'leriyle standalone, inject() ve signal'lere taşıyacak, her schematic'in çıktısını review edecek ve schematic'lerin yapamadığı kısmı elle dönüştüreceğiz.