Message Contract'ları ve Schema Evolution: CloudEvents, AsyncAPI ve Versioning
Message contract'ı bağımsız deploy edilen producer ve consumer'lar arasında bir public API'dir; alan eklemek güvenli, alanı yeniden adlandırmak ya da enum'a değer eklemek ise consumer'ı sessizce ya da gürültüyle kırar, bu yüzden değişiklikler expand-contract ve versiyonlu type adlarıyla yapılır. System.Text.Json ile gerçek kırılma çıktıları, CloudEvents 1.0 envelope'u C# SDK'sıyla, AsyncAPI 3.1 ile doğrulanmış bir event kataloğu ve schema registry uyumluluk modları.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; CloudEvents örneği CloudNative.CloudEvents 2.9.0 ile, AsyncAPI dokümanı @asyncapi/parser 3.6.6 ile doğrulandı.
Önce Tahmin Et
Ordering ekibi OrderPlaced contract'ında üç değişiklik planlıyor. Biri para birimi alanı ve yeni bir kargo seçeneği ekliyor, diğeri Total'ı "daha açıklayıcı olsun" diye TotalAmount yapıyor:
namespace Bookstore.Ordering.Contracts.V1
{
public sealed record OrderPlaced(Guid OrderId, decimal Total, ShippingSpeed Shipping);
public enum ShippingSpeed { Standard, Express }
}
namespace Bookstore.Ordering.Contracts.V2
{
// Yalnız ekleme: yeni alan ve yeni enum değeri
public sealed record OrderPlaced(Guid OrderId, decimal Total, string Currency, ShippingSpeed Shipping);
public enum ShippingSpeed { Standard, Express, SameDay }
}
namespace Bookstore.Ordering.Contracts.Renamed
{
// "Daha açıklayıcı olsun" diye yeniden adlandırılmış alan
public sealed record OrderPlaced(Guid OrderId, decimal TotalAmount, V1.ShippingSpeed Shipping);
}
Inventory, Payments ve Notification hâlâ V1 ile çalışıyor ve hepsi Ordering'den bağımsız deploy ediliyor. Üç değişiklikten hangisi V1 consumer'ını hiç etkilemez, hangisi gürültülü bir exception'la kırar, hangisi hiçbir hata vermeden yanlış veri üretir?
Contract Bir Public API'dir
Senkron bir HTTP (Hypertext Transfer Protocol) çağrısında istemciyi kırarsan hemen bir 400 ya da 500 görürsün. Message'larda geri bildirim çok daha geç gelir, çünkü producer ile consumer hem zamanda hem deploy'da ayrık. Ordering'in yeni sürümü yayına girdiğinde queue'da eski formatta binlerce message bekliyor olabilir; Kafka'da retention süresi boyunca eski event'ler log'da duruyor; DLQ (dead-letter queue) üç gün önceki bir message'ı yeniden işlemeye gönderebiliyor. Aynı anda eski consumer'lar da yeni message'ları okuyacak. Bu yüzden message contract'ı, bir library'nin public API'si (Application Programming Interface) kadar dikkat ister ve iki yönde uyumluluk konuşulur. Confluent Schema Registry dokümantasyonunun tanımlarıyla: backward compatibility, yeni şemayla çalışan consumer'ın eski şemayla yazılmış veriyi okuyabilmesi; forward compatibility, eski şemayla çalışan consumer'ın yeni şemayla yazılmış veriyi okuyabilmesi. İkisi birden full compatibility.
| Uyumluluk | Kim kimi okuyabilir | Önce kim deploy edilir | Güvenli değişiklik |
|---|---|---|---|
| Backward | Yeni consumer, eski veriyi | Consumer'lar | Alan silmek, optional alan eklemek |
| Forward | Eski consumer, yeni veriyi | Producer | Alan eklemek, optional alanı silmek |
| Full | İkisi de | Sıra önemsiz | Yalnız optional alan eklemek ya da silmek |
Bookstore'da producer'lar ve consumer'lar ayrı ekiplerde ve ayrı takvimlerde deploy ediliyor; hangisinin önce çıkacağını koordine etmek istemiyoruz. Pratik hedef bu yüzden full compatibility: her değişiklik hem eski hem yeni taraf için okunabilir olmalı.
Ekleme Güvenli, Yeniden Adlandırma Değil
Üç değişikliği System.Text.Json ile, JSON (JavaScript Object Notation) payload'larında Web varsayılanları (camelCase) ve string enum converter'ıyla denedim:
JsonSerializerOptions wire = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } };
Guid orderId = Guid.Parse("0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11");
Console.WriteLine("1) v2 producer adds Currency; v1 consumer (default options)");
string v2Json = JsonSerializer.Serialize(new V2.OrderPlaced(orderId, 60m, "EUR", V2.ShippingSpeed.Express), wire);
Console.WriteLine($" payload: {v2Json}");
Console.WriteLine($" v1 reads: {JsonSerializer.Deserialize<V1.OrderPlaced>(v2Json, wire)}");
Console.WriteLine("2) Same payload, strict v1 consumer");
JsonSerializerOptions strict = new(wire) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
Try(() => JsonSerializer.Deserialize<V1.OrderPlaced>(v2Json, strict));
Console.WriteLine("3) Producer renames Total to TotalAmount");
string renamedJson = JsonSerializer.Serialize(new Renamed.OrderPlaced(orderId, 60m, V1.ShippingSpeed.Standard), wire);
Console.WriteLine($" payload: {renamedJson}");
Console.WriteLine($" v1 reads: {JsonSerializer.Deserialize<V1.OrderPlaced>(renamedJson, wire)}");
JsonSerializerOptions required = new(wire) { RespectRequiredConstructorParameters = true };
Try(() => JsonSerializer.Deserialize<V1.OrderPlaced>(renamedJson, required));
1) v2 producer adds Currency; v1 consumer (default options)
payload: {"orderId":"0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11","total":60,"currency":"EUR","shipping":"Express"}
v1 reads: OrderPlaced { OrderId = 0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11, Total = 60, Shipping = Express }
2) Same payload, strict v1 consumer
JsonException: The JSON property 'currency' could not be mapped to any .NET member contained in type 'Bookstore.Ordering.Contracts.V1.OrderPlaced'.
3) Producer renames Total to TotalAmount
payload: {"orderId":"0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11","totalAmount":60,"shipping":"Standard"}
v1 reads: OrderPlaced { OrderId = 0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11, Total = 0, Shipping = Standard }
JsonException: JSON deserialization for type 'Bookstore.Ordering.Contracts.V1.OrderPlaced' was missing required properties including: 'total'.
Alan eklemek, varsayılan ayarlarla sorunsuz: System.Text.Json tanımadığı currency property'sini yok sayıyor. Martin Fowler bu tutumu 2011'de Tolerant Reader adıyla tarif etti: consumer yalnız ihtiyacı olan alanları okusun, gerisine aldırmasın. Ama tolerans bir ayar ve kolayca kapanabiliyor; .NET 8'den beri var olan JsonUnmappedMemberHandling.Disallow, "beklenmeyen bir şey gelirse hemen fark edeyim" diye açılmış iyi niyetli bir katılık, ve ilk additive değişiklikte bütün consumer'ı durduruyor. Message consumer'larında bu ayar kapalı kalmalı.
Yeniden adlandırma ise en kötü sonucu verdi: exception yok, log yok, Total = 0. V1 consumer'ı total'ı bulamayınca positional record'un parametresine decimal'ın varsayılan değerini verdi. Payments bu siparişten sıfır euro çekmeye, Notification müşteriye "toplam: 0,00 EUR" yazmaya hazırlanıyor. .NET 9 ile gelen RespectRequiredConstructorParameters seçeneği eksik constructor parametresini exception'a çeviriyor; sessiz bozulmayı gürültülü bir hataya çevirmek her zaman daha iyi, çünkü gürültülü hata DLQ'ya düşer ve düzeltilip yeniden işlenebilir. Yine de asıl çözüm serializer ayarı değil, değişikliğin kendisini uyumlu yapmak: yeniden adlandırma gerçekten gerekiyorsa expand-contract (parallel change) ile yapılır. Önce yeni alan eskisinin yanına eklenir ve ikisi birlikte doldurulur, consumer'lar teker teker yeni alana geçer, en son eski alan kaldırılır. Bu, bir sürüm yerine üç deploy sürer; producer'ın kimin hangi alanı okuduğunu bilmesi gerektiği için de contract'ın bir sahibi olmalı.
Enum'a Değer Eklemek
Ekleme güvenli dedik, ama enum'a değer eklemek bir istisna:
Console.WriteLine("4) v2 producer sends the new enum value SameDay");
string sameDay = JsonSerializer.Serialize(new V2.OrderPlaced(orderId, 60m, "EUR", V2.ShippingSpeed.SameDay), wire);
Try(() => JsonSerializer.Deserialize<V1.OrderPlaced>(sameDay, wire));
JsonSerializerOptions numeric = new(JsonSerializerDefaults.Web); // enum'lar sayı olarak
V1.OrderPlaced? asNumber = JsonSerializer.Deserialize<V1.OrderPlaced>(
JsonSerializer.Serialize(new V2.OrderPlaced(orderId, 60m, "EUR", V2.ShippingSpeed.SameDay), numeric), numeric);
Console.WriteLine($" as a number: Shipping = {asNumber!.Shipping}, defined in v1: {Enum.IsDefined(asNumber.Shipping)}");
4) v2 producer sends the new enum value SameDay
JsonException: The JSON value could not be converted to Bookstore.Ordering.Contracts.V1.OrderPlaced. Path: $.shipping | LineNumber: 0 | BytePositionInLine: 98.
as a number: Shipping = 2, defined in v1: False
String olarak taşınan enum'da V1 consumer'ı SameDay'i tanımıyor ve bütün message'ı reddediyor; aynı gün kargo seçen her sipariş Shipping'in DLQ'suna düşüyor. Sayı olarak taşınırsa daha kötü: consumer 2 değerini sorgusuz kabul ediyor, ama bu V1'de tanımlı olmayan bir değer ve switch'lerin default dalına, belki de "Standard gibi davran" mantığına düşüyor. Birinci durum en azından fark ediliyor. Sağlam yaklaşım, contract'taki enum'ları açık küme saymak: producer yeni değer ekleyebileceğini, consumer da bilmediği değerle karşılaşabileceğini baştan kabul eder. Pratikte consumer tarafında bilinmeyen değeri bir Unknown üyesine eşleyen küçük bir custom converter ve o durumda ne yapılacağına dair açık bir karar (örneğin "aynı gün kargoyu tanımıyorsan standart kargo etiketi bas ama alarm üret") yeterli. AsyncAPI dokümanındaki "consumers must accept unknown values" notu birazdan bu kuralı contract'ın bir parçası yapacak.
Versiyonlu Type Adı ve Gerçek Breaking Change
Bazı değişiklikler hiçbir şekilde uyumlu yapılamaz: OrderId'nin tipinin değişmesi, bir alanın anlamının değişmesi (Total'ın KDV (katma değer vergisi) dahil tutardan KDV hariç tutara dönmesi), bir event'in ikiye bölünmesi. Anlam değişikliği özellikle tehlikeli, çünkü hiçbir serializer onu yakalayamaz: alan adı ve tipi aynı, yalnız değer artık başka bir şey söylüyor. Böyle bir değişiklik eski contract'ın yeni sürümü değil, yeni bir contract'tır ve yeni bir adla yayınlanır. Serinin "Transactional Outbox ve Inbox" yazısındaki bookstore.ordering.order-placed.v1 type adı bunun için vardı: v2 çıktığında producer bir geçiş süresi boyunca hem v1'i hem v2'yi yayınlar (outbox'a iki satır), consumer'lar kendi takvimlerinde v2'ye geçer, v1'i dinleyen kimse kalmayınca v1 emekliye ayrılır. Kimin dinlediğini bilmek de AsyncAPI kataloğunun ve broker'daki subscription listesinin işi.
Contract'ı DLL Olarak Değil Schema Olarak Paylaşmak
.NET ekiplerinin ilk refleksi contract'ları bir Bookstore.Contracts NuGet paketine, yani herkesin referans verdiği bir DLL'e (dynamic-link library) koymak. Kısa vadede rahat: tip güvenliği, IntelliSense, tek kaynak. Uzun vadede birkaç sorun getiriyor. Paket sürümü bir deploy bağımlılığına dönüşüyor; Inventory, Ordering'in yeni paketini almadan derlenmiyor ya da eski paketle yeni alanları göremiyor. Paket zamanla yardımcı tipler, extension method'lar ve "nasılsa ortak" diye domain tipleri biriktiriyor; DDD (Domain-Driven Design) serisinin context map'inde Shared Kernel'i yalnız Money ile sınırlamamızın sebebi buydu. Ve contract yalnız .NET'e açılıyor; ileride Python ile yazılmış bir raporlama servisi gelirse paket ona bir şey söylemiyor.
Alternatif, contract'ı bir şema olarak (JSON Schema, Avro ya da Protobuf) ve bir dokümanla yayınlamak, her consumer'ın da kendi ihtiyacı kadar alanı içeren kendi DTO'sunu (data transfer object) yazması. Inventory için OrderPlaced'in tamamı değil, OrderId ve kitaplar yeter; consumer'ın kendi DTO'su, tolerant reader'ın koddaki karşılığı. Sahiplik sorusunun cevabı da message türüne göre değişiyor: event'in sahibi onu yayınlayan, yani producer (DDD serisindeki context map'te Ordering'in OrderPlaced'i Published Language olarak yayınlaması); command'ın sahibi ise onu işleyen, yani alıcı (CapturePayment'ın şemasını Payments belirler). Consumer'ların gerçekte hangi alanlara dayandığını producer'ın pipeline'ında test etmek, yani consumer-driven contract test, ilerideki Microservices serisinin "Service Contract'ları ve Versioning" yazısının konusu.
CloudEvents: Ortak Bir Envelope
Payload'un şeması ekipten ekibe değişir, ama her message'ın taşıdığı üst bilgiler (id, kaynak, tip, zaman) aynıdır. CNCF (Cloud Native Computing Foundation) projesi CloudEvents bu üst bilgiler için ortak bir format tanımlıyor; proje Ocak 2024'te graduated oldu, spec'in güncel sürümü 1.0.2 ve event'teki specversion değeri 1.0. Zorunlu dört attribute var: id, source, specversion, type. Opsiyonel olanlar datacontenttype, dataschema, subject ve time. Spec'in en önemli kuralı, source ile id ikilisinin her farklı event için benzersiz olması; aynı event yeniden gönderilirse aynı id'yi taşıyabilir ve consumer aynı source ve id'yi duplicate sayabilir. Yani serinin "Idempotent Consumer" yazısındaki dedup key'in standart bir adı var. Aynı OrderPlaced, resmî C# SDK'sı (Software Development Kit) ile:
CloudEvent cloudEvent = new()
{
Id = "0199f0c2-7a11-7d40-8c55-1a2b3c4d5e6f",
Source = new Uri("https://example.com/bookstore/ordering"),
Type = "com.example.bookstore.ordering.order-placed.v2",
Subject = orderId.ToString(),
Time = new DateTimeOffset(2026, 11, 8, 10, 0, 0, TimeSpan.Zero),
DataContentType = MediaTypeNames.Application.Json,
DataSchema = new Uri("https://example.com/bookstore/schemas/order-placed/v2.json"),
Data = new V2.OrderPlaced(orderId, 60m, "EUR", V2.ShippingSpeed.Express)
};
JsonEventFormatter formatter = new(wire, new JsonDocumentOptions());
ReadOnlyMemory<byte> body = formatter.EncodeStructuredModeMessage(cloudEvent, out ContentType contentType);
Console.WriteLine($" content type: {contentType}");
Console.WriteLine(Indent(Encoding.UTF8.GetString(body.Span)));
Console.WriteLine("6) A CloudEvent without source");
Try(() => formatter.EncodeStructuredModeMessage(new CloudEvent { Id = "1", Type = "com.example.test" }, out _));
5) The same event as a CloudEvent (structured mode)
content type: application/cloudevents+json; charset=utf-8
{
"specversion": "1.0",
"id": "0199f0c2-7a11-7d40-8c55-1a2b3c4d5e6f",
"source": "https://example.com/bookstore/ordering",
"type": "com.example.bookstore.ordering.order-placed.v2",
"subject": "0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11",
"time": "2026-11-08T10:00:00Z",
"datacontenttype": "application/json",
"dataschema": "https://example.com/bookstore/schemas/order-placed/v2.json",
"data": {
"orderId": "0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11",
"total": 60,
"currency": "EUR",
"shipping": "Express"
}
}
6) A CloudEvent without source
ArgumentException: CloudEvent is missing required attributes: source (Parameter 'cloudEvent')
Spec, type'ın kuralları belirleyen kurumun ters çevrilmiş alan adıyla (reverse-DNS) başlamasını öneriyor; Bookstore için gerçek bir alan adı uydurmamak adına örneklerde example.com'u kullandım. Versiyon type adının sonunda, şemanın adresi dataschema'da. Buradaki structured mode'da envelope ve payload tek bir JSON belgesi; binary mode'da ise attribute'lar transport'un header'larına taşınır (HTTP'de ce-id, ce-type gibi) ve body yalnız payload olur. Binary mode, CloudEvents'ten habersiz eski consumer'ların payload'u eskisi gibi okumaya devam edebilmesini sağladığı için mevcut bir sisteme sonradan eklemek için daha yumuşak bir yol. Bookstore'un kendi envelope'u (MessageId, Type, CorrelationId, OccurredAt) CloudEvents'e neredeyse birebir eşleniyor; correlation id gibi spec'te olmayan bilgiler extension attribute olarak eklenir. Envelope'u baştan CloudEvents yapmak, ileride bir event gateway'e, başka bir dildeki servise ya da bir bulut sağlayıcısının event servisine bağlanmayı ucuzlatır.
AsyncAPI ile Event Kataloğu
OpenAPI'nin HTTP API'leri için yaptığını AsyncAPI message tabanlı API'ler için yapıyor: hangi channel'a hangi message'ın gittiğini ve payload şemasını makinenin okuyabileceği bir dokümanda topluyor. Ocak 2026'da çıkan 3.1.0 kırıcı değişiklik içermeyen bir minor sürüm. Ordering'in OrderPlaced için yayınladığı doküman:
asyncapi: 3.1.0
info:
title: Bookstore Ordering events
version: 2.0.0
defaultContentType: application/json
channels:
orderPlaced:
address: bookstore.ordering.order-placed
messages:
orderPlacedV2:
$ref: '#/components/messages/orderPlacedV2'
operations:
publishOrderPlaced:
action: send
channel:
$ref: '#/channels/orderPlaced'
messages:
- $ref: '#/channels/orderPlaced/messages/orderPlacedV2'
components:
messages:
orderPlacedV2:
name: com.example.bookstore.ordering.order-placed.v2
title: An order was placed
payload:
$ref: '#/components/schemas/orderPlacedV2'
schemas:
orderPlacedV2:
type: object
required: [orderId, total, currency, shipping]
properties:
orderId:
type: string
format: uuid
total:
type: number
currency:
type: string
description: ISO 4217 code, added in v2
shipping:
type: string
description: Standard, Express, SameDay; consumers must accept unknown values
valid AsyncAPI 3.1.0, operations: publishOrderPlaced:send
AsyncAPI 3'te action uygulamanın bakış açısından yazılıyor: send, bu uygulamanın channel'a message gönderdiği; receive, channel'dan message aldığı anlamına geliyor. Inventory kendi dokümanında aynı channel için receive yazar; iki doküman yan yana konunca "bu event'i kim yayınlıyor, kim dinliyor" sorusunun cevabı, yani bir event kataloğu ortaya çıkar. Dokümanı AsyncAPI'nin JavaScript parser'ı ile doğruladım; info altına tanımsız bir alan eklediğimde aynı parser "Property is not expected to be here" diyerek reddetti. Bu doğrulamayı CI (continuous integration) pipeline'ına koymak, dokümanın kodla birlikte bozulmasını engeller.
Bir adım ötesi schema registry: şemaların merkezi bir serviste sürümlendiği ve her yeni sürümün uyumluluk kuralına göre kontrol edildiği bir yapı. Confluent Schema Registry'de varsayılan kural backward ve yalnız son sürüme karşı kontrol ediliyor; transitive türleri bütün eski sürümlere karşı kontrol ediyor. Producer yeni bir şemayla yayın yapmaya çalıştığında uyumsuzluk deploy anında değil, şema kaydında yakalanıyor. Kafka ve Avro ya da Protobuf kullanan ekipler için doğal bir seçim; RabbitMQ ve JSON ile çalışan, birkaç ekipli Bookstore için AsyncAPI dokümanını CI'da doğrulamak ve bir uyumluluk test'i yazmak bugün yeterli. Serializer seçimi ve Avro/Protobuf ayrıntıları Backend serisinin "Mesaj Contract'ları" yazısında.
Sık Düşülen Tuzaklar
Domain event'i olduğu gibi integration contract yapmak
DDD serisinin "Domain Event'ler" yazısındaki tabloda gördük: domain event modelle birlikte serbestçe değişir, integration event sürümlenen bir contract'tır. Domain event'i doğrudan serialize edip yayınlamak, iç modeldeki her refactoring'i başka bir ekibin production hatasına çevirir. Arada her zaman açık bir dönüşüm olmalı.
Alanı yeniden adlandırmak ya da silmek
Yukarıdaki çıktı gösterdi: yeniden adlandırma hata vermeden yanlış veri üretir. Expand-contract ile yapılmalı. Bir alanı silmek de aynı şey; silmeden önce kimsenin okumadığından emin olmak gerekir.
Enum'u kapalı küme saymak
Producer'ın yeni değer eklemesi doğal bir evrim. Consumer bilinmeyen değeri tanımıyorsa ya bütün message'ı reddeder ya da yanlış bir varsayılana düşer. İkisi de contract'ta önceden konuşulmalı.
Her alanı zorunlu yapmak
Contract'taki her alan zorunluysa, alanı opsiyonel yapmak ya da kaldırmak bir breaking change olur. Zorunluluk, consumer'ın o alan olmadan gerçekten çalışamadığı alanlar için saklanmalı.
Anlam değişikliğini sürüm saymamak
Aynı ad ve tipte bir alanın anlamını değiştirmek, şema araçlarının hiçbirinin yakalayamadığı tek değişiklik. Anlam değişiyorsa yeni bir alan ya da yeni bir type adı gerekir.
Ne Zaman Kullanma
CloudEvents, AsyncAPI ve schema registry'nin her biri ayrı bir disiplin ve bir bedel. Tek bir process içindeki bir modular monolith'te module'ler arasında in-memory event'ler dolaşıyorsa, contract'lar aynı solution'da derlendiği için derleyici zaten uyumsuzluğu yakalar; sürüm ve katalog gereksiz. Producer ve consumer aynı ekipteyse ve birlikte deploy ediliyorsa da hafif bir tolerant reader disiplini yetebilir. Bu araçlar, ekip sayısı arttıkça ve kimin hangi event'i dinlediğini kimse aklında tutamaz hâle geldiğinde değer kazanır; Bookstore'un 12–18 ay içinde dört ekibe çıkma planı tam o eşik.
Küçük Bir Deneme
★☆☆ V1.ShippingSpeed için bilinmeyen string değeri Unknown adlı yeni bir üyeye çeviren küçük bir JsonConverter yaz ve 4. senaryoyu tekrar çalıştır. Consumer artık ne görüyor?
★★☆ Total → TotalAmount değişikliğini expand-contract ile planla: her adımda producer ne yayınlıyor, consumer ne okuyor? Ara adımın JSON'unu yaz ve V1 consumer'ının hâlâ doğru Total'ı okuduğunu doğrula.
★★★ Inventory için action: receive içeren ikinci bir AsyncAPI dokümanı yaz ve iki dokümanı karşılaştırıp "yayınlanan ama kimsenin dinlemediği" ve "dinlenen ama kimsenin yayınlamadığı" message'ları listeleyen küçük bir script hazırla. Hangi bilgiyi dokümanlara eklemen gerekti?
Contract'lar artık kontrollü değişiyor. Geriye serinin başından beri ertelediğimiz soru kaldı: bütün bu asenkron akışta bir sipariş neden takıldığında "ne oldu?" sorusunu nasıl cevaplayacağız, ve hangi akışlar hiç event-driven olmamalı? Serinin son yazısında event-driven architecture'ın bedeline, observability'ye ve Bookstore'un final mimarisine bakacağız.