Event Sourcing Nedir? State Yerine Olanları Saklamak
Event Sourcing, son durumu değil olanları append-only bir event stream'inde saklayıp state'i bu event'lerden fold ederek türetmektir. Bookstore'un stok defteriyle rehydration, audit, geçmişe dönük okuma, expected version ile optimistic concurrency, event store'un neden bir message broker olmadığı, CQRS ile neden birlikte kullanıldığı ve gerçek maliyetleri.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Kasım'ın ilk günü depo ekibi sayım yaptı: rafta Dune'dan 37 nüsha var, sistem 40 diyor. Müşteri hizmetleri de soruyor, çünkü geçen hafta "stokta var" görünen bir siparişi kargoya veremediler. Bookstore'un Inventory context'indeki StockItem bugün şöyle saklanıyor:
namespace Bookstore.Inventory.StateBased;
// Klasik yaklaşım: tabloda yalnızca son durum
public sealed class StockItem(Guid id, Guid bookId)
{
public Guid Id { get; } = id;
public Guid BookId { get; } = bookId;
public int OnHand { get; private set; }
public int Reserved { get; private set; }
public int Available => OnHand - Reserved;
public void Receive(int quantity) => OnHand += quantity;
public bool TryReserve(int quantity)
{
if (quantity > Available)
return false;
Reserved += quantity;
return true;
}
public void Release(int quantity) => Reserved -= quantity;
public void Ship(int quantity) => (OnHand, Reserved) = (OnHand - quantity, Reserved - quantity);
// Sayım düzeltmesi: eski değerin üzerine yazar
public void CorrectCount(int counted) => OnHand = counted;
}
Veritabanında bu kitap için tek bir satır var: OnHand = 40, Reserved = 0. Şu soruların hangisini bu satırdan cevaplayabilirsin? Üç nüshalık fark ne zaman oluştu? Biri sayımı elle mi düzeltti, düzelttiyse ne zaman ve neden? 22 Ekim öğlen müşteriye "stokta var" dediğimizde gerçekte kaç nüsha müsaitti? Ekim'de süresi dolduğu için bırakılan kaç rezervasyon oldu?
State Yerine Olanlar: Event, Stream ve Append-Only Log
Cevap: hiçbiri. Satır yalnızca son durumu biliyor; oraya nasıl gelindiğini her UPDATE sildi. CorrectCount ise en sinsi olanı: eski değerin üstüne yazıyor ve ardında iz bırakmıyor.
Event Sourcing bu tabloyu tersine çevirir. Martin Fowler 2005'teki yazısında onu, uygulama state'indeki bütün değişiklikleri bir event dizisi olarak yakalamak diye tanımlar. Saklanan şey son durum değil, olanların kendisidir; son durum gerektiğinde bu olgulardan türetilir. Greg Young CQRS (Command Query Responsibility Segregation) Documents'ta en tanıdık örneği verir: muhasebe defteri. Defterde her işlem bir satırdır, bakiye bu satırların toplamıdır ve yanlış bir kayıt silinmez, onu tersine çeviren yeni bir kayıtla (reversal) düzeltilir. Dune'un stoğu için olgular şunlar:
namespace Bookstore.Inventory;
public readonly record struct StockItemId(Guid Value);
public readonly record struct BookId(Guid Value);
public readonly record struct OrderId(Guid Value);
public enum ReleaseReason { OrderCancelled, Expired }
public enum AdjustmentReason { CountCorrection, Damaged, Lost }
// Olanı, iş diliyle anlatan event'ler; hiçbiri "yeni değer şu" demiyor
public abstract record StockEvent(StockItemId StockItemId, DateTimeOffset At);
public sealed record StockItemRegistered(StockItemId StockItemId, BookId BookId, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record StockReceived(StockItemId StockItemId, int Quantity, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record StockReserved(StockItemId StockItemId, OrderId OrderId, int Quantity, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record OutOfStock(StockItemId StockItemId, OrderId OrderId, int Requested, int Available, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record ReservationReleased(StockItemId StockItemId, OrderId OrderId, int Quantity, ReleaseReason Reason, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record ReservedStockShipped(StockItemId StockItemId, OrderId OrderId, int Quantity, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record StockAdjusted(StockItemId StockItemId, int Delta, AdjustmentReason Reason, DateTimeOffset At) : StockEvent(StockItemId, At);
Her event geçmiş zamanlı bir olgu ve iş dilinde konuşuyor. Microsoft Learn'deki Event Sourcing pattern sayfası bu ayrımı bir rezervasyon örneğiyle anlatır: "iki koltuk rezerve edildi" event'i, "kalan koltuk 42 oldu" event'inden çok daha değerlidir; ilki ne olduğunu, ikincisi yalnızca sonucunu söyler. Bizde de StockReserved(OrderId, Quantity: 2) var, StockLevelChanged(NewAvailable: 42) yok. Sonucu söyleyen event'lerle kurulmuş bir event store, iş anlamı olmayan bir değişiklik log'una dönüşür.
Bir StockItem'a ait event'lerin sıralı dizisine stream denir. Stream yalnızca sona ekleme (append) kabul eder: olmuş bir şey sonradan değişmez, silinmez. Bir aggregate'in, bu yazıda bir stok kaleminin, her event'i stream'de bir sürüm numarası alır.
Rehydration: State'i Event'lerden Türetmek
State'i event'lerden türetmek bir fold işlemidir: başlangıç state'inden yola çıkıp event'leri sırayla uygularsın. Bu işleme rehydration denir:
using System.Collections.Immutable;
namespace Bookstore.Inventory;
public sealed record StockState(int OnHand, ImmutableDictionary<OrderId, int> Reservations)
{
public static readonly StockState Initial = new(0, ImmutableDictionary<OrderId, int>.Empty);
public int Reserved => Reservations.Values.Sum();
public int Available => OnHand - Reserved;
// Rehydration: state, event'lerin sırayla uygulanmasıyla (fold) türetilir
public static StockState From(IEnumerable<StockEvent> history) => history.Aggregate(Initial, Evolve);
public static StockState Evolve(StockState state, StockEvent e) => e switch
{
StockReceived r => state with { OnHand = state.OnHand + r.Quantity },
StockReserved r => state with { Reservations = state.Reservations.Add(r.OrderId, r.Quantity) },
ReservationReleased r => state with { Reservations = state.Reservations.Remove(r.OrderId) },
ReservedStockShipped s => new StockState(state.OnHand - s.Quantity, state.Reservations.Remove(s.OrderId)),
StockAdjusted a => state with { OnHand = state.OnHand + a.Delta },
_ => state // StockItemRegistered, OutOfStock: sayıları değiştirmez
};
}
Evolve saf bir fonksiyon: bir state ve bir event alıp yeni bir state döndürüyor, hiçbir şeyi değiştirmiyor, dışarıya hiçbir şey sormuyor. StockItemRegistered ve OutOfStock sayıları değiştirmediği için state'e etki etmiyor ama stream'de duruyor; ikisi de birilerinin bilmek isteyeceği olgular. Dune'un Ekim ayını stream'e yazıp okuyalım:
IReadOnlyList<StoredEvent> history = store.ReadStream(dune.Value);
foreach (StoredEvent s in history)
Console.WriteLine($"v{s.Version,-2} {s.Data.At:yyyy-MM-dd HH:mm} {Describe(s.Data)}");
StockState now = StockState.From(history.Select(s => s.Data));
Console.WriteLine($"Now: OnHand={now.OnHand}, Reserved={now.Reserved}, Available={now.Available}");
v1 2026-10-01 08:00 StockItemRegistered
v2 2026-10-01 08:05 StockReceived +50
v3 2026-10-14 18:30 StockAdjusted +3 (CountCorrection)
v4 2026-10-20 10:00 StockReserved 5 for order 4302
v5 2026-10-21 09:00 ReservedStockShipped 5 for order 4302
v6 2026-10-22 11:00 StockReserved 2 for order 3dc8
v7 2026-10-22 11:30 ReservationReleased 2 (Expired)
v8 2026-10-25 14:00 StockReserved 4 for order a95c
v9 2026-10-26 09:00 ReservedStockShipped 4 for order a95c
v10 2026-10-28 16:00 StockReserved 3 for order 8f32
v11 2026-10-28 16:20 ReservationReleased 3 (OrderCancelled)
v12 2026-10-30 12:00 StockReserved 4 for order 00dc
v13 2026-10-31 09:00 ReservedStockShipped 4 for order 00dc
Now: OnHand=40, Reserved=0, Available=40
Son durum yine 40, ama artık nereden geldiği belli. Üçüncü satır açılıştaki soruların ilkini cevaplıyor: 14 Ekim akşamı biri sayım düzeltmesiyle stoğa 3 nüsha eklemiş. Rafta 37 nüsha olması, o düzeltmenin hatalı olduğunu düşündürüyor; doğru düzeltme yeni bir StockAdjusted(-3, CountCorrection) event'i olacak, v3 silinmeyecek.
Event Sourcing'in Kazandırdıkları
Aynı stream'den, hiçbiri önceden planlanmamış iki soru daha:
// Geçmişe dönük okuma: aynı fold, belirli bir ana kadar
StockState then = StockState.From(history.Select(s => s.Data).Where(x => x.At <= T("2026-10-22 11:15")));
Console.WriteLine($"As of 2026-10-22 11:15: OnHand={then.OnHand}, Reserved={then.Reserved}");
// Kimsenin önceden planlamadığı soru, var olan event'lerden cevaplanır
ReservationReleased[] expired = [.. history.Select(s => s.Data).OfType<ReservationReleased>().Where(r => r.Reason == ReleaseReason.Expired)];
Console.WriteLine($"Expired reservations in October: {expired.Length} ({expired.Sum(r => r.Quantity)} copies)");
Bookstore.Inventory.StateBased.StockItem row = new(dune.Value, Guid.CreateVersion7());
row.Receive(50); row.CorrectCount(53);
row.TryReserve(5); row.Ship(5); row.TryReserve(2); row.Release(2); row.TryReserve(4); row.Ship(4);
row.TryReserve(3); row.Release(3); row.TryReserve(4); row.Ship(4);
Console.WriteLine($"State-based row: OnHand={row.OnHand}, Reserved={row.Reserved}");
As of 2026-10-22 11:15: OnHand=48, Reserved=2
Expired reservations in October: 1 (2 copies)
State-based row: OnHand=40, Reserved=0
Geçmişe dönük okuma (Fowler'ın "temporal query" dediği şey), aynı fold'u belirli bir ana kadar çalıştırmaktan ibaret: 22 Ekim 11:15'te 48 nüsha raftaydı ve 2'si bir siparişe ayrılmıştı. Süresi dolan rezervasyonları sayan soru ise, bu ekranı kimse istemediği hâlde var olan event'lerden cevaplanıyor. Yarın biri "kampanya haftasında hangi saatlerde rezervasyonlar düştü" diye sorarsa, yeni bir read model yazıp geçmişin tamamını ona oynatabilirsin; veri zaten orada. Aynı işlemleri state-based StockItem'a uygulayınca elde kalan tek şey iki sayı.
Bu kazanımların üçü öne çıkıyor: audit (kim, ne zaman, ne yaptı sorusunun cevabı verinin kendisi), geçmişe dönük okuma ve geçmişten yeni read model türetebilmek. Test ve hata ayıklama da kolaylaşır: bir hatayı yeniden üretmek için o stream'in event'lerini bir test'e kopyalamak yeterli. Bunu bir sonraki yazıda Given-When-Then test'leriyle yapacağız.
Event Store ve Optimistic Concurrency
Event'leri saklayan yere event store denir. Bir event store'dan beklenen üç şey var: stream'i id ile sırasıyla okumak, stream'e append etmek ve append'i, okuduğun andaki sürüm hâlâ geçerliyse kabul etmek. Sonuncusu optimistic concurrency'dir ve event sourcing'in kilitsiz çalışmasının anahtarıdır. Öğretim amaçlı bellek içi bir event store yazalım:
namespace Bookstore.Inventory;
public sealed record StoredEvent(Guid StreamId, long Version, long Position, StockEvent Data);
public sealed class WrongExpectedVersionException(Guid streamId, long expected, long actual)
: Exception($"Stream {streamId}: expected version {expected}, actual {actual}.");
// Öğretim amaçlı, bellek içi event store: append-only, stream başına sürüm, global sıra
public sealed class InMemoryEventStore
{
private readonly Lock _lock = new();
private readonly Dictionary<Guid, List<StoredEvent>> _streams = [];
private readonly List<StoredEvent> _all = [];
public IReadOnlyList<StoredEvent> ReadStream(Guid streamId)
{
lock (_lock)
return _streams.TryGetValue(streamId, out List<StoredEvent>? stream) ? [.. stream] : [];
}
public IReadOnlyList<StoredEvent> ReadAll()
{
lock (_lock)
return [.. _all];
}
// expectedVersion: okuduğun andaki sürüm; arada biri yazdıysa append reddedilir
public long Append(Guid streamId, long expectedVersion, params IReadOnlyList<StockEvent> events)
{
lock (_lock)
{
List<StoredEvent> stream = _streams.TryGetValue(streamId, out List<StoredEvent>? s) ? s : _streams[streamId] = [];
if (stream.Count != expectedVersion)
throw new WrongExpectedVersionException(streamId, expectedVersion, stream.Count);
foreach (StockEvent e in events)
{
StoredEvent stored = new(streamId, stream.Count + 1, _all.Count + 1, e);
stream.Add(stored);
_all.Add(stored);
}
return stream.Count;
}
}
}
Stok ayırmak isteyen bir command handler dört adımda çalışır: stream'i oku, state'i türet, karar ver, okuduğun sürümle append et:
namespace Bookstore.Inventory;
public sealed class ReserveStockHandler(InMemoryEventStore store, TimeProvider clock)
{
public StockEvent Handle(StockItemId id, OrderId orderId, int quantity)
{
// 1) Stream'i oku, 2) state'i türet, 3) karar ver, 4) okuduğun sürümle append et
IReadOnlyList<StoredEvent> stream = store.ReadStream(id.Value);
StockState state = StockState.From(stream.Select(e => e.Data));
StockEvent result = quantity <= state.Available
? new StockReserved(id, orderId, quantity, clock.GetUtcNow())
: new OutOfStock(id, orderId, quantity, state.Available, clock.GetUtcNow());
store.Append(id.Value, expectedVersion: stream.Count, result);
return result;
}
}
Kampanyanın en yoğun anını canlandıralım: The Hobbit'ten son 2 nüsha kaldı ve Ada ile Alan aynı anda ikişer nüsha istiyor.
namespace Bookstore.Inventory;
public static class Concurrency
{
public static void Run()
{
InMemoryEventStore store = new();
StockItemId hobbit = new(Guid.CreateVersion7());
store.Append(hobbit.Value, 0, new StockItemRegistered(hobbit, new BookId(Guid.CreateVersion7()), DateTimeOffset.UtcNow),
new StockReceived(hobbit, 2, DateTimeOffset.UtcNow));
// İki istek aynı anda okur: ikisi de sürüm 2'yi ve 2 müsait nüshayı görür
IReadOnlyList<StoredEvent> seenByAda = store.ReadStream(hobbit.Value);
IReadOnlyList<StoredEvent> seenByAlan = store.ReadStream(hobbit.Value);
Console.WriteLine($"Both see version {seenByAda.Count} with {StockState.From(seenByAda.Select(s => s.Data)).Available} available");
store.Append(hobbit.Value, seenByAda.Count, new StockReserved(hobbit, new OrderId(Guid.CreateVersion7()), 2, DateTimeOffset.UtcNow));
try
{
store.Append(hobbit.Value, seenByAlan.Count, new StockReserved(hobbit, new OrderId(Guid.CreateVersion7()), 2, DateTimeOffset.UtcNow));
}
catch (WrongExpectedVersionException ex)
{
Console.WriteLine(ex.Message);
}
// Retry: yeniden oku, yeniden karar ver
StockEvent retried = new ReserveStockHandler(store, TimeProvider.System).Handle(hobbit, new OrderId(Guid.CreateVersion7()), 2);
Console.WriteLine($"Retry result: {retried.GetType().Name}");
}
}
Both see version 2 with 2 available
Stream 01a1b846-3cf4-7f69-bf0b-83eec7d0431e: expected version 2, actual 3.
Retry result: OutOfStock
İki istek de sürüm 2'yi ve 2 müsait nüshayı gördü. Ada'nınki önce yazıldı ve stream sürüm 3'e çıktı; Alan'ın append'i "sürüm 2 bekliyordum, 3 buldum" diye reddedildi. Retry stream'i yeniden okudu, müsait nüsha kalmadığını gördü ve bu kez bir OutOfStock event'i yazdı. Satır kilidi yok, transaction boyunca beklemek yok; çakışma yalnızca gerçekten aynı stream'e aynı anda yazıldığında ortaya çıkıyor. DDD (Domain-Driven Design) serisinde Entity Framework Core (EF Core) ile Version concurrency token'ı kullanarak yaptığımızın event store'daki karşılığı bu.
Gerçek bir event store bu sözleşmeyi kalıcı ve dağıtık bir ortamda sağlar. .NET dünyasında iki yaygın seçenek var: PostgreSQL üstünde çalışan, açık kaynak Marten ve amaca özel bir veritabanı olan KurrentDB (eski adıyla EventStoreDB). Serinin altıncı yazısında Marten ile gerçek bir event store'a geçeceğiz.
Event Store Bir Message Broker Değildir
Event kelimesi ortak diye event store ile Kafka gibi bir message broker sık karıştırılır. Microsoft Learn bu konuda açık bir uyarı yapar: Kafka gibi broker'lar genellikle entity başına stream sorgusu ve optimistic concurrency sunmaz; event'leri projection'lara ve dış tüketicilere dağıtmak için iyi bir katmandır ama event store'un yerine geçmez. Pratikte fark şu: Kafka'da bir topic'e "bu stok kaleminin son sürümü hâlâ 2 ise yaz" diyemezsin ve tek bir stok kaleminin geçmişini okumak için o kalemin event'lerini topic'in tamamı içinde aramak zorunda kalırsın. Yukarıdaki çakışma senaryosunu bir broker'la çözmeye çalışan ekip, kilidi sonunda başka bir veritabanında kurar.
Bunun tersi de geçerli: event store'daki event'ler bir bounded context'in iç verisidir. Onları olduğu gibi başka context'lere integration contract olarak açmak, iç modelini değiştirmeyi imkânsız hâle getirir. Dışarıya ayrı, kararlı integration event'leri yayınlanır; bu ayrımı ilerideki Messaging ve Event-Driven Architecture serisi ayrıntılı işleyecek.
CQRS ile Neden Birlikte?
Event store bir stream'i id ile okumakta çok iyi, başka hiçbir şeyde değil. "Müsait stoğu 5'in altına düşen kitaplar" sorusunu event store'a soramazsın; her stok kaleminin stream'ini okuyup fold etmen gerekir. Bu yüzden event sourcing kullanan sistemlerin neredeyse hepsi CQRS da kullanır: write tarafı event store, read tarafı event'lerden beslenen projection'lar. Serinin üçüncü yazısında kurduğumuz projector'ın aynısı, bu kez kaynağını write tarafındaki bir event log'dan değil, doğrudan event store'dan alır. Greg Young da Documents'ta ikilinin ilişkisini simbiyotik diye anlatır: event sourcing'li bir domain'de repository'nin tek sorgusu id ile getirmek olduğundan, okuma ihtiyaçları ister istemez ayrı bir modele gider. Microsoft Learn'deki CQRS sayfası da aynı birleşimi tarif eder: event store write modeli ve tek source of truth'tur, read modeli event'lerden materialized view'lar üretir.
Bookstore'da Aday: Stok Defteri
Bookstore'da event sourcing'e gerçekten aday tek yer Inventory'nin stok defteri. Gerekçeler bu yazının açılışında: sayım farkı ve kayıp nüsha soruları bir audit ister; kampanya zirvesindeki dakikada ~120 siparişin büyük kısmı birkaç popüler kitaba yığılır ve optimistic concurrency'nin stream seviyesindeki ince taneli çakışma kontrolü tam bunun için; event'ler de zaten DDD serisinde keşfedildi (StockReserved, OutOfStock). Ordering'deki Order için ise CRUD (Create, Read, Update, Delete) artı domain event'ler yeterli; siparişin geçmişini soran bir iş ihtiyacı yok ve son durumu bir tabloda tutmak her raporu kolaylaştırıyor. Catalog'daki kitap kayıtları neredeyse statik referans verisi. Payments'ta para defteri zaten ödeme sağlayıcısında tutuluyor; bizim tarafımız bir adapter.
Bedeller de gerçek. Event'ler sonsuza kadar yaşadığı için şemalarını değiştirmek bir versioning stratejisi ister. Ad-hoc sorgu yoktur, her yeni soru bir projection'dır ve projection'lar eventual consistent'tır. Kişisel verinin silinmesi gerektiğinde append-only bir log ile KVKK (Kişisel Verilerin Korunması Kanunu) ve GDPR (General Data Protection Regulation) arasında bir gerilim doğar. Ve ekip, test etme, hata ayıklama ve production'da veri düzeltme alışkanlıklarını baştan öğrenir: "şu satırı UPDATE ile düzeltelim" artık bir seçenek değil. Bu konuların her biri serinin sekizinci ve dokuzuncu yazılarında.
Sık Düşülen Tuzaklar
Sonucu söyleyen event'ler
StockLevelChanged(NewOnHand: 40) ya da StockItemUpdated(...) gibi event'ler, CRUD'ı event kılığına sokar. Yeni bir read model türetmek istediğinde elinde yalnızca bir sayı dizisi olur; "neden değişti" sorusunun cevabı yine kaybolmuştur. Event adı bir domain expert'in cümlesinde geçebilmeli: "14 Ekim'de sayım düzeltmesi yapıldı".
Event'i düzeltmek için güncellemek
Hatalı bir StockAdjusted(+3) event'ini UPDATE ile +0 yapmak, audit'in bütün değerini yok eder ve o event'i zaten tüketmiş projection'ları sessizce tutarsız bırakır. Düzeltme, Young'ın "reversal" dediği yeni bir event'tir: StockAdjusted(-3, CountCorrection).
Rehydration'da dış dünyaya dokunmak
Evolve içinde e-posta göndermek ya da başka bir servisi çağırmak, her rehydration'da bu çağrının tekrarlanması demek. Fowler 2005'te de replay sırasında dış sistemlere giden çağrıların bir gateway ile bastırılması gerektiğini anlatır. Evolve saf kalır; yan etkiler event'i dinleyen ayrı bir consumer'ın işidir.
Bütün sisteme event sourcing
Microsoft Learn'deki uyarı net: event sourcing ya hep ya hiç bir karar değildir; bir ödeme defteri ya da sipariş işleme hattı gibi en çok fayda sağladığı parçalara uygulanır, kullanıcı profili ya da uygulama ayarları gibi yerlerde klasik CRUD kalır.
Ne Zaman Kullanma
Microsoft Learn event sourcing'in uygun olmayabileceği durumları açıkça sıralar: audit, replay ya da geçmişe dönük okuma gerektirmeyen basit CRUD işlemleri; prototipler, MVP'ler (minimum viable product) ve ömrü kısa sistemler; view'larda gerçek zamanlı tutarlılık şartı olan sistemler; çoğunlukla statik referans verisi; event-driven mimaride deneyimi olmayan ekipler. Aynı sayfa bir event sourcing çözümüne girmenin ve ondan çıkmanın pahalı olduğunu da vurgular. Bookstore'da üç developer'lık ekip için bu, event sourcing'i yalnızca stok defterine uygulamak ve ilk sürümü hâlâ tek bir PostgreSQL'de tutmak demek. Elindeki soru "kim, ne zaman, neden değiştirdi" değilse ve audit bir log tablosuyla yetiyorsa, event sourcing'e ihtiyacın yok.
Küçük Bir Deneme
★☆☆ Stream'e, depo ekibinin 1 Kasım'daki sayımını yansıtan doğru event'i ekle ve son durumun 37'ye indiğini çıktıda gör. v3'ü silmediğin hâlde iki sorunun ("14 Ekim'de ne oldu?", "şimdi kaç nüsha var?") cevabı da duruyor mu?
★★☆ Bütün stok kalemleri için "Ekim'de kargoya verilen nüsha" sayısını hesaplayan bir projection yaz: InMemoryEventStore.ReadAll() ile global sırayı oku, yalnızca ReservedStockShipped event'lerini topla. State-based modelde bu soruyu cevaplamak için neyi baştan tasarlaman gerekirdi?
★★★ ReserveStockHandler'a otomatik retry ekle: WrongExpectedVersionException geldiğinde stream'i yeniden okuyup en fazla üç kez tekrar denesin. Sonra 50 paralel Task ile aynı stok kalemine birer nüsha rezervasyon gönder; toplam StockReserved adedinin OnHand'i asla aşmadığını ve kaç retry yapıldığını ölç.
Bu yazıda karar verme mantığını handler'ın içine bir if olarak koyduk. Bir sonraki yazıda onu kendi yerine taşıyacağız: Decide ve Evolve adında iki saf fonksiyondan oluşan decider pattern, OOP (Object-Oriented Programming) aggregate'iyle karşılaştırması ve veritabanına hiç dokunmayan Given-When-Then test'leri.