Event Versioning ve Snapshot: Event Schema'sını Kırmadan Değiştirmek
Yazılmış event'ler değiştirilmez; yeni kod eski şekilleri tolerant deserialization, sabit JSON adları ve upcaster ile okur, snapshot ise ölçümle ve rebuild ile birlikte açılır. Sessizce sıfırlanan bir property rename'i, RespectRequiredConstructorParameters, Marten upcaster'ı, namespace taşırken Türkçe culture alias'ının kırılması, in-place migration'ın bedeli, snapshot serialization tuzağı ve closing the books.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Stok defteri altı aydır production'da. Kod incelemesinde biri StockAdjusted.Delta adının belirsiz olduğunu söylüyor; ekip onu QuantityChange yapmaya karar veriyor. Küçük, masum bir refactoring:
// v2: Delta artık QuantityChange
public sealed record StockAdjusted(StockItemId StockItemId, int QuantityChange, AdjustmentReason Reason, DateTimeOffset At)
: StockEvent(StockItemId, At);
Mart'ta açılmış bir Emma stok kaleminde dört event var: kayıt, 10 nüsha giriş, 2 hasarlı (Delta = -2) ve sayımda bulunan 1 nüsha (Delta = 1). Eski kodla stok 9 çıkıyordu. Yeni kod aynı stream'i şöyle okuyor:
int onHand = 0;
foreach (IEvent e in await query.Events.FetchStreamAsync(Guid.Parse(id)))
{
onHand += e.Data switch { StockReceived r => r.Quantity, StockAdjusted a => a.QuantityChange, _ => 0 };
Console.WriteLine($"v{e.Version} {e.EventTypeName,-21} {Describe(e.Data)}");
}
Console.WriteLine($"OnHand={onHand}");
Ne olur? Deserialization exception mı fırlar, OnHand 9 mu çıkar, yoksa başka bir şey mi?
Event'ler Neden Değiştirilemez?
Gerçek çıktı:
v1 stock_item_registered StockItemRegistered
v2 stock_received StockReceived
v3 stock_adjusted StockAdjusted { QuantityChange = 0, Reason = Damaged }
v4 stock_adjusted StockAdjusted { QuantityChange = 0, Reason = CountCorrection }
OnHand=10
Exception yok, uyarı yok, stok yanlış. Veritabanındaki JSON (JavaScript Object Notation) belgesinde hâlâ "Delta": -2 yazıyor; System.Text.Json yeni record'un constructor'ında QuantityChange parametresini bulamadı ve sessizce 0 verdi. Decider artık depoda olmayan bir nüshayı satılabilir sanıyor.
Bu örnek, event sourcing'de versioning'in neden ayrı bir konu olduğunu özetliyor. İlişkisel bir tabloda kolon adını değiştirirken bir migration yazar, veriyi de taşırsın; eski hâl geride kalır. Event store'da geçmiş, yazıldığı şekliyle kalıcıdır ve her rehydration, her projection rebuild'i onu bugünkü kodla yeniden okur. Bugünkü kod, şimdiye kadar yazılmış her event şeklini okuyabilmek zorunda.
"O zaman eski event'leri de güncelleyelim" fikri ilk akla gelendir ve neden son çare olduğunu aşağıda konuşacağız. Greg Young'ın Versioning in an Event Sourced System kitabı da "Why can't I update an event?" sorusuna ayrı bir bölüm ayırıyor ve tekniklerini bu kısıt üzerine kuruyor: versioned type'lar, weak schema (event'i esnek bir mapping'le okumak), eski event'leri okurken dönüştürmek ve en sonda copy-replace. Marten'in dokümantasyonu da aynı yerden başlıyor: geçmişi değiştirme, düzeltmeyi yeni bir event olarak yaz.
Tolerant Deserialization: Ekle, Silme, Yeniden Adlandırma
Kendini korumanın ilk yolu, sessiz hatayı yüksek sesli hataya çevirmek. .NET 9 ile gelen RespectRequiredConstructorParameters ayarı, JSON'da karşılığı olmayan constructor parametrelerini default'la doldurmak yerine hata verdirir:
// Eksik constructor parametresini sessizce default yapmak yerine hata ver (.NET 9+)
if (args.Contains("strict"))
opts.UseSystemTextJsonForSerialization(configure: json => json.RespectRequiredConstructorParameters = true);
EventDeserializationFailureException: Event deserialization error on sequence = 3 for event type stock_adjusted
JsonException: JSON deserialization for type 'Bookstore.Inventory.StockAdjusted' was missing required properties including: 'QuantityChange'.
Artık rename deploy'dan önce, testte ya da staging'de patlar. Bunun bir bedeli var: event'e field eklemek de artık bir karar gerektirir. Eklenen field'ı nullable yap (string? Note) ya da constructor'da default değer ver (string Note = ""); ikisi de eski event'leri okurken hata vermez. Kural basit ve Greg Young'ın weak schema dediği yaklaşımın özü: field eklemek güvenlidir, field silmek okuyucuyu kırmaz ama yazılmış veriyi anlamsızlaştırabilir, yeniden adlandırma ise aslında "sil ve ekle"dir.
Yeniden adlandırma gerçekten gerekiyorsa JSON'daki adı sabit tut, C# adını değiştir:
// JSON'daki ad değişmez; yalnızca C# tarafındaki ad değişir
public sealed record StockAdjusted(
StockItemId StockItemId,
[property: JsonPropertyName("Delta")] int QuantityChange,
AdjustmentReason Reason,
DateTimeOffset At) : StockEvent(StockItemId, At);
v1 stock_item_registered StockItemRegistered
v2 stock_received StockReceived
v3 stock_adjusted StockAdjusted { QuantityChange = -2, Reason = Damaged }
v4 stock_adjusted StockAdjusted { QuantityChange = 1, Reason = CountCorrection }
OnHand=9
Yeni event'ler de "Delta" adıyla yazılmaya devam eder. Marten'in dokümantasyonu bunun bir yan etkisini hatırlatıyor: event verisini LINQ (Language Integrated Query) ile sorgularken Marten C# adını kullanır, JSON'da ise eski ad durur; sorgu hata vermeden boş döner.
Event Tipinin Yeni Version'ı ve Upcasting
Bazen değişiklik bir field adından büyüktür. 05. yazıda yetmeyen stok için OutOfStock event'i üretiyorduk. Ürün durdurulduğunda da rezervasyonların reddedilmesi gerekince ekip bu event'i genelleştirmeye karar veriyor: yeni adı ReservationRejected ve red sebebini açıkça taşıyor.
// v2: OutOfStock'un yeni adı; red sebebi artık açık
public enum RejectionReason { OutOfStock, Discontinued }
public sealed record ReservationRejected(
StockItemId StockItemId, OrderId OrderId, int Requested, int Available, RejectionReason Reason, DateTimeOffset At)
: StockEvent(StockItemId, At);
Eski event'ler veritabanında out_of_stock adıyla ve sebep field'ı olmadan duruyor. Upcasting, bu eski şekli okuma anında yeni tipe çeviren bir ara katman. Marten'de eski C# class'ını kodda tutmak istemiyorsan JSON üzerinden çalışan bir upcaster yazarsın:
// Eski "out_of_stock" JSON'unu okurken yeni tipe çevirir; eski class koda gerekmez
public sealed class OutOfStockUpcaster : EventUpcaster<ReservationRejected>
{
public override string EventTypeName => "out_of_stock";
protected override ReservationRejected Upcast(JsonDocument oldEvent)
{
JsonElement e = oldEvent.RootElement;
return new ReservationRejected(
new StockItemId(e.GetProperty("StockItemId").GetProperty("Value").GetGuid()),
new OrderId(e.GetProperty("OrderId").GetProperty("Value").GetGuid()),
e.GetProperty("Requested").GetInt32(),
e.GetProperty("Available").GetInt32(),
RejectionReason.OutOfStock,
e.GetProperty("At").GetDateTimeOffset());
}
}
opts.Events.Upcast<OutOfStockUpcaster>();
Upcaster'ı kaydettikten sonra yeni kod bir ReservationRejected daha yazıyor ve stream'i okuyoruz:
v1 stock_item_registered StockItemRegistered
v2 stock_received StockReceived
v3 out_of_stock ReservationRejected (Requested=2, Available=1, Reason=OutOfStock)
v4 reservation_rejected ReservationRejected (Requested=5, Available=1, Reason=OutOfStock)
Uygulama kodu ve projection'lar yalnızca ReservationRejected'ı biliyor; switch'lerde OutOfStock dalı kalmadı. Veritabanında ise hiçbir şey değişmedi: v3 hâlâ out_of_stock adıyla, eski JSON'u ve Bookstore.Inventory.OutOfStock .NET tip adıyla duruyor. Audit açısından doğru olan da bu; o gün sistem gerçekten "stok yetmedi" demişti.
Upcaster'ın da kuralları var. Saf olmalı: Marten'in dokümantasyonu, upcaster'ın her deserialization'da çalıştığını ve içinden veritabanına ya da bir servise gitmenin N+1 problemine yol açacağını özellikle uyarıyor. Upcaster'lar zincirlenebilir (v1 → v2 → v3), ama her yeni version bir zincir halkası daha demek; bir event tipinin çok sık version değiştirmesi genelde modelleme sorununa işarettir.
Event Tipi Adı ve Namespace Taşımak
Event'in kimliği, JSON şekli kadar adıdır. Marten event'i type kolonundaki alias'la ve mt_dotnet_type'taki .NET tip adıyla saklıyor. Class'ın adını değiştirirsen opts.Events.MapEventType<YeniAd>("eski_alias") ile eski alias'ı yeni class'a bağlarsın. Namespace ya da assembly değişirse Marten'in dokümante ettiği yol event tiplerini AddEventTypes ile kaydetmek; alias class adından türediği için değişmez.
06. yazıda Türkçe culture'da üretilen stock_ıtem_registered alias'ını görmüştük. Şimdi bu ikisi buluşuyor. Ekip event'leri ayrı bir contracts assembly'sine taşıyor:
// Refactoring: event'ler ayrı bir contracts assembly'sine ve namespace'ine taşındı
namespace Bookstore.Inventory.Events;
public readonly record struct StockItemId(Guid Value);
public readonly record struct BookId(Guid Value);
public abstract record StockEvent(StockItemId StockItemId, DateTimeOffset At);
public sealed record StockItemRegistered(StockItemId StockItemId, BookId BookId, DateTimeOffset At) : StockEvent(StockItemId, At);
// Marten'in dokümante ettiği namespace taşıma yolu: tipleri kaydet, alias class adından türer
opts.Events.AddEventTypes([typeof(StockItemRegistered)]);
Taşımadan önce biri en-US, biri tr-TR culture'lı process'ten yazılmış iki stream'i okuyoruz:
en-US: 1 event(s), first is Bookstore.Inventory.Events.StockItemRegistered
tr-TR: UnknownEventTypeException
type | mt_dotnet_type
-----------------------+--------------------------------------------------------------
stock_item_registered | Bookstore.Inventory.StockItemRegistered, Bookstore.Inventory
stock_ıtem_registered | Bookstore.Inventory.StockItemRegistered, Bookstore.Inventory
İkinci satırın alias'ı kayıtlı hiçbir tiple eşleşmiyor, .NET tip adı da artık yok. İki yedek mekanizmanın ikisi de aynı anda boşa düştü. Bu sefer düzeltme gerçekten veritabanında yapılmalı:
UPDATE inventory_v.mt_events SET type = 'stock_item_registered' WHERE type = 'stock_ıtem_registered';
en-US: 1 event(s), first is Bookstore.Inventory.Events.StockItemRegistered
tr-TR: 1 event(s), first is Bookstore.Inventory.Events.StockItemRegistered
Dikkat: değiştirdiğimiz şey event'in içeriği değil, store'un ona verdiği etiket; olan biten aynı kalıyor. Bu yüzden kabul edilebilir bir müdahale. Daha iyisi hiç gerek bırakmamak: her event tipine baştan MapEventType ile sabit, ASCII (American Standard Code for Information Interchange) bir ad ver ve sunucu process'lerini sabit bir culture ile çalıştır. Event'in adını C# class'ının adına bağlamamak, rename ve taşıma gibi refactoring'leri versioning olayı olmaktan çıkarır.
Son Çare: In-Place Migration
PostgreSQL'de mt_events.data sıradan bir jsonb kolonu; UPDATE ... SET data = jsonb_set(...) ile Delta'yı QuantityChange yapmak bir satırlık iş. Neden son çare? Birincisi audit: event store'un vaadi "o gün ne olduysa o yazıyor"; payload'ı değiştirdiğin an bu vaat bozulur ve kimin, neyi, neden değiştirdiğinin kaydı event store'un dışında kalır. İkincisi tüketiciler: event'leri okuyan async projection'lar zaten işledikleri event'leri yeniden okumaz, read model'ler yeni şekli hiç görmez; dışarıya event yayınlıyorsan alıcılar eski şekli çoktan almıştır. Üçüncüsü operasyon: büyük bir tabloda toplu UPDATE uzun kilitler ve büyük bir WAL (Write-Ahead Log) üretir. Marten'in archiving dokümantasyonu da event'leri SQL (Structured Query Language) ile değiştirmenin mümkün ama dikkat gerektiren bir yol olduğunu açıkça söylüyor.
Payload'ı gerçekten dönüştürmek gerekiyorsa Greg Young'ın copy-replace'i daha dürüst bir yol: stream'i dönüştürerek yeni bir stream'e kopyala, eskisini arşivle; Marten'in dokümantasyonunda bunun bir "copy and transform stream" senaryosu var. Geçmiş hâlâ yerinde, yeni stream de nereden geldiğini biliyor.
Snapshot: Ne Zaman Gerekir?
06. yazıda Live aggregate'in her command'da bütün stream'i okuduğunu ölçmüştük. Şimdi aynı şeyi command seviyesinde ölçelim: 20.001 event'lik bir stream'e ReserveStock. Snapshot, aggregate'in belli bir version'daki hâlinin saklanmış kopyası; Marten'de bu, write model projection'ını Live yerine Inline kaydetmek demek:
// Live yerine Inline: her SaveChanges'te StockItem document'i de güncellenir
opts.Projections.Add(new StockItemProjection(), snapshot ? ProjectionLifecycle.Inline : ProjectionLifecycle.Live);
if (args.Contains("converter"))
opts.UseSystemTextJsonForSerialization(configure: json => json.Converters.Add(new OrderIdJsonConverter()));
İlk denemede command hiç çalışmadı:
Unhandled exception. System.NotSupportedException: The type 'Bookstore.Inventory.OrderId' is not a supported dictionary key using converter of type 'System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1[Bookstore.Inventory.OrderId]'. Custom converters can add support for dictionary key serialization by overriding the 'ReadAsPropertyName' and 'WriteAsPropertyName' methods. The unsupported member type is located on type 'Bookstore.Inventory.Reservation'. Path: $.State.Reservations.
Live aggregate hiç serialize edilmediği için StockState'in rezervasyonları OrderId key'li bir dictionary'de tutması bugüne kadar sorun olmamıştı. Snapshot ise state'i JSON'a yazmak zorunda ve System.Text.Json strongly typed id'yi dictionary key'i olarak tanımıyor. İki çözüm var: key'i Guid yapmak ya da bir converter yazmak. Domain'e dokunmamak için converter'ı seçtim:
// Snapshot'ta OrderId dictionary key'i olarak yazılabilsin; değer olarak eski {"Value": ...} şekli korunur
public sealed class OrderIdJsonConverter : JsonConverter<OrderId>
{
public override OrderId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using JsonDocument document = JsonDocument.ParseValue(ref reader);
return new OrderId(document.RootElement.GetProperty("Value").GetGuid());
}
public override void Write(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("Value", value.Value);
writer.WriteEndObject();
}
public override OrderId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new(Guid.Parse(reader.GetString()!));
public override void WriteAsPropertyName(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options) =>
writer.WritePropertyName(value.Value.ToString());
}
opts.UseSystemTextJsonForSerialization(configure: json => json.Converters.Add(new OrderIdJsonConverter())) ile kaydettikten sonra ölçüm (aynı makine, her satır ayrı bir command):
live: ReserveStock on a 20,001-event stream took 43 ms
live: ReserveStock on a 20,001-event stream took 44 ms
live: ReserveStock on a 20,001-event stream took 45 ms
snapshot: ReserveStock on a 20,001-event stream took 5 ms
snapshot: ReserveStock on a 20,001-event stream took 2 ms
snapshot: ReserveStock on a 20,001-event stream took 1 ms
Fark büyük, ama bir tuzak daha var. Snapshot'ı açtığında, Live döneminde açılmış stream'lerin henüz snapshot document'i yoktur:
// Live döneminde açılmış bir stream; Inline'a geçtikten sonra ilk command
StockItemId old = new(Guid.Parse(args[1]));
if (args.Contains("rebuild"))
{
using IProjectionDaemon daemon = await store.BuildProjectionDaemonAsync();
await daemon.RebuildProjectionAsync<StockItemProjection>(CancellationToken.None);
}
await using (IQuerySession query = store.QuerySession())
Console.WriteLine($"Snapshot document exists: {await query.LoadAsync<StockItem>(old.Value) is not null}");
try
{
IReadOnlyList<StockEvent> events = await handler.HandleAsync(new ReserveStock(old, new OrderId(Guid.CreateVersion7()), 1, at));
Console.WriteLine($"Decided: {events.Single().GetType().Name}");
}
catch (DomainException ex)
{
Console.WriteLine($"{ex.Code}: {ex.Message}");
}
Snapshot document exists: False
stock.not-registered: Stock item is not registered.
Snapshot document exists: True
Decided: StockReserved
İlk koşuda FetchForWriting document bulamadı ve aggregate'i null döndürdü; decider da başlangıç state'ini görüp stok kaleminin kayıtlı olmadığını söyledi. Bu sefer command reddedildi; başka bir kuralda yanlış bir event yazılabilirdi. İkinci koşuda önce projection rebuild edildi ve command doğru çalıştı. Lifecycle değiştirmek, bir deploy adımı olarak rebuild ile birlikte yapılmalı.
Snapshot'ın sıklığı da bir trade-off. Inline snapshot her command'da document'i yeniden yazar: okuma ucuzlar, yazma pahalılaşır. Async snapshot daemon tarafından güncellenir ve command'ı hiç yavaşlatmaz; Marten'in dokümantasyonuna göre FetchForWriting lifecycle ne olursa olsun command'a tutarlı state verir. Bir kural daha: snapshot source of truth değildir. Silinebilir, yeniden üretilebilir ve şekli değiştiğinde (az önceki converter gibi) rebuild edilir. Snapshot'ı "ne zaman" sorusunun cevabı ölçümdür: stream'lerin büyük çoğunluğu birkaç yüz event'te kalıyorsa Live yeterli; 06. yazıdaki gibi binlerce event'e uzanan stream'ler varsa önce stream'i kısaltmayı düşün, sonra snapshot'ı.
Stream'i Kısa Tutmak: Closing the Books
Muhasebede yıl sonunda defter kapanır ve yeni yıl bir devir bakiyesiyle açılır; kimse 2019'un bütün fişlerini toplayıp bugünkü kasayı hesaplamaz. Event sourcing'de "closing the books" aynı fikir: bir stream'i iş açısından anlamlı bir dönemle sınırlamak. Stok defterinde bu dönem yıllık sayım olabilir. Sayım günü eski stream bir StockCounted event'iyle kapanır ve arşivlenir, yeni dönemin stream'i sayılan miktarla açılır. 06. yazıdaki zaman dilimli stream id stratejisi (stock-978-0441172719-2026) bunun için vardı.
Bunun snapshot'tan farkı, kısalmanın domain'in kendi kavramı olması: devir bakiyesi bir teknik optimizasyon değil, işin gerçekten yaptığı bir şey. Marten'de kapanmış stream'i session.Events.ArchiveStream(id) ile arşivlersin; event'ler silinmez, is_archived olarak işaretlenir ve async daemon onları varsayılan olarak atlar. Marten 8 ile gelen CompactStreamAsync ise daha agresif: eski event'leri tek bir Compacted<T> snapshot event'iyle değiştirir ve bir archiver vermezsen eski event'ler gerçekten silinir. Audit'in önemli olduğu bir defterde bunu kullanmadan önce iki kez düşün.
Sık Düşülen Tuzaklar
Event'i C# class'ına sıkı bağlamak
Bu yazıdaki üç sorunun ortak kökü: event'in kalıcı kimliği (alias'ı ve JSON field adları) C# class'ının adından ve property'lerinden türüyordu. Class'ı yeniden adlandırmak, property'yi değiştirmek ya da namespace taşımak sıradan refactoring'ler; event tiplerinde ise birer versioning olayı. Event'leri ayrı bir dosyada ya da contracts klasöründe tut, adlarını MapEventType ile sabitle ve bu klasördeki değişiklikleri kod incelemesinde ayrıca işaretle.
Upcaster'ı unutup eski tipi silmek
OutOfStock class'ını upcaster yazmadan silersen, stream'i okuyan ilk istek UnknownEventTypeException alır; async daemon ise varsayılan olarak bilinmeyen event'i atlar ve read model sessizce eksik kalır (07. yazıda gördüğümüz hata atlama varsayılanlarından biri olan SkipUnknownEvents). Eski bir event tipini silmeden önce veritabanında o alias'la kaç event olduğuna bak.
Snapshot'ı source of truth sanmak
Snapshot document'ini elle düzeltmek ya da başka bir servisin ona doğrudan yazması, event'lerle çelişen bir state üretir ve ilk rebuild'de kaybolur. Düzeltme her zaman yeni bir event'tir.
Küçük Bir Deneme
★☆☆ StockReceived'a string? DeliveryNote field'ını ekle ve eski stream'leri RespectRequiredConstructorParameters = true ile oku. Hata alıyor musun? Field'ı nullable olmayan string yapınca ne değişiyor?
★★☆ OutOfStockUpcaster'ı JSON yerine eski OutOfStock record'unu kullanan EventUpcaster<OutOfStock, ReservationRejected> olarak yeniden yaz. Hangisini production'da tercih ederdin, neden?
★★★ Yıllık sayım için closing the books akışını uygula: bir command eski stream'e StockCounted ekleyip arşivlesin ve aynı transaction'da yeni yılın stream'ini sayılan miktarla açsın. Bir kitabın güncel stream id'sini nereden bulacaksın?
Event'lerin şeklini ve stream'lerin boyunu yönetmeyi öğrendik. Ama bazı sorunlar bir stream'in sınırına hiç sığmaz: bir müşteri verisinin silinmesini istediğinde değiştirilemez event'lere ne olacak, iki stream'e yayılan bir benzersizlik kuralı nasıl korunacak? Bir sonraki yazı event sourcing'in bu zor konularını ve Dynamic Consistency Boundary'yi ele alıyor.