Microservice'lerde Transaction Yok: Eventual Consistency'yi Tasarımla Kabullenmek
Service'ler arasında distributed transaction (2PC) kullanılmaz; anında tutarlılık gereken kural tek service'te tutulur, birden fazla service'e yayılan akış saga ve compensating action'larla, event'ler outbox ile kurulur. TransactionScope'un Linux ve macOS'ta verdiği gerçek hata, choreography ile çalışan bir checkout saga'sı, domain'deki ara durumlar ve 202 Accepted ile kullanıcıya gösterilen eventual consistency.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'da Inventory ve Ordering artık ayrı service'ler ve her birinin kendi veritabanı var. Checkout'un iki işi birlikte yapması gerekiyor: Inventory'de stoktan bir nüsha ayırmak ve Ordering'de siparişi oluşturmak. Ekipten biri, monolith günlerinden bildiği çözümü öneriyor: "İkisini bir TransactionScope'un içine koyalım, biri başarısız olursa ikisi de geri alınır." Dapper serisinin "Dapper ile Transaction: BeginTransaction, Commit, Rollback ve TransactionScope" yazısında bu class'ı tek bir veritabanıyla kullanmıştık.
Kod Linux container'da ya da bir Mac'te çalışıyor. İki bağlantı iki ayrı veritabanına gidiyor. Sence ne olur: ikisi birlikte commit edilir mi, bir exception mı gelir, yoksa ilk işlem yapılıp ikincisi mi kaybolur? Windows'ta çalışsaydı ne değişirdi? Ve en önemlisi: çalışsa bile bunu istemeli miyiz?
Neden Distributed Transaction Kullanmıyoruz?
Önce deneyelim. İki ayrı veritabanı (her biri bir service'in), tek bir TransactionScope:
using System.Transactions;
using Microsoft.Data.SqlClient;
// Bağlantı bilgisi ortam değişkeninden; iki ayrı service'in iki ayrı veritabanı
string server = Environment.GetEnvironmentVariable("BOOKSTORE_SQL")!;
string inventoryDb = server + ";Database=ms_05_inventory";
string orderingDb = server + ";Database=ms_05_ordering";
try
{
// "İkisini tek transaction'a koyalım" denemesi
using TransactionScope scope = new(TransactionScopeAsyncFlowOption.Enabled);
await using (SqlConnection inventory = new(inventoryDb))
{
await inventory.OpenAsync();
await new SqlCommand(
"UPDATE dbo.StockItems SET Reserved = Reserved + 1 WHERE Isbn = '978-0441013593'", inventory)
.ExecuteNonQueryAsync();
}
await using (SqlConnection ordering = new(orderingDb))
{
await ordering.OpenAsync(); // ikinci kaynak: transaction distributed'a yükseltilmek isteniyor
await new SqlCommand(
"INSERT dbo.Orders (Id, Status) VALUES (NEWID(), 'Placed')", ordering).ExecuteNonQueryAsync();
}
scope.Complete();
Console.WriteLine("Committed both");
}
catch (Exception ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
PlatformNotSupportedException: This platform does not support distributed transactions.
İkinci bağlantı açıldığında transaction'ın iki kaynağı oldu ve .NET onu distributed transaction'a yükseltmeye çalıştı. .NET 7'den itibaren bu yalnız Windows'ta destekleniyor; orada da MSDTC (Microsoft Distributed Transaction Coordinator) gerekiyor ve uygulamanın TransactionManager.ImplicitDistributedTransactions'ı açıkça true yapması lazım. Linux ve macOS'ta cevap net bir exception. Exception sonrası iki tabloya bakınca ilk UPDATE'in de geri alındığını görüyoruz; tutarsız bir yarım iş kalmamış:
SELECT Reserved FROM ms_05_inventory.dbo.StockItems WHERE Isbn = '978-0441013593';
SELECT COUNT(*) AS Orders FROM ms_05_ordering.dbo.Orders;
Reserved
-----------
0
(1 rows affected)
Orders
-----------
0
(1 rows affected)
Peki Windows'ta, MSDTC ile çalışsaydı kullanmalı mıydık? Microservices bağlamında cevap genelde hayır ve sebebi platform desteği değil. Distributed transaction'ların arkasındaki protokol 2PC (two-phase commit): bir coordinator önce bütün katılımcılara "commit'e hazır mısın?" diye sorar, hepsi "evet" derse ikinci aşamada "commit et" der. Bu basit protokolün microservices'te üç bedeli var.
Birincisi lock süresi. Katılımcılar "hazırım" dedikleri andan coordinator'ın kararı gelene kadar ilgili satırları kilitli tutar. Coordinator ya da network o arada sorun yaşarsa, Inventory'nin stok satırı başka bir service'in sorunu yüzünden kilitli kalır. İkincisi availability: işlem ancak bütün katılımcılar aynı anda ayaktaysa tamamlanabilir; bir önceki yazılarda gördüğümüz zincir çarpımı burada da geçerli. Üçüncüsü ve en belirleyicisi, katılımcılar: Bookstore'un checkout'unda ödeme sağlayıcısının HTTP (Hypertext Transfer Protocol) API'si (Application Programming Interface), message broker ve e-posta sağlayıcısı var. Bunların hiçbiri senin coordinator'ının yönettiği bir 2PC'ye katılmaz. Pat Helland'ın 2007 tarihli "Life beyond Distributed Transactions: an Apostate's Opinion" makalesinin ana fikri de bu: ölçeklenen sistemler, tek bir makinenin ötesine geçen atomik transaction'lara güvenemez ve uygulamayı buna göre tasarlamak gerekir.
Messaging ve Event-Driven Architecture serisinin "Transactional Outbox ve Inbox: Dual Write Problemini Anlamak ve Doğru Tasarlamak" yazısında 2PC'nin neden çözüm olmadığını message broker açısından da konuşmuştuk; protokolün teorisi ilerideki Distributed Systems Temelleri serisinin konusu. Bu yazının sorusu daha mimari: transaction yoksa tutarlılığı nasıl tasarlarız?
Sınır Testi: Tutarlılık Gerektiren Kural Tek Service'te
İlk cevap, sınırı doğru çizmek. Anında tutarlı olması gereken her kural (her invariant) tek bir service'in, hatta tek bir aggregate'in içinde yaşamalı. DDD (Domain-Driven Design) serisinin "Aggregate Tasarımı: Consistency Boundary, Küçük Aggregate'ler ve Id ile Referans" yazısında aggregate'i tam olarak bu şekilde tanımlamıştık: bir transaction içinde tutarlı kalması gereken en küçük sınır. Service sınırı, aggregate sınırından daha geniştir; dolayısıyla bir kural tek aggregate'e sığıyorsa tek service'e de sığar.
Bookstore'dan örnekler. "Stok eksiye düşmez" kuralı Inventory'nin StockItem aggregate'inin içinde: bir nüsha ancak boştaysa ayrılır. "Sipariş toplamı satırların toplamıdır" ve "kupon bir siparişte bir kez kullanılır" kuralları Ordering'in Order aggregate'inin içinde. Bunların hiçbiri iki service'i aynı anda kilitlemeyi gerektirmiyor.
Peki "kampanyalı kitaptan en fazla 100 adet satılır" kuralı? Burada iki seçenek var ve karar sınırı belirliyor. Kural stokla ilgiliyse (depoda kampanya için ayrılmış 100 nüsha var) Inventory'de yaşar ve rezervasyonun bir parçası olur. Kural fiyatlandırmayla ilgiliyse (ilk 100 sipariş indirimli) Ordering'de bir kampanya sayacı olarak yaşar. Yanlış cevap, kuralın yarısını birine yarısını öbürüne koyup ikisini aynı anda tutarlı tutmaya çalışmak. Bir kuralın gerçekten iki service'in verisine anında ihtiyaç duyduğunu görüyorsan, bu bir tasarım sinyali: sınır yanlış yerde olabilir. Microsoft'un sınır rehberinin son kriteri de bunu söylüyor: tutarlılığı korumak bazen ilgili işlevi tek bir service'te toplamayı gerektirir.
Saga'ya Karar Seviyesinde Bakış
Birden fazla service'e yayılan bir iş akışı için kalan araç saga. Hector Garcia-Molina ve Kenneth Salem'in 1987'de SIGMOD'da (Special Interest Group on Management of Data) sunduğu "Sagas" makalesinden gelen fikir: uzun bir işlemi, her biri kendi başına commit edilen bir dizi local transaction'a böl; bir adım başarısız olursa, daha önce tamamlanmış adımları compensating action'larla telafi et. Bookstore'un checkout'u bir saga: Ordering siparişi oluşturur, Inventory stok ayırır, Payment parayı çeker. Ödeme başarısız olursa Inventory ayırdığı stoğu bırakır, Ordering siparişi iptal eder.
Akışı küçük bir in-memory bus üzerinde, choreography ile kuralım: her service kendi işini yapıp bir event yayınlıyor, bir sonraki adım o event'i dinleyen service'te:
namespace Bookstore.Contracts;
// Service'ler arası integration event'ler (her biri kendi service'inin local transaction'ı sonunda yayınlanır)
public sealed record OrderPlaced(Guid OrderId, string Isbn, int Quantity, decimal Total);
public sealed record StockReserved(Guid OrderId);
public sealed record OutOfStock(Guid OrderId);
public sealed record PaymentCaptured(Guid OrderId);
public sealed record PaymentFailed(Guid OrderId, string Reason);
using Bookstore.Contracts;
namespace Bookstore;
// Broker yerine geçen en basit in-memory bus: yayınlanan event'i subscriber'lara sırayla verir
public sealed class Bus
{
private readonly Dictionary<Type, List<Action<object>>> _handlers = [];
private readonly Queue<object> _pending = new();
public void Subscribe<T>(Action<T> handler)
{
if (!_handlers.TryGetValue(typeof(T), out List<Action<object>>? list))
_handlers[typeof(T)] = list = [];
list.Add(e => handler((T)e));
}
public void Publish(object e) => _pending.Enqueue(e);
public void Drain()
{
while (_pending.TryDequeue(out object? e))
{
Console.WriteLine($" -> {e}");
foreach (Action<object> h in _handlers.GetValueOrDefault(e.GetType(), [])) h(e);
}
}
}
public sealed class Ordering(Bus bus)
{
public Dictionary<Guid, string> Status { get; } = [];
public void Place(Guid orderId, string isbn, int quantity, decimal total)
{
Status[orderId] = "Placed"; // kullanıcıya "siparişin alındı" bu noktada dönülür
bus.Publish(new OrderPlaced(orderId, isbn, quantity, total));
}
public void Wire()
{
bus.Subscribe<PaymentCaptured>(e => Status[e.OrderId] = "Paid");
bus.Subscribe<OutOfStock>(e => Status[e.OrderId] = "Cancelled(OutOfStock)");
bus.Subscribe<PaymentFailed>(e => Status[e.OrderId] = "Cancelled(PaymentFailed)");
}
}
public sealed class Inventory(Bus bus, int onHand)
{
private readonly Dictionary<Guid, int> _reservations = [];
public int Available => onHand - _reservations.Values.Sum();
public void Wire()
{
bus.Subscribe<OrderPlaced>(e =>
{
if (Available < e.Quantity) { bus.Publish(new OutOfStock(e.OrderId)); return; }
_reservations[e.OrderId] = e.Quantity;
bus.Publish(new StockReserved(e.OrderId));
});
// Compensating action: ödeme başarısızsa ayrılan stoğu geri bırak
bus.Subscribe<PaymentFailed>(e => _reservations.Remove(e.OrderId));
}
}
public sealed class Payments(Bus bus, Func<Guid, bool> providerAccepts)
{
public void Wire() =>
bus.Subscribe<StockReserved>(e => bus.Publish(providerAccepts(e.OrderId)
? new PaymentCaptured(e.OrderId)
: new PaymentFailed(e.OrderId, "card declined")));
}
using System.Globalization;
using Bookstore;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Guid ada = Guid.Parse("0199f0c4-5c1e-7000-8000-00000000000a");
Guid alan = Guid.Parse("0199f0c4-5c1e-7000-8000-00000000000b");
Bus bus = new();
Ordering ordering = new(bus);
Inventory inventory = new(bus, onHand: 3);
Payments payments = new(bus, providerAccepts: orderId => orderId != alan);
ordering.Wire(); inventory.Wire(); payments.Wire();
Console.WriteLine("Ada orders 2 x Dune");
ordering.Place(ada, "978-0441013593", 2, 25.00m);
bus.Drain();
Console.WriteLine("Alan orders 1 x Dune (card will be declined)");
ordering.Place(alan, "978-0441013593", 1, 12.50m);
bus.Drain();
foreach ((Guid id, string status) in ordering.Status)
Console.WriteLine($"{(id == ada ? "Ada " : "Alan")}: {status}");
Console.WriteLine($"Dune available in Inventory: {inventory.Available}");
Ada orders 2 x Dune
-> OrderPlaced { OrderId = 0199f0c4-5c1e-7000-8000-00000000000a, Isbn = 978-0441013593, Quantity = 2, Total = 25.00 }
-> StockReserved { OrderId = 0199f0c4-5c1e-7000-8000-00000000000a }
-> PaymentCaptured { OrderId = 0199f0c4-5c1e-7000-8000-00000000000a }
Alan orders 1 x Dune (card will be declined)
-> OrderPlaced { OrderId = 0199f0c4-5c1e-7000-8000-00000000000b, Isbn = 978-0441013593, Quantity = 1, Total = 12.50 }
-> StockReserved { OrderId = 0199f0c4-5c1e-7000-8000-00000000000b }
-> PaymentFailed { OrderId = 0199f0c4-5c1e-7000-8000-00000000000b, Reason = card declined }
Ada : Paid
Alan: Cancelled(PaymentFailed)
Dune available in Inventory: 1
Alan'ın siparişinde ödeme reddedildi; Inventory PaymentFailed'i duyunca ayırdığı nüshayı bıraktı ve Ordering siparişi PaymentFailed sebebiyle iptal etti. Stokta 1 nüsha kaldı, çünkü yalnız Ada'nın 2 nüshası ayrılı. Hiçbir an iki service aynı transaction'da değildi; her biri kendi verisini kendi local transaction'ında değiştirdi.
Dikkat edilmesi gereken üç karar var. Compensation bir rollback değil: geri alınan şey bir iş işlemi, iz bırakıyor ve bazen hiç geri alınamıyor (gönderilmiş bir e-posta gibi). Bu yüzden adımların sırası önemli; geri alınamayan adımlar mümkün olduğunca sona konur. İkincisi, choreography mi orchestration mı: üç adımlık bu akışta event'lerle koordinasyon yeterince okunur, ama adım sayısı ve dallanma arttıkça akışın tamamını tek yerde gösteren bir orchestrator (bir state machine) daha anlaşılır olur. Üçüncüsü izolasyon: saga'nın ara durumları başka işlemlere görünür; Alan'ın nüshası birkaç saniyeliğine ayrılı görünür ve o arada başka bir müşteri "stok yok" cevabı alabilir. Bu kararların ayrıntısı, pivot adım kavramı ve saga anomalileri Messaging serisinin "Saga Pattern: Choreography mi Orchestration mı?" yazısında, .NET'te persist edilen bir state machine ile uygulanması da "Saga'yı .NET'te Uygulamak: Persist Edilen State Machine, Timeout ve Compensation" yazısında.
Outbox: Event Kaybolursa Saga Durur
Yukarıdaki örnekte bir boşluk var: Ordering.Place sipariş durumunu yazıyor ve ardından event'i yayınlıyor. Gerçekte bunlar iki ayrı sisteme iki ayrı yazma: veritabanına ve broker'a. Veritabanı yazması başarılı olup broker'a yazma başarısız olursa, sipariş "Placed" olarak kalır ve saga hiç ilerlemez; tersi olursa, veritabanında olmayan bir sipariş için stok ayrılır. Bu dual write problemi, saga'yı event'le yürüten her microservice tasarımının ilk kararı. Cevap transactional outbox: event, iş verisiyle aynı local transaction'da bir outbox tablosuna yazılır ve ayrı bir süreç oradan broker'a taşır. dotnet/eShop'taki IntegrationEventLogEF projesi bu fikrin .NET'teki somut bir örneği. Nasıl kurulacağı, relay'in neden duplicate üretebileceği ve inbox ile nasıl karşılanacağı Messaging serisinin outbox yazısında; bu seride bilmemiz gereken, saga'dan söz edilen her yerde outbox'ın da bir karar olarak masada olduğu.
Ara Durumlar Domain'in Parçasıdır
Eventual consistency'yi kabullenmenin en önemli kısmı teknik değil, dilsel. Sistem artık "sipariş oldu" ile "sipariş olmadı" arasında anlık geçmiyor; arada saniyeler, bazen dakikalar süren durumlar var. Bu durumların bir adı, bir anlamı ve iş tarafının onayladığı bir davranışı olmalı. Bookstore'un OrderStatus'u bu yüzden Placed, PaymentPending, Paid, Shipped, Delivered ve Cancelled değerlerini taşıyor. Mimariye Giriş serisinde koyduğumuz hedef de bir ara durum tarifiydi: ödeme sağlayıcısı 5 saniye içinde cevap vermezse sipariş PaymentPending olur ve kullanıcıya hata dönülmez.
dotnet/eShop aynı yaklaşımı daha da açık yazıyor: Ordering domain'indeki OrderStatus enum'unda Submitted, AwaitingValidation, StockConfirmed, Paid, Shipped ve Cancelled değerleri var. Stok doğrulaması ve ödeme ayrı service'lerde yapıldığı için sipariş, "gönderildi" ile "onaylandı" arasındaki her adımı bir durum olarak taşıyor.
Bu durumları domain expert'le konuşmak gerekiyor, çünkü her biri bir iş kararı içeriyor. Müşteriye "siparişin alındı" e-postası Placed'de mi gider, Paid'de mi? PaymentPending'de kalan bir sipariş ne kadar bekler, sonra ne olur? Stok rezervasyonu ödeme beklerken ne kadar tutulur? Bu cevaplar kodda timeout, mesaj metni ve compensation olarak karşılık buluyor.
Eventual Consistency'yi Kullanıcıya Göstermek
Ara durumlar arayüze de yansımalı. Checkout endpoint'i artık "sipariş tamamlandı" diyemez, çünkü tamamlanmadı; diyebileceği şey "siparişini aldım, işliyorum". HTTP bunun için bir status code'a sahip: 202 Accepted, isteğin işlenmek üzere kabul edildiğini ama işlemin henüz bitmediğini söyler. Cevaba durumun izlenebileceği adresi koymak yaygın bir pratik:
using System.Collections.Concurrent;
using System.Net.Http.Json;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.UseUrls("http://127.0.0.1:5088");
WebApplication app = builder.Build();
ConcurrentDictionary<Guid, string> orders = new();
// Checkout: siparişi kaydet, event'i yayınla, sonucu bekleme
app.MapPost("/orders", () =>
{
Guid id = Guid.CreateVersion7();
orders[id] = "Placed";
_ = Task.Delay(300).ContinueWith(_ => orders[id] = "Paid"); // stok + ödeme arka planda tamamlanıyor
return Results.Accepted($"/orders/{id}", new OrderStatusDto(id, orders[id]));
});
app.MapGet("/orders/{id:guid}", (Guid id) =>
orders.TryGetValue(id, out string? status) ? Results.Ok(new OrderStatusDto(id, status)) : Results.NotFound());
await app.StartAsync();
using HttpClient client = new() { BaseAddress = new Uri("http://127.0.0.1:5088") };
HttpResponseMessage placed = await client.PostAsync("/orders", content: null);
Console.WriteLine($"POST /orders -> {(int)placed.StatusCode} {placed.StatusCode}, Location: {placed.Headers.Location}");
Console.WriteLine($" body: {await placed.Content.ReadAsStringAsync()}");
// Angular tarafı bu adresi birkaç kez sorar (ya da SignalR ile haber bekler)
for (int attempt = 1; attempt <= 3; attempt++)
{
OrderStatusDto? now = await client.GetFromJsonAsync<OrderStatusDto>(placed.Headers.Location);
Console.WriteLine($"GET #{attempt}: {now!.Status}");
if (now.Status != "Placed") break;
await Task.Delay(250);
}
await app.StopAsync();
public sealed record OrderStatusDto(Guid Id, string Status);
POST /orders -> 202 Accepted, Location: /orders/01a1b86b-a6a6-77ce-a3f6-7c273a825323
body: {"id":"01a1b86b-a6a6-77ce-a3f6-7c273a825323","status":"Placed"}
GET #1: Placed
GET #2: Placed
GET #3: Paid
Örnekteki Task.Delay(300).ContinueWith(...) yalnız arka plandaki saga'yı taklit ediyor. Önemli olan contract: POST 202 ve bir Location döndürüyor, client o adresten durumu okuyor. Angular tarafında bu, checkout sonrası ekranın iki aşamalı olması demek. İlk aşamada "Siparişin alındı, ödeme onayı bekleniyor" mesajı ve sipariş numarası görünür; sayfa kapatılsa bile sipariş kaybolmaz. İkinci aşamada durum Paid'e geçince mesaj "Siparişin onaylandı" olur, Cancelled'a geçerse sebebiyle birlikte açıklanır. Durumu öğrenmek için birkaç saniye aralıkla, belli bir üst sınıra kadar tekrar soran bir polling yeterli olabilir; daha canlı bir deneyim için SignalR ya da ASP.NET Core Web API serisinin ".NET 10 Yenilikleri: ASP.NET Core'da AddValidation, OpenAPI 3.1 ve Server-Sent Events" yazısında gördüğümüz SSE (Server-Sent Events) ile server durumu kendisi itebilir.
Arayüzde kaçınılması gereken iki şey var. Ara durumu gizleyip spinner'ı ödeme sonuçlanana kadar döndürmek, eventual consistency'yi kullanıcıya timeout olarak yansıtır. Ara durumu "başarılı" gibi göstermek ise, sonradan iptal edilen siparişte güveni kırar. Kullanıcıya gerçeği söyleyen ara bir ekran, iki uçtan da iyidir.
Sık Düşülen Tuzaklar
Distributed transaction'ı Windows'ta "çalışıyor" diye kullanmak. MSDTC ile iki SQL (Structured Query Language) Server arasında çalışan bir transaction, ilk HTTP API'si ya da broker devreye girdiğinde işe yaramaz ve lock'ları network'ün kaderine bağlar.
Invariant'ı iki service'e bölmek. Anında tutarlı olması gereken bir kural için iki service'i senkron çağırıp "ikisi de başarılıysa tamam" demek, 2PC'nin protokolsüz bir taklidi. Kural tek bir yerde yaşamalı.
Compensation'ı unutmak. Saga'nın mutlu yolunu yazıp başarısızlık yolunu sonraya bırakmak, ilk reddedilen kartta sonsuza kadar ayrılı kalan stoklar demek.
Outbox'sız event. Veritabanına yazıp ardından broker'a publish etmek, bir gün bir event'in kaybolmasını ve bir saga'nın sessizce durmasını garanti eder.
Ara durumu kullanıcıdan saklamak. "Sipariş tamamlandı" ekranı gösterip sonradan iptal e-postası göndermek, eventual consistency'nin bedelini müşteriye ödetmek demek.
Ne Zaman Kullanma
Eventual consistency her kural için kabul edilebilir değil. Bir kural gerçekten anında tutarlılık istiyorsa ve bu, iki service'in verisini kesiyorsa, saga ile yamamak yerine iki parçayı tek bir service'te birleştirmeyi düşün. Bookstore'da kampanya fiyatlandırması ile sipariş oluşturma bu yüzden aynı service'te. Ekip tek ve sistem küçükse, monolith ya da modular monolith içinde tek bir veritabanı transaction'ı, saga'nın bütün karmaşıklığını tek bir satıra indirir; bu yazının tekniklerine ancak service'leri ayırmak için güçlü bir sebebin varsa ihtiyaç duyarsın.
Küçük Bir Deneme
★☆☆ Saga örneğinde Inventory'nin başlangıç stoğunu 2 yap ve üçüncü bir sipariş ekle. OutOfStock yolunu çalıştır: Ordering'in son durumu ne oluyor, Payment hiç çağrılıyor mu?
★★☆ Inventory'deki compensation handler'ını kaldır ve tekrar çalıştır. Stok sayısı ne oluyor? Gerçek bir sistemde bu hatayı kim, ne zaman fark ederdi? Fark etmeyi kolaylaştıracak bir metrik ya da alarm öner.
★★★ Aynı checkout akışını orchestration ile yeniden yaz: bir CheckoutSaga class'ı OrderPlaced'i dinlesin, Inventory'ye ve Payment'a command göndersin, sonuçlara göre ilerlesin ya da compensation başlatsın. Hangi tasarımda "ödeme 5 saniyede sonuçlanmazsa PaymentPending" kuralını eklemek daha kolay?
Bu yazıda service'ler arası tutarlılığı event'lerle kurduk. Bir sonraki yazıda bir adım geri çekilip iletişimin kendisine bakıyoruz: sync request/response ile asenkron event arasındaki fark, REST (Representational State Transfer), gRPC ve message'ın yeri, temporal coupling ve checkout akışını senkron bir zincirden event-driven bir akışa dönüştürmek.