Event Sourcing'in Zor Konuları: GDPR, Unique Constraint ve Dynamic Consistency Boundary
Kişisel veriyi event'in dışında tut, kalması gerekiyorsa crypto-shredding ile şifrele; stream'ler arası benzersizliği doğal anahtarlı stream id ya da unique index'li Inline projection ile, iki aggregate'e yayılan kuralları Dynamic Consistency Boundary ile koru. Marten 9 ile AES-GCM crypto-shredding, iki eşzamanlı kayıt yarışı, DcbConcurrencyException ile kampanya kotası ve replay'de dış servis çağırmamak.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'a yeni bir özellik geldi: tükenen bir kitabın sayfasında "Gelince haber ver" butonu. Talep stok kaleminin stream'ine bir event olarak yazılıyor ve ilk sürümde event müşterinin e-posta adresini de taşıyor:
// Kötü: e-posta adresi event'in içinde, açık metin
public sealed record RestockAlertRequestedWithEmail(StockItemId StockItemId, CustomerId CustomerId, string Email, DateTimeOffset At)
: StockEvent(StockItemId, At);
Üç ay sonra Ada bir e-posta gönderiyor: hesabını kapattı ve kişisel verilerinin silinmesini istiyor. GDPR'ın (General Data Protection Regulation) 17. maddesindeki "unutulma hakkı", Türkiye'de KVKK'nın (6698 sayılı Kişisel Verilerin Korunması Kanunu) 7. maddesindeki silme ve yok etme yükümlülüğü. Ama event store append-only ve 08. yazının bütün mesajı "geçmişi değiştirme" idi. Ne yaparsın? Stream'i tamamen silmek mi (stok defterinin geçmişi de gider), event'i UPDATE ile değiştirmek mi, yoksa başka bir yol mu?
Bu yazı event sourcing'in tek bir stream'e sığmayan üç zor konusunu ele alıyor: değiştirilemez event'lerde kişisel veri, iki stream'e yayılan benzersizlik kuralı ve aggregate sınırını aşan tutarlılık. Sonunda bir de replay'in dış dünyaya dokunmaması gerektiğine bakacağız.
Kişisel Veriyi Event'in Dışında Tutmak
En iyi çözüm, sorunu hiç yaşamamak. Event, kararın gerektirdiği kimliği taşır; e-posta gibi iletişim bilgisi silinebilir sıradan bir kayıtta durur:
// İyi (varsayılan): event yalnızca kimliği taşır, iletişim bilgisi silinebilir bir document'te
public sealed record RestockAlertRequested(StockItemId StockItemId, CustomerId CustomerId, DateTimeOffset At)
: StockEvent(StockItemId, At);
public sealed record CustomerContact(Guid Id, string Email);
Ada silinmek istediğinde CustomerContact document'i silinir. Event'ler yerinde kalır ve hâlâ anlamlıdır: "bir müşteri bu kitap için haber istedi" bilgisi stok planlaması için değerli, ama artık kimseye ait bir e-posta adresi taşımıyor. Bildirimi gönderen kod event'teki CustomerId ile iletişim bilgisini o an arar; bulamazsa göndermez. Bu serideki stok event'lerinin hiçbirinde kişisel veri yok ve bu bilinçli bir karar. 06. yazıdaki "event'e domain object koymak" tuzağının en pahalı hâli, event'e Customer entity'sini olduğu gibi gömmektir.
Crypto-Shredding: Anahtarı Silmek
Bazen kişisel veri event'te kalmak zorundadır: event'ler arşivlenip başka bir sisteme aktarılıyorsa, ya da o anki adresin hukuken kayıt altında olması gerekiyorsa. Crypto-shredding burada devreye girer: kişisel veri içeren field'ı müşteriye özel bir anahtarla şifrelersin, anahtarı ayrı ve silinebilir bir yerde tutarsın. Silme isteği geldiğinde yalnızca anahtar silinir; event'teki şifreli metin artık kimse tarafından okunamaz.
// Kişisel veri event'te kalmak zorundaysa: müşteri başına anahtarla şifrele
public sealed record EncryptedValue(string Nonce, string CipherText, string Tag);
public sealed record RestockAlertRequestedEncrypted(StockItemId StockItemId, CustomerId CustomerId, EncryptedValue Email, DateTimeOffset At)
: StockEvent(StockItemId, At);
public sealed record CustomerKey(Guid Id, byte[] Key);
public sealed class PersonalDataProtector(IDocumentStore store)
{
public async Task<EncryptedValue> EncryptAsync(CustomerId customer, string plainText)
{
await using IDocumentSession session = store.LightweightSession();
CustomerKey? key = await session.LoadAsync<CustomerKey>(customer.Value);
if (key is null)
{
key = new CustomerKey(customer.Value, RandomNumberGenerator.GetBytes(32));
session.Store(key);
await session.SaveChangesAsync();
}
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
byte[] plain = Encoding.UTF8.GetBytes(plainText);
byte[] cipher = new byte[plain.Length];
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize];
using AesGcm aes = new(key.Key, tag.Length);
aes.Encrypt(nonce, plain, cipher, tag);
return new EncryptedValue(Convert.ToBase64String(nonce), Convert.ToBase64String(cipher), Convert.ToBase64String(tag));
}
public async Task<string?> DecryptAsync(IQuerySession session, CustomerId customer, EncryptedValue value)
{
// Anahtar silindiyse veri artık okunamaz: crypto-shredding
CustomerKey? key = await session.LoadAsync<CustomerKey>(customer.Value);
if (key is null)
return null;
byte[] cipher = Convert.FromBase64String(value.CipherText);
byte[] plain = new byte[cipher.Length];
using AesGcm aes = new(key.Key, AesGcm.TagByteSizes.MaxSize);
aes.Decrypt(Convert.FromBase64String(value.Nonce), cipher, Convert.FromBase64String(value.Tag), plain);
return Encoding.UTF8.GetString(plain);
}
public async Task ForgetAsync(CustomerId customer)
{
await using IDocumentSession session = store.LightweightSession();
session.Delete<CustomerKey>(customer.Value);
await session.SaveChangesAsync();
}
}
AES (Advanced Encryption Standard) algoritmasının GCM (Galois/Counter Mode) modu .NET'te System.Security.Cryptography.AesGcm ile geliyor; her şifrelemede yeni bir nonce üretiyoruz ve tag sayesinde değiştirilmiş bir şifreli metin Decrypt'te hata verir. Senaryo şu: önce açılıştaki düz e-postalı event, sonra Ada ve Alan için şifreli talepler.
await using (IDocumentSession session = store.LightweightSession())
{
session.Events.Append(dune.Value, new RestockAlertRequestedWithEmail(dune, ada, "ada@example.com", at));
await session.SaveChangesAsync();
}
Console.WriteLine(await EventJson(dune.Value, 2));
PersonalDataProtector protector = new(store);
await using (IDocumentSession session = store.LightweightSession())
{
session.Events.Append(dune.Value,
new RestockAlertRequestedEncrypted(dune, ada, await protector.EncryptAsync(ada, "ada@example.com"), at),
new RestockAlertRequestedEncrypted(dune, alan, await protector.EncryptAsync(alan, "alan@example.com"), at));
await session.SaveChangesAsync();
}
Console.WriteLine(await EventJson(dune.Value, 3));
await PrintAlerts("Before");
// Ada "unutulma hakkı"nı kullanıyor: yalnızca anahtarı silinir, event'ler yerinde kalır
await protector.ForgetAsync(ada);
await PrintAlerts("After forgetting Ada");
async Task PrintAlerts(string label)
{
await using IQuerySession query = store.QuerySession();
IReadOnlyList<IEvent> events = await query.Events.FetchStreamAsync(dune.Value);
IEnumerable<RestockAlertRequestedEncrypted> alerts = events.Select(e => e.Data).OfType<RestockAlertRequestedEncrypted>();
List<string> emails = [];
foreach (RestockAlertRequestedEncrypted alert in alerts)
emails.Add(await protector.DecryptAsync(query, alert.CustomerId, alert.Email) ?? "[erased]");
Console.WriteLine($"{label}: {string.Join(", ", emails)} (events in stream: {events.Count})");
}
{"At": "2026-09-01T09:00:00+00:00", "Email": "ada@example.com", "CustomerId": {"Value": "01a1b854-93ad-76ea-8f86-0bd17aa679f7"}, "StockItemId": {"Value": "01a1b854-92b0-7c26-a538-2965046ea85c"}}
{"At": "2026-09-01T09:00:00+00:00", "Email": {"Tag": "yPo+f+Bcv/3eVYsScwH2Ew==", "Nonce": "GCk8uSzQ3NpAegk1", "CipherText": "wTEZpzY8shTskyY+jALq"}, "CustomerId": {"Value": "01a1b854-93ad-76ea-8f86-0bd17aa679f7"}, "StockItemId": {"Value": "01a1b854-92b0-7c26-a538-2965046ea85c"}}
Before: ada@example.com, alan@example.com (events in stream: 4)
After forgetting Ada: [erased], alan@example.com (events in stream: 4)
Stream'deki dört event de yerinde; Ada'nın şifreli e-postası artık [erased]. Ama ilk satıra bak: düz metinle yazılmış eski event hâlâ ada@example.com diyor. Crypto-shredding yalnızca baştan şifrelenmiş veriyi korur; bir kere açık yazılmış kişisel veri için geriye iki yol kalır. Biri Marten'in 7.31'den beri sunduğu event data masking: AddMaskingRuleForProtectedInformation<T> ile bir maskeleme kuralı tanımlar, store.Advanced.ApplyEventDataMasking(...) ile seçtiğin stream'lerdeki event'leri yerinde yeniden yazarsın. Bu, 08. yazıda son çare dediğimiz in-place migration'ın hukuken zorunlu, dar kapsamlı bir örneği. Diğeri stream'i silmek; o da stok defterinin geçmişini götürür.
Crypto-shredding'in kendi bedelleri de var. Anahtar tablosu artık kritik bir parça: kaybolursa bütün müşterilerin verisi gider, yedeklenirse silinen anahtar yedekte yaşamaya devam eder (yedek saklama süren silme sürecinin parçası olmalı). Read model'ler de şifresi çözülmüş veriyi tutuyorsa onların da temizlenmesi ya da rebuild edilmesi gerekir. Ve hukuki soru yerinde duruyor: anahtarı silinmiş şifreli verinin "silinmiş" sayılıp sayılmadığı, uygulamanın tabi olduğu düzenlemeye ve hukuk ekibinin görüşüne bağlı. Marten'in dokümantasyonu crypto-shredding'i yerleşik bir özellik olarak sunmuyor; yukarıdaki gibi uygulama tarafında kurarsın.
Cross-Stream Invariant: Benzersizlik
Stok defterinin varsayımlarından biri: tek depo var ve bir kitap için tek bir StockItem açılır. Bu kural tek bir stream'in içinde değil, stream'ler arasında. Kendi başına bırakırsan ne olur?
// Aynı kitap için iki ayrı istek, iki farklı StockItemId
BookId dune = new(Guid.CreateVersion7());
await Task.WhenAll(
handler.HandleAsync(new RegisterStockItem(new StockItemId(Guid.CreateVersion7()), dune, at)),
handler.HandleAsync(new RegisterStockItem(new StockItemId(Guid.CreateVersion7()), dune, at)));
Stock items registered for the same book: 2
İki stream, iki ayrı consistency boundary; her biri kendi version'ını kontrol etti ve ikisi de haklıydı. Aynı kitap için iki stok kalemi oluştu ve nüshalar ikisi arasında bölünecek. "Önce read model'e bak, bu kitap için stok kalemi var mı" demek de çözmez: async bir read model iki isteğin arasındaki birkaç milisaniyede güncellenmiş olmaz. Benzersizliği ancak tek bir noktada atomik olarak kontrol eden bir mekanizma korur. Üç yol var.
Doğal anahtarı stream id yapmak
Kural "kitap başına bir stok kalemi" ise stok kaleminin id'si kitabın id'si olabilir:
// Stream id kitabın kendi id'si: aynı kitap için ikinci stream açılamaz
BookId dune = new(Guid.CreateVersion7());
StockItemId stockItemId = new(dune.Value);
Task<IReadOnlyList<StockEvent>>[] attempts =
[
handler.HandleAsync(new RegisterStockItem(stockItemId, dune, at)),
handler.HandleAsync(new RegisterStockItem(stockItemId, dune, at))
];
foreach (Task<IReadOnlyList<StockEvent>> attempt in attempts)
{
try
{
Console.WriteLine($"Registered: {(await attempt).Count} event(s)");
}
catch (Exception ex) when (ex is ConcurrencyException or Marten.Exceptions.ExistingStreamIdCollisionException or DomainException)
{
Console.WriteLine($"Rejected: {ex.GetType().Name}");
}
}
Registered: 1 event(s)
Rejected: ExistingStreamIdCollisionException
İkinci istek, 06. yazıda gördüğümüz mt_streams'in primary key'ine çarptı. En ucuz çözüm bu; ama yalnızca benzersiz olan şey stream'in kendi kimliği olduğunda işe yarar. "Bir e-posta adresiyle tek hesap" gibi bir kural için e-postayı stream id yapmak istemezsin: değişebilir ve kişisel veridir.
Reservation pattern: unique index'li bir Inline projection
İkinci yol, benzersiz değeri event'lerle aynı transaction'da bir tabloya "rezerve etmek". Marten'de bu, unique index'li bir Inline projection:
// Bir kitap için tek stok kalemi: Inline projection + unique index
public sealed record StockItemByBook(Guid Id, Guid BookId);
public sealed class StockItemByBookProjection : SingleStreamProjection<StockItemByBook, Guid>
{
public override StockItemByBook? Evolve(StockItemByBook? snapshot, Guid id, IEvent e) =>
e.Data is StockItemRegistered r ? new StockItemByBook(id, r.BookId.Value) : snapshot;
}
opts.Projections.Add(new StockItemByBookProjection(), ProjectionLifecycle.Inline);
opts.Schema.For<StockItemByBook>().UniqueIndex(x => x.BookId);
Rejected: DocumentAlreadyExistsException (23505 mt_doc_stockitembybook_uidx_book_id)
Registered: 1 event(s)
Bu sefer iki isteğin stream id'leri farklıydı, ama ikisi de aynı transaction'da StockItemByBook document'ini yazmaya çalıştı ve unique index ikincisini reddetti. Event de projection'la aynı transaction'da olduğu için geri alındı. 07. yazıda Inline projection'ın hatasının command'ı düşürdüğünü bir dezavantaj olarak saymıştık; burada tam istediğimiz şey o. Hangisinin kazanacağı ise yarışa bağlı: çıktıda ilk task kaybetti.
Event store'un ve benzersizlik kaydının ayrı veritabanlarında olduğu sistemlerde (ör. KurrentDB + SQL (Structured Query Language) tablosu) bu tek transaction yoktur. O zaman reservation pattern iki adımlı olur: önce anahtarı ayrı tabloya süreli olarak rezerve et, sonra event'i yaz, en sonda rezervasyonu onayla; arada kalan rezervasyonları süresi dolunca temizle. Marten'in PostgreSQL üzerinde olması bu karmaşıklığı bizden alıyor.
Dynamic Consistency Boundary
Benzersizlik özel bir durum. Daha genel soru şu: bir kural iki aggregate'in verisine aynı anda bakıyorsa ne yaparsın? Okul açılışı kampanyasında imzalı Dune ve imzalı Emma baskılarından kampanyaya ikişer nüsha ayrıldı ve bir müşteri kampanya boyunca en fazla bir imzalı nüsha alabilir. Kotayı stok kalemi bilir, müşterinin hakkını ise müşteri. İkisini tek aggregate'te birleştirmek bütün kampanyayı tek bir stream'de kilitlemek demek; ayrı tutmak ise iki transaction ve bir telafi akışı.
Sara Pellegrini Nisan 2023'te "I am here to kill the aggregate" yazısıyla başlayan dizide başka bir yol önerdi; fikir sonra Dynamic Consistency Boundary (DCB) adını aldı. Consistency boundary'yi tasarım anında sabit bir aggregate olarak değil, her kararın okuduğu event'leri seçen bir sorgu olarak tanımla; event'leri tag'lerle işaretle ve yazarken "okuduğumdan beri bu sorguya uyan yeni bir event eklendiyse reddet" koşulunu kullan. dcb.events'teki spesifikasyon bunun için gereken asgari event store yeteneğini tanımlıyor: event tipine ve tag'e göre okuma ve koşullu append. Domain-Driven Design (DDD) serisinin "DDD'nin Evrimi ve Yaygın Yanlışlar: Blue Book'tan Dynamic Consistency Boundary'ye" yazısında bu fikri bellek içi küçük bir store ile kurmuştuk. Marten Mart 2026'da çıkan 8.23 sürümünden beri DCB'yi destekliyor; aynı sorunu bu sefer gerçek bir event store'la çözelim.
// Kampanya kuralı iki aggregate'e yayılıyor: kitabın kampanya kotası ve müşterinin kampanya hakkı
public sealed record PromoCopyClaimed(StockItemId StockItemId, CustomerId CustomerId, OrderId OrderId, DateTimeOffset At);
[BoundaryAggregate]
public sealed class PromoClaims
{
public Dictionary<Guid, int> ByStockItem { get; } = [];
public Dictionary<Guid, int> ByCustomer { get; } = [];
public void Apply(PromoCopyClaimed e)
{
ByStockItem[e.StockItemId.Value] = ByStockItem.GetValueOrDefault(e.StockItemId.Value) + 1;
ByCustomer[e.CustomerId.Value] = ByCustomer.GetValueOrDefault(e.CustomerId.Value) + 1;
}
}
opts.Events.AddEventType<PromoCopyClaimed>();
opts.Events.RegisterTagType<StockItemId>("stock_item").ForAggregate<PromoClaims>();
opts.Events.RegisterTagType<CustomerId>("customer").ForAggregate<PromoClaims>();
PromoClaims bir stream'e ait değil; tag sorgusunun seçtiği event'lerden kurulan bir boundary aggregate. Marten 9'da Id'si olmayan böyle bir aggregate'in [BoundaryAggregate] ile işaretlenmesi zorunlu. Karar fonksiyonu sorguyu tanımlıyor, durumu okuyor ve gerekiyorsa tag'li event'i ekliyor:
const int PromoQuota = 2;
const int MaxPerCustomer = 1;
async Task<(IDocumentSession Session, string Outcome)> Decide(StockItemId item, CustomerId customer)
{
IDocumentSession session = store.LightweightSession();
EventTagQuery query = new EventTagQuery()
.Or<PromoCopyClaimed, StockItemId>(item)
.Or<PromoCopyClaimed, CustomerId>(customer);
IEventBoundary<PromoClaims> boundary = await session.Events.FetchForWritingByTags<PromoClaims>(query);
PromoClaims claims = boundary.Aggregate ?? new PromoClaims();
if (claims.ByStockItem.GetValueOrDefault(item.Value) >= PromoQuota)
return (session, "rejected: promo quota used up");
if (claims.ByCustomer.GetValueOrDefault(customer.Value) >= MaxPerCustomer)
return (session, "rejected: customer already claimed a promo copy");
IEvent claimed = session.Events.BuildEvent(
new PromoCopyClaimed(item, customer, new OrderId(Guid.CreateVersion7()), at));
claimed.WithTag(item, customer);
boundary.AppendOne(claimed);
return (session, "claimed");
}
async Task Commit(string who, (IDocumentSession Session, string Outcome) decision)
{
await using IDocumentSession session = decision.Session;
await session.SaveChangesAsync();
Console.WriteLine($"{who,-6} {decision.Outcome}");
}
StockItemId signedDune = new(Guid.CreateVersion7()), signedEmma = new(Guid.CreateVersion7());
CustomerId ada = new(Guid.CreateVersion7()), alan = new(Guid.CreateVersion7()),
grace = new(Guid.CreateVersion7()), linus = new(Guid.CreateVersion7());
await Commit("Ada", await Decide(signedDune, ada));
await Commit("Ada", await Decide(signedEmma, ada));
// Alan ve Grace Dune'un son kampanya nüshası için aynı anda karar veriyor; Linus başka bir kitap için
(IDocumentSession, string) alanDecision = await Decide(signedDune, alan);
(IDocumentSession, string) graceDecision = await Decide(signedDune, grace);
(IDocumentSession, string) linusDecision = await Decide(signedEmma, linus);
await Commit("Alan", alanDecision);
try
{
await Commit("Grace", graceDecision);
}
catch (DcbConcurrencyException)
{
Console.WriteLine("Grace conflict: DcbConcurrencyException, deciding again");
await Commit("Grace", await Decide(signedDune, grace));
}
await Commit("Linus", linusDecision);
Ada claimed
Ada rejected: customer already claimed a promo copy
Alan claimed
Grace conflict: DcbConcurrencyException, deciding again
Grace rejected: promo quota used up
Linus claimed
Ada'nın ikinci denemesi kendi müşteri hakkına takıldı. Alan ve Grace Dune'un son kampanya nüshası için aynı anda karar verdi; Alan'ın event'i Grace'in sorgusuna (stock_item: Dune) uyduğu için Grace'in kaydı DcbConcurrencyException ile reddedildi, yeniden karar verince kotanın dolduğunu gördü. Linus da aynı anlarda karar vermişti, ama sorgusu Emma ve kendisiyle sınırlıydı; Alan'ın event'i ona dokunmadı. Consistency boundary, kararın sorgusu kadar büyük.
Marten bu kontrolü mt_dcb_tag_version adlı bir yan tabloda, tag değeri başına bir satırla yapıyor: kayıt sırasında okunan version'la koşullu bir UPDATE, PostgreSQL'in varsayılan READ COMMITTED izolasyonunda iki eşzamanlı yazarı sıraya sokuyor. Bu tablo 9.4'te, gerçekten eşzamanlı iki kaydın ikisinin de geçebildiği bir hatayı (#4591) düzeltmek için eklendi; DCB kullanıyorsan en az 9.4'te olmalısın. 9.0 ile tag'leri mt_events üzerinde tek bir hstore kolonunda tutan DcbStorageMode.HStore seçeneği de geldi.
Aggregate'e göre trade-off'u dürüst koymak gerekiyor. Kazanç: kural ihtiyacı kadar geniş bir boundary, dev aggregate yok, telafi akışı yok. Bedel: her karar kendi sorgusunu doğru tasarlamak zorunda ve bir event'e bir tag'i eklemeyi unutmak hata vermez, yalnızca sorgunun cevabından o event'i düşürür (Marten 9.30 bunun için TagWith ile tanımlanan tag kurallarını getirdi). Ekosistem de genç: spesifikasyon sayfası bir sürüm numarası taşımıyor, Java tarafında Axon Framework 5 destekliyor, .NET'te Marten dışındaki kütüphaneler yeni. Bookstore'da stok kalemi gibi sınırı net olan yerlerde aggregate kalıyor; DCB'yi kampanya kotası gibi gerçekten iki modele yayılan kurallar için saklıyoruz.
Replay'de Dış Dünyaya Dokunmamak
Event sourcing'in sessiz kuralı: replay, yani state'i event'lerden yeniden kurmak, yan etkisiz olmalı. Evolve bir e-posta gönderir ya da kargo firmasının API'sini (Application Programming Interface) çağırırsa, her rehydration, her rebuild ve her snapshot yenilemesi o çağrıyı tekrarlar. Doğru yer karar anı: dış çağrıyı handler bir kez yapar, sonucunu event'e yazar; evolve yalnızca o sonucu okur.
public interface ICarrierApi
{
Task<string> CreateLabelAsync(OrderId orderId);
}
public sealed record ShipmentLabelCreated(OrderId OrderId, string TrackingNumber, DateTimeOffset At);
public sealed record Shipment(Guid Id, OrderId OrderId, string TrackingNumber)
{
// Replay yalnızca event'teki sonucu okur; kargo firmasını hiç çağırmaz
public static Shipment Create(JasperFx.Events.IEvent<ShipmentLabelCreated> e) =>
new(e.StreamId, e.Data.OrderId, e.Data.TrackingNumber);
}
public sealed class CreateShipmentLabelHandler(IDocumentStore store, ICarrierApi carrier, TimeProvider clock)
{
public async Task<Guid> HandleAsync(OrderId orderId)
{
// Dış çağrı karar anında bir kez yapılır, sonucu event'e yazılır
string trackingNumber = await carrier.CreateLabelAsync(orderId);
Guid shipmentId = Guid.CreateVersion7();
await using IDocumentSession session = store.LightweightSession();
session.Events.StartStream<Shipment>(shipmentId, new ShipmentLabelCreated(orderId, trackingNumber, clock.GetUtcNow()));
await session.SaveChangesAsync();
return shipmentId;
}
}
FakeCarrierApi carrier = new();
CreateShipmentLabelHandler labels = new(store, carrier, TimeProvider.System);
Guid shipmentId = await labels.HandleAsync(new OrderId(Guid.CreateVersion7()));
await using IQuerySession query = store.QuerySession();
for (int i = 0; i < 3; i++)
{
Shipment? shipment = await query.Events.AggregateStreamAsync<Shipment>(shipmentId);
Console.WriteLine($"Replay {i + 1}: {shipment!.TrackingNumber}");
}
Console.WriteLine($"Carrier API calls: {carrier.Calls}");
Replay 1: TRK-0001
Replay 2: TRK-0001
Replay 3: TRK-0001
Carrier API calls: 1
Üç replay, tek API çağrısı; takip numarası olmuş bir şey olarak event'te duruyor. Aynı ilke ters yönde de geçerli: event'e tepki olarak dışarıya bir şey göndermek (e-posta, başka bir servise mesaj) projection'ın değil, kendi checkpoint'ini tutan bir subscription'ın ya da outbox'ın işi. 07. yazıda Marten'in projection side effect'lerini rebuild sırasında çalıştırmadığını görmüştük; outbox ve idempotent consumer ise ilerideki Messaging ve Event-Driven Architecture serisinin konusu.
Sık Düşülen Tuzaklar
"Silme isteği gelince stream'i silelim"
Stream'i silmek kişisel veriyi götürür, ama onunla birlikte o stream'in iş geçmişini ve ondan türetilmiş read model'lerin rebuild edilebilirliğini de. Kişisel veriyi baştan event'in dışında tut; silme isteği bir DELETE ile bitsin.
Read model'leri unutmak
Kişisel veriyi event store'da doğru yönettin, ama PendingNotification read model'i e-posta adreslerini açık metin tutuyor. Silme süreci bütün read model'leri, cache'leri, log'ları ve yedekleri kapsamalı. Read model'in sıfırdan rebuild edilebilmesi burada bir avantaj: kişisel veriyi içermeyen yeni bir projection'la yeniden kurarsın.
Benzersizliği async read model'le kontrol etmek
"Bu kitap için stok kalemi var mı" sorusunu async bir read model'e sormak, iki isteğin birkaç milisaniye arayla geldiği gün iki kayıt üretir; açılıştaki naive çıktısı tam olarak bu. Kontrol, yazmayla aynı atomik adımda olmalı.
Her cross-stream kuralı DCB'ye taşımak
DCB yeni ve çekici, ama çoğu kural ya tek aggregate'e sığar ya da eventual consistency ile yaşanabilir. "Müşteri kampanyada bir nüsha alabilir" kuralını bir dakika geç fark edip siparişi iptal etmek iş için kabul edilebiliyorsa, iki aggregate ve bir telafi adımı DCB'den daha az yeni kavram demektir.
Küçük Bir Deneme
★☆☆ PersonalDataProtector'a bir anahtar rotasyonu ekle: Alan'ın anahtarını yenile ve eski event'lerin hâlâ okunabilmesi için hangi bilginin event'e eklenmesi gerektiğini bul (ipucu: anahtar version'ı).
★★☆ Unique index yerine StockItemByBook kaydını command'dan önce ayrı bir session'da yazan iki adımlı bir reservation pattern kur. Event yazılamazsa rezervasyon ne olacak?
★★★ DCB örneğine "kampanya kotası dolunca, iptal edilen bir talep nüshayı geri açsın" kuralını ekle: PromoClaimCancelled event'i, tag'leri ve PromoClaims.Apply. Grace'in yeniden deneme senaryosunu Alan'ın iptaliyle birlikte tekrar çalıştır.
Üç yazı boyunca event sourcing'in nasıl yapıldığını konuştuk: event store, projection, versioning ve zor konular. Serinin son yazısında en baştaki soruya dönüyoruz ve CQRS (Command Query Responsibility Segregation) ile event sourcing'in ne zaman gereksiz olduğunu, hangi sinyalin hangi seviyeyi haklı çıkardığını bir karar rehberine çeviriyoruz.