Mert Özen Her Satırda Daha İleri
Backend .NET ile Mikroservis Uygulaması — #07

Checkout Saga'sı: Orchestration ile Sipariş, Stok, Ödeme ve Compensation

Mert Özen 14 Kas 2026 12 dk 6 görüntülenme
Checkout Saga'sı: Orchestration ile Sipariş, Stok, Ödeme ve Compensation

Checkout'un sahibi Ordering'deki bir Wolverine saga'sı: stoğu ve ödemeyi mesajlarla istiyor, ödeme reddinde ya da 15 dakikalık timeout'ta stoğu serbest bırakıp siparişi iptal ediyor, iptal edilmiş siparişe geç gelen ödemeyi iadeye çeviriyor. Sipariş artık 202 ile kabul ediliyor; Inventory kapalıyken bile 23 ms'de cevap veriyor ve Inventory dönünce onaylanıyor. Saga'nın adımları veritabanısız unit testlerle doğrulanıyor.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6, WolverineFx 6.64, RabbitMQ 4.3, Entity Framework Core 10 ve xUnit v3 ile derlenip denenmiştir.

Önce Tahmin Et

05'te Inventory kapalıyken sipariş göndermiştik: HTTP 503 in 2.616873s, sipariş yok. Bu yazının sonunda checkout bir saga'ya dönüşüyor ve Ordering artık Inventory'yi senkron çağırmıyor. Inventory yine kapalıyken POST /api/v1/orders ne döner ve ne kadar sürer? İkinci soru: ödeme 15 dakikalık süre dolduktan sonra başarılı olursa, yani sipariş iptal edildikten sonra Payments "ödeme alındı" derse, sistem ne yapmalı? Cevaplar aşağıdaki çalıştırmalarda.

Neden Orchestration ve Neden Ordering?

Checkout'ta üç service'in verisi değişiyor: Ordering'de sipariş, Inventory'de stok, Payments'ta ödeme. Aralarında transaction yok; Messaging ve Event-Driven Architecture serisinin Saga Pattern yazısında bunun iki çözümünü karşılaştırdık. Choreography'de her service bir event'e tepki verip kendi event'ini yayınlar ve akış hiçbir yerde yazılı değildir. Orchestration'da bir taraf akışın sahibidir ve diğerlerine ne yapacaklarını command ile söyler. Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) orchestration'ı seçtik ve sahibi Ordering: sipariş onun, müşteriye "siparişin onaylandı" diyecek olan o ve akış dört adım ile üç hata yolundan oluşuyor. Bu kadar dallanan bir akışı event'lerin arasına dağıtmak, hatayı bulmayı zorlaştırırdı.

Saga'nın adımları ve Order'ın durumları şöyle: sipariş Pending olarak doğuyor, stok ayrılınca StockReserved, ödeme alınınca Paid, stok kesinleşince Confirmed oluyor. Stok yoksa, ödeme reddedilirse ya da ödeme 15 dakika içinde gelmezse Cancelled.

07-checkout-saga-tr.png

Command'lar Queue'ya, Event'ler Exchange'e

Saga'nın konuştuğu mesajlar Bookstore.Contracts'ta. Command'lar bir şey yapılmasını isteyen, tek alıcısı olan mesajlar; event'ler olmuş bir şeyi duyuran, kimin dinlediğini bilmeyen mesajlar:

namespace Bookstore.Contracts.Inventory;

// Ordering'in checkout saga'sının Inventory'ye gönderdiği command'lar. OrderId her birini idempotent yapar.
public sealed record ReserveStock(Guid OrderId, IReadOnlyList<StockLine> Lines);

public sealed record StockLine(string Isbn, int Quantity);

public sealed record CommitStock(Guid OrderId);

public sealed record ReleaseStock(Guid OrderId, string Reason);

// Inventory'nin cevapları, event olarak yayınlanıyor.
public sealed record StockReserved(Guid OrderId, Guid ReservationId, DateTimeOffset ExpiresAtUtc);

public sealed record StockReservationFailed(Guid OrderId, IReadOnlyList<string> UnavailableIsbns);

public sealed record StockCommitted(Guid OrderId);

Payments tarafında da aynı yapı var: AuthorizePayment ve RefundPayment command'ları, PaymentSucceeded ve PaymentFailed event'leri. Command'lar alıcının queue'suna gidiyor (inventory.commands, payments.commands), event'ler yayınlayanın exchange'ine. Ordering iki cevap exchange'ine (bookstore.inventory, bookstore.payments) kendi ordering.checkout queue'sunu bağlıyor. Kanal türlerinin farkı aynı serinin Channel'lar yazısında.

İlk çalıştırmada Ordering açılmadı. Inventory kendi queue'sunu inventory.dead-letter DLQ'su (Dead Letter Queue; işlenemeyen mesajların ayrıldığı queue) ile oluşturmuştu; Ordering ise aynı queue'ya gönderebilmek için onu Wolverine'in varsayılan ayarlarıyla bir kez daha tanımlamaya çalıştı. Wolverine'in hata mesajı sorunu ve üç çözümünü kendisi yazıyor:

System.InvalidOperationException: Rabbit MQ rejected Wolverine's declaration of queue 'inventory.commands' because the queue already exists
with a different configuration, and the broker closed the channel. Wolverine tried to declare it as IsDurable=True, IsExclusive=False, AutoDelete=False,
x-dead-letter-exchange=wolverine-dead-letter-queue.
...
Fix this in one of three ways: (1) align the endpoint's configuration with the existing queue, (...) (2) delete
the existing queue and let Wolverine recreate it; or (3) mark the queue as ExternallyOwned() so Wolverine stops declaring it at all.

Doğru olan üçüncüsü. Queue'nun sahibi Inventory; gönderen onun ayarlarını bilmek zorunda olmamalı:

// Command'lar alıcının queue'suna. Bu queue'ların (ve dead letter ayarlarının) sahibi Inventory ve Payments:
// Ordering onlara gönderiyor ama hiçbir zaman tanımlamıyor.
opts.PublishMessage<ReserveStock>().ToRabbitQueue(CommandQueues.Inventory).ExternallyOwned();
opts.PublishMessage<CommitStock>().ToRabbitQueue(CommandQueues.Inventory).ExternallyOwned();
opts.PublishMessage<ReleaseStock>().ToRabbitQueue(CommandQueues.Inventory).ExternallyOwned();
opts.PublishMessage<AuthorizePayment>().ToRabbitQueue(CommandQueues.Payments).ExternallyOwned();
opts.PublishMessage<RefundPayment>().ToRabbitQueue(CommandQueues.Payments).ExternallyOwned();
opts.Policies.UseDurableOutboxOnAllSendingEndpoints();
// StartCheckout ve zamanlanmış CheckoutTimedOut Ordering'in içinde kalıyor, orderingdb'de saklanıyor
opts.Policies.UseDurableLocalQueues();

Inventory tarafında 05'teki StockReservations iki kapıya hizmet ediyor: gRPC API'si ve ReserveStock mesajı. Stok yetmediğinde artık RpcException fırlatmıyor, bir sonuç dönüyor; gRPC service'i onu FailedPrecondition'a, mesaj handler'ı StockReservationFailed event'ine çeviriyor. Rezervasyonun sipariş id'siyle idempotent olması 05'ten geliyor ve burada en çok işe yarıyor: RabbitMQ aynı ReserveStock'u iki kez teslim ederse stok iki kez düşmüyor. Yeni bir adım da var: CommitStock, rezervasyonu satışa çeviriyor ve kitapları raftan kalıcı olarak düşüyor.

Saga'nın Kendisi: CheckoutSaga

Wolverine'de saga, Saga sınıfından türeyen ve state'ini property'lerinde tutan bir handler. Messaging ve Event-Driven Architecture serisinin .NET'te saga yazısında persist edilen state machine'i kendimiz yazmıştık; burada aynı fikri Wolverine'in saga desteği ve Entity Framework Core (EF Core) ile kuruyoruz:

public static (CheckoutSaga, ReserveStock) Start(StartCheckout command) =>
(
    new CheckoutSaga
    {
        Id = command.OrderId,
        Step = CheckoutStep.AwaitingStock,
        Total = command.Total,
        Currency = command.Currency,
        CustomerEmail = command.CustomerEmail
    },
    new ReserveStock(command.OrderId, command.Lines)
);

public async Task<(AuthorizePayment, CheckoutTimedOut)> Handle(
    [SagaIdentityFrom(nameof(StockReserved.OrderId))] StockReserved message,
    IOrderRepository orders, IOptions<CheckoutOptions> options, CancellationToken ct)
{
    Order order = await orders.GetAsync(Id, ct);
    order.MarkStockReserved(message.ReservationId, message.ExpiresAtUtc.UtcDateTime);
    Step = CheckoutStep.AwaitingPayment;

    // Ödeme isteniyor ve aynı transaction'da onun için bir timeout zamanlanıyor
    return (new AuthorizePayment(Id, Total, Currency, CustomerEmail), new CheckoutTimedOut(Id, options.Value.PaymentTimeout));
}

public async Task<(ReleaseStock, OrderCancelled)> Handle(
    [SagaIdentityFrom(nameof(PaymentFailed.OrderId))] PaymentFailed message, IOrderRepository orders,
    TimeProvider clock, CancellationToken ct)
{
    // Compensation: önceki adımda ayrılan stok yeniden satışa çıkıyor
    return (new ReleaseStock(Id, "payment-failed"), await CancelAsync(orders, $"Payment failed: {message.Reason}", clock, ct));
}

Birkaç karar bu koddan okunuyor. Handler'ların döndürdüğü mesajlar Wolverine için "cascading message": saga state'i, Order'daki değişiklik ve giden mesajlar aynı transaction'da outbox'a yazılıyor. Saga'nın kimliği sipariş id'si; Wolverine kimliği mesajda CheckoutSagaId, SagaId ya da Id adlı bir alanda arıyor, bizim mesajlarımızda ise alanın adı OrderId. [SagaIdentityFrom] attribute'u bunu handler tarafında söylüyor. Contract'lara Wolverine'in [SagaIdentity] attribute'unu koymak da mümkündü, ama o zaman Bookstore.Contracts bir pakete bağlanırdı ve 01'deki architecture test bunu reddederdi.

CheckoutTimedOut, Wolverine'in TimeoutMessage'ından türüyor: gönderildiği anda değil, verilen süre sonra teslim ediliyor ve orderingdb'de saklandığı için Ordering yeniden başlasa da kaybolmuyor. Saga o anda yoksa (tamamlanmış ya da iptal edilmişse) Wolverine onu sessizce yok sayıyor.

Saga, Order aggregate'ini IOrderRepository üzerinden değiştiriyor. EF Core implementasyonu, Wolverine'in saga için kullandığı aynı OrderingDbContext'i alıyor. Bunu varsaymak yerine log'dan doğruladık; StockReserved işlenirken giden tek SQL batch'i:

[ordering]       SET NOCOUNT ON;
[ordering]       UPDATE [CheckoutSagas] SET [Step] = @p0, [Version] = @p1
[ordering]       OUTPUT 1
[ordering]       WHERE [Id] = @p2 AND [Version] = @p3;
[ordering]       UPDATE [Orders] SET [Status] = @p4, [StockReservationId] = @p5, [StockReservedUntilUtc] = @p6
[ordering]       OUTPUT INSERTED.[RowVersion]
[ordering]       WHERE [Id] = @p7 AND [RowVersion] = @p8;

Saga state'i ve sipariş tek SaveChanges'ta, ikisi de optimistic concurrency ile. Version, Wolverine'in Saga sınıfından geliyor ve her kayıtta artıyor; EF Core mapping'inde onu concurrency token yaptık. Aynı siparişe ait iki mesaj aynı anda işlenirse (süre dolarken gelen bir ödeme gibi), ikincisinin kaydı reddediliyor; Ordering bu hatada mesajı kısa beklemelerle yeniden deniyor ve ikinci deneme saga'nın güncel hâlini görüyor. Saga tamamlanınca (MarkCompleted()) satır siliniyor; başarılı bir checkout'tan sonra CheckoutSagas tablosunda satır kalmadığını kontrol ettik.

Sipariş API'si: 201 Değil, 202

Siparişi oluşturan kod artık Inventory'yi çağırmıyor. Siparişi kaydediyor, OrderPlaced event'ini yayınlıyor ve saga'yı başlatacak yerel StartCheckout command'ını gönderiyor; üçü aynı transaction'da:

// Burada Inventory çağrısı yok: stoğu saga mesajla ayırıyor. Inventory kapalıyken checkout artık durmuyor.
await outbox.SendAsync(new StartCheckout(
    order.Id.Value,
    [.. order.Items.Select(i => new StockLine(i.Isbn, i.Quantity))],
    order.Total.Amount,
    order.Total.Currency,
    order.Buyer.Email));

// Orders/OrderItems INSERT'ü ve iki outbox satırı tek transaction'da, sonra mesajlar çıkıyor
await outbox.SaveChangesAndFlushMessagesAsync(ct);

Endpoint 201 yerine 202 Accepted dönüyor: sipariş var, ama sonucu henüz belli değil. Bu bir contract değişikliği ve 03'te CI'a koyduğumuz oasdiff kontrolü onu yakaladı:

1 changes: 1 error, 0 warning, 0 info
error	[response-success-status-removed] at src/Services/Ordering/openapi/ordering.json
	in API POST /api/v1/orders
		removed the success response with the status `201`

Henüz bu API'yi kullanan bir istemci olmadığı için değişikliği bilerek kabul ettik; Angular istemcisi 11'de doğrudan 202 ile yazılacak. Grace'in siparişi ve Location header'ındaki adresi yarım saniyede bir sorgulamanın sonucu:

HTTP 202 in 0.367596s
Location: http://localhost:5102/api/v1/orders/01a1da7d-057f-70b4-bd4d-8621c2cbe883
{"id":"01a1da7d-057f-70b4-bd4d-8621c2cbe883","status":"Pending", ... "stockReservedUntilUtc":null}
0.5s Pending
1.0s Pending
1.5s Pending
2.0s StockReserved
2.5s StockReserved
3.0s Confirmed

Üç saniyenin büyük kısmı ilk mesajlarda Wolverine'in handler kodunu üretip derlemesi; sonraki üç sipariş POST'tan 0,13–0,15 saniye sonra Confirmed'dı. Inventory ve Payments'ın log'ları aynı siparişin yolunu gösteriyor:

[inventory]       Reserved 1 line(s) for order 01a1da7d-057f-70b4-bd4d-8621c2cbe883 until 2026-11-14T14:56:30.8791400Z
[payments]       Authorized 399.80 TRY for order 01a1da7d-057f-70b4-bd4d-8621c2cbe883: pay_01a1da7d109974a0a08049a974e1bf4b
[inventory]       Reservation 01a1da7d-0d1f-76eb-b28b-58b2cb317406 for order 01a1da7d-057f-70b4-bd4d-8621c2cbe883 Committed
[notifications]       Order 01a1da7d-057f-70b4-bd4d-8621c2cbe883 confirmed for grace@example.com

Önce Tahmin Et'in ilk sorusu: Inventory'yi durdurup sipariş gönderdik. Cevap HTTP 202 in 0.022776s; 10 saniye sonra sipariş hâlâ Pending'di ve inventory.commands'ta bir mesaj bekliyordu. Inventory'yi başlattıktan sonraki ilk sorgularda (14:39:16) sipariş Confirmed'dı. Microservices serisinin İletişim Stilleri yazısında koyduğumuz hedefe ulaştık: Inventory'nin kesintisi artık kaybedilmiş satış değil, gecikmiş onay. Bedeli kullanıcı deneyiminde: ekran "siparişin alındı, onay bekleniyor" demek zorunda. 11'de bunu Angular'da ve SignalR ile çözeceğiz.

Hata Yolları: Stok Yok, Ödeme Reddi, Zaman Aşımı

Payments bu yazıda bir yer tutucu: 5.000 TRY (Türk lirası) üstündeki ödemeleri reddediyor. 08'de yerini sahte bir ödeme sağlayıcısına ve webhook'a bırakacak. Üç hata yolunu sırayla denedik.

Stoğu sıfır olan Don Quixote için sipariş: Cancelled, sebebi Out of stock: 9780060934347. Hiçbir şey ayrılmadığı için compensation yok. 5.668 TRY'lik sipariş: Payments reddetti, saga stoğu serbest bıraktı ve siparişi iptal etti:

[payments]       Declined 5668.00 TRY for order 01a1da7d-9bdb-7bad-aea8-a78ecf0f0fa7
[inventory]       Reservation 01a1da7d-9c23-777e-aa14-e26c99b1cfce for order 01a1da7d-9bdb-7bad-aea8-a78ecf0f0fa7 Released
[notifications]       Order 01a1da7d-9bdb-7bad-aea8-a78ecf0f0fa7 cancelled for linus@example.com: Payment failed: card-declined

Zaman aşımı için Ordering'in Checkout:PaymentTimeout ayarını 20 saniyeye indirdik, Payments'ı durdurduk ve sipariş gönderdik. Sipariş 14:37:45'te StockReserved oldu ve 14:38:07'deki sorguda Cancelled'dı. Wolverine'in incoming tablosunda zamanlanmış mesajlar görünüyor; üçüncü satır bu siparişin timeout'u, ilk ikisi önceki başarılı siparişlerin 15 dakikalık timeout'ları:

message_type                                 execution_time                     status
Bookstore.Ordering.Checkout.CheckoutTimedOut 2026-11-14 14:51:31.0886160 +00:00 Scheduled
Bookstore.Ordering.Checkout.CheckoutTimedOut 2026-11-14 14:52:07.5313680 +00:00 Scheduled
Bookstore.Ordering.Checkout.CheckoutTimedOut 2026-11-14 14:38:02.7646180 +00:00 Handled

İlk ikisinin saga'ları çoktan tamamlanmıştı; zamanları geldiğinde Wolverine onları yok saydı ve log'a hata düşmedi. Rezervasyonun süresini de değiştirdik: Inventory'nin TTL'i (Time To Live; kaydın geçerli kalacağı süre) 15 dakikadan 20'ye çıktı. Stoğu normalde saga serbest bırakıyor; TTL, hiç bitmeyen bir saga için son çare. Saga'nın 15 dakikası dolmadan rezervasyon kendiliğinden düşerse, ödemesi alınmış bir sipariş satılmış sanılan bir stokla kalır.

Compensation Geri Alma Değildir: Geç Gelen Ödeme

Zaman aşımı deneyinin devamı Önce Tahmin Et'in ikinci sorusu. Sipariş iptal edildikten sonra Payments'ı başlattık. payments.commands'ta bekleyen AuthorizePayment işlendi ve ödeme başarılı oldu. Ama saga artık yok. Bu durum için saga'da bir NotFound method'u var:

// Saga yok (tamamlandı ya da süresi doldu). Şimdi başarılı olan bir ödeme iptal edilmiş bir siparişe ait:
// para geri gitmeli. Bu yeni bir işlem (iade), geri alma değil.
public static async Task<RefundPayment?> NotFound(
    [SagaIdentityFrom(nameof(PaymentSucceeded.OrderId))] PaymentSucceeded message, IOrderRepository orders, CancellationToken ct)
{
    Order order = await orders.GetAsync(message.OrderId, ct);
    return order.Status == OrderStatus.Cancelled ? new RefundPayment(message.OrderId, "order-cancelled") : null;
}
[payments]       Authorized 389.00 TRY for order 01a1da7e-22ba-71d8-8ff1-1f06d1468e59: pay_01a1da7eb7e47a3ea381580d548a5ad1
[payments] warn: Bookstore.Contracts.Payments.RefundPayment[0]
[payments]       Refund requested for order 01a1da7e-22ba-71d8-8ff1-1f06d1468e59: order-cancelled

Siparişin durumunu kontrol etmek önemli. Saga normal şekilde tamamlandıktan sonra aynı PaymentSucceeded ikinci kez teslim edilirse sipariş Confirmed'dır ve iade yapılmamalı. Compensation'ın neden "geri alma" olmadığını bu örnek iyi gösteriyor. Stok rezervasyonu gerçekten geri alınabiliyor: ReleaseStock durumu eski hâline getiriyor. Ödeme ise geri alınamıyor. Müşterinin kartından çekilen para ancak ayrı bir işlemle, iadeyle geri dönüyor; o işlemin kendi ücreti, süresi ve başarısız olma ihtimali var. 08'de iadeyi gerçek bir sağlayıcı akışına bağlayacağız.

Saga'yı Test Etmek

Saga'nın her adımı bir method; girdisi mesaj ve state, çıktısı yeni state ve mesajlar. IOrderRepository sayesinde testler veritabanı istemiyor:

[Fact]
public async Task A_declined_payment_releases_the_stock_and_cancels_the_order()
{
    await saga.Handle(new StockReserved(OrderId, Guid.CreateVersion7(), Clock.GetUtcNow().AddMinutes(20)), orders, Options, Ct);

    (ReleaseStock release, OrderCancelled cancelled) = await saga.Handle(new PaymentFailed(OrderId, "card-declined"), orders, Clock, Ct);

    Assert.Equal("payment-failed", release.Reason);
    Assert.Equal("Payment failed: card-declined", cancelled.Reason);
    Assert.Equal(OrderStatus.Cancelled, order.Status);
    Assert.True(saga.IsCompleted());
}
passed Bookstore.Ordering.Tests.CheckoutSagaTests.A_payment_for_a_cancelled_order_is_refunded (13ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.A_timeout_after_the_payment_does_nothing (2ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.No_stock_cancels_without_compensation (0ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.Happy_path_ends_with_a_confirmed_order (2ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.A_declined_payment_releases_the_stock_and_cancels_the_order (0ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.Start_reserves_the_stock (0ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.A_timeout_while_waiting_for_payment_releases_the_stock (0ms)
  total: 15
  failed: 0

Happy path, ödeme reddi, stok yokluğu, ödeme beklenirken ve ödemeden sonra gelen timeout, iptal edilmiş siparişin geç ödemesi: yedi test milisaniyeler içinde bitiyor. Bu testler mesajların doğru kurulduğunu ve state'in doğru ilerlediğini gösteriyor; RabbitMQ routing'ini, outbox'ı ve gerçek zamanlamayı göstermiyor. Onlar için yukarıdaki elle yaptığımız denemelerin otomatik hâli gerekiyor; Aspire ile uçtan uca testi 13'te kuracağız.

Üretimde Dikkat

Takılı kalan saga'ları say. CheckoutSagas tablosundaki satırlar devam eden checkout'lar. Bir saatten eski bir satır, bir mesajın DLQ'ya düştüğünü ya da bir service'in cevap vermediğini gösterir. En basit metric: adım başına satır sayısı ve en eski satırın yaşı.

Alıcının queue'su yoksa mesaj kaybolabilir. ExternallyOwned() ile Ordering queue'yu oluşturmuyor. RabbitMQ'da default exchange'e, var olmayan bir queue adıyla gönderilen mesaj (mandatory bayrağı yoksa) sessizce atılır. Lokalde Inventory ve Payments queue'larını açılışta oluşturuyor; production'da queue'ları uygulamalardan önce altyapı kodu kurmalı.

Zaman ayarlarını birlikte düşün. Ödeme timeout'u (15 dakika) < rezervasyon TTL'i (20 dakika) < ödeme sağlayıcısının bir ödemeyi askıda tuttuğu süre. Biri değişince diğerlerine bak.

İlk açılışta Wolverine her service'in veritabanında kendi tablolarını kontrol edip oluşturuyor. Dört service aynı anda açılırken yüklü bir SQL Server'da bu işlem bir kez Execution Timeout Expired ile düştü; resource'ları yeniden başlatınca düzeldi. Production'da bu şema da migration'larla birlikte, deploy öncesinde kurulmalı.

gRPC kalmaya devam ediyor. ReserveStock RPC'si (Remote Procedure Call; uzak bir service'teki method'u çağırma) Inventory'de duruyor, ama checkout artık onu kullanmıyor. 09'da BFF (Backend for Frontend; Angular'a özel ara backend) kitap sayfası için GetStock'u çağıracak; gRPC'nin yeri anlık sorgu.

Sık Düşülen Tuzaklar

Compensation'ı geri alma sanmak. İade, iptal, ücret kesintisi: her compensation kendi başına bir iş akışı ve kendisi de başarısız olabilir.

Saga'nın içinde senkron çağrı zinciri kurmak. Saga adımında Inventory'yi gRPC ile çağırmak, 05'teki bağımlılığı geri getirir ve saga'nın en önemli kazancını, Inventory kapalıyken siparişi kabul edebilmeyi, ortadan kaldırır.

Geç gelen mesajı unutmak. Saga tamamlandıktan sonra gelen bir mesaj için NotFound yoksa Wolverine onu hata sayar ve mesaj retry'dan sonra DLQ'ya düşer. Hangi mesajın geç gelebileceğini ve geldiğinde ne yapılacağını adım adım düşünmek gerekiyor.

Saga kimliğini mesaja koymamak. Cevap mesajında sipariş id'si yoksa Wolverine onu hangi saga'ya teslim edeceğini yalnız header'dan bulabilir; header'ı korumayan bir ara katman (başka bir library, elle yazılmış bir consumer) zinciri koparır.

Timeout'u saga'dan ayrı bir zamanlayıcıyla kurmak. Ayrı bir background job saga state'ini görmeden siparişi iptal ederse, ödemesi az önce alınmış bir siparişi iptal edebilir. Timeout bir mesaj olarak saga'ya gelmeli ve saga hangi adımda olduğuna bakarak karar vermeli.

Küçük Bir Deneme

★☆☆ Payments'ı durdur ve 5.000 TRY'nin altında bir sipariş gönder. CheckoutSagas tablosunda satır hangi Step'te? Payments'ı 15 dakika dolmadan başlattığında ne oluyor?

★★☆ Kitap başına sınır 10 adet. Bir siparişte stok bir kitapta yetiyor, diğerinde yetmiyorsa ne oluyor? Inventory'yi "olanı ayır, eksiği bildir" mantığıyla değiştir ve saga'ya müşteriye kısmi onay soran bir adım ekle. Testlerini önce yaz.

★★★ Zamanlamayı bozan bir senaryo kur: Checkout:PaymentTimeout'u 5 saniye yap ve Payments'a 5 saniyelik bir gecikme ekle, böylece PaymentSucceeded ile CheckoutTimedOut aynı anda gelsin. Ordering'in log'unda saga'nın Version çakışmasını ve retry'ı bul. Sonuç her denemede aynı mı?

Checkout artık Inventory'nin kesintisinden etkilenmiyor ve her hata yolunun bir sonucu var. Ama Payments hâlâ bir if'ten ibaret. Bir sonraki yazıda gerçek bir ödeme akışının sorunlarını kuruyoruz: sahte bir ödeme sağlayıcısı, Idempotency-Key ile charge, zaman aşımından sonra bilinmeyen sonuç ve reconciliation job'ı, imzalı webhook callback'i ve dış modelin domain'e sızmaması.