Integration Event'leri ile Async İletişim: RabbitMQ, Messaging Library ve Transactional Outbox
Catalog ve Ordering, integration event'leri WolverineFx'in EF Core outbox'ı ile kendi veritabanlarına yazıp commit'ten sonra RabbitMQ'ya gönderiyor; RabbitMQ kapalıyken bile fiyat değişikliği 204 dönüyor ve event broker geri geldiğinde Ordering'e ulaşıyor. Ordering'in durable inbox'ı tekrar eden mesajı, alan başına versiyon da geç gelen eski event'i eliyor; poison message'lar service'in kendi dead letter queue'suna exception bilgisiyle düşüyor.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6, WolverineFx 6.64, RabbitMQ 4.3 ve Entity Framework Core 10 ile derlenip denenmiştir.
Önce Tahmin Et
RabbitMQ'yu durduruyoruz ve Catalog'da Emma'nın fiyatını değiştiriyoruz. PUT /api/v1/admin/books/{isbn}/price ne döner: 500 mü, 503 mü, 204 mü? 204 dönerse BookPriceChanged event'i nerede bekler ve RabbitMQ geri geldiğinde Ordering'e ulaşır mı? İkinci soru: Ordering'in kitap kopyasında tek bir Version kolonu olsaydı ve aynı kitabın BookRenamed v6 event'i, BookPriceChanged v7'den sonra işlenseydi kitabın adı ne olurdu? İki sorunun cevabı da aşağıdaki gerçek çalıştırmalarda.
Domain Event mi, Integration Event mi?
04'te Ordering'in Order.Place method'u bir OrderPlaced domain event'i üretiyordu; 03'te Catalog'un Book'u BookPriceChanged ve BookRenamed üretiyordu. İkisi de şimdiye kadar yalnız log'a yazılıyordu. Bu yazıda sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) service'leri ilk kez birbirine mesaj gönderiyor, bu yüzden önce iki event türünü ayırmak gerekiyor.
Domain event service'in içinde kalır: aggregate'in diliyle konuşur, OrderId gibi kendi tiplerini ve Money gibi value object'leri taşır, model değişince o da değişir. Integration event ise service'in dışarıya verdiği sözdür: başka bir ekibin kodu onu okur, bu yüzden yalnız primitive tipler taşır ve kolay değişmez. Ordering'de iki OrderPlaced bu yüzden var. Integration olanı Bookstore.Contracts projesinde:
namespace Bookstore.Contracts.Ordering;
// Ordering bir siparişi kabul ettiğinde yayınlanır. Consumer'ların ihtiyacı olanı taşır (Order aggregate'i,
// EF Core tipi yok); böylece domain model değişse de contract sabit kalabilir.
public sealed record OrderPlaced(
Guid OrderId, string CustomerEmail, IReadOnlyList<OrderPlacedLine> Lines, decimal Total, string Currency,
DateTimeOffset PlacedAtUtc);
public sealed record OrderPlacedLine(string Isbn, int Quantity, decimal UnitPrice);
Yanına bir de exchange adları geldi: her yayınlayan service'in bir fanout exchange'i var (bookstore.catalog, bookstore.ordering), consumer'lar kendi queue'larını ona bağlıyor. Contract'ların versioning kuralını Message Queue'larla Çalışmak serisinin Mesaj Contract'ları yazısında anlattık; burada uyguladığımız kısmı şu: alan eklemek serbest, alan silmek ya da adını değiştirmek yeni bir mesaj tipi demek. Wolverine mesajın tipini Bookstore.Contracts.Catalog.BookPriceChanged gibi tam adıyla bir header'a yazıyor, yani namespace ve record adı da contract'ın parçası. Bookstore.Contracts projesi hâlâ hiçbir pakete bağlı değil; 01'deki architecture test bunu korumaya devam ediyor.
Wolverine'i Kurmak: RabbitMQ, SQL Server Outbox ve EF Core
01'de neden MassTransit yerine WolverineFx kullandığımızı anlattık. Bu yazının yazıldığı gün son kararlı sürüm 6.64. Wolverine 6'da runtime'da kod üretmek için gereken Roslyn derleyicisi ayrı bir pakete taşındı: WolverineFx.RuntimeCompilation referans edilmezse uygulama açılışta durup bu paketi ya da önceden üretilmiş kodu istiyor. Catalog'un kurulumu:
builder.UseWolverine(opts =>
{
if (!openApiBuild)
{
// Outbox tabloları Catalog'un kendi veritabanında, "wolverine" şemasında
opts.PersistMessagesWithSqlServerUsingNamedConnection("catalogdb", "wolverine");
opts.UseRabbitMqUsingNamedConnection("rabbitmq").AutoProvision();
}
opts.UseEntityFrameworkCoreTransactions();
opts.PublishMessage<BookPriceChanged>().ToRabbitExchange(Exchanges.Catalog);
opts.PublishMessage<BookRenamed>().ToRabbitExchange(Exchanges.Catalog);
// Hiçbir mesaj önce outbox tablosuna yazılmadan service'ten çıkmıyor
opts.Policies.UseDurableOutboxOnAllSendingEndpoints();
});
"catalogdb" ve "rabbitmq", Aspire'ın WithReference ile verdiği connection string'lerin adları; AppHost'ta Catalog ve Ordering'e WithReference(rabbitmq) ve WaitFor(rabbitmq) ekledik. Wolverine ilk açılışta catalogdb'de wolverine şemasını ve wolverine_outgoing_envelopes, wolverine_incoming_envelopes, wolverine_dead_letters gibi dokuz tabloyu kendisi oluşturdu. Outbox service'in kendi veritabanında duruyor; başka bir service'in ya da ortak bir "mesaj veritabanının" connection string'ine ihtiyaç yok.
Bu bloğa gelene kadar iki hata aldık. Birincisi build sırasında geldi. 03'ten beri OpenAPI dokümanı her build'de üretiliyor ve bunun için host GetDocument.Insider adlı bir araçla kısa süreliğine çalıştırılıyor. O çalıştırmada Aspire yok, connection string de yok; Wolverine ise eksik connection string'i açılışta yakalıyor ve build şu mesajla kırıldı: 2 connection strings required by this Wolverine application are missing from configuration. openApiBuild değişkeni o çalıştırmayı tanıyor, Wolverine'in transport'larını ve message store'unu kapatıyor ve broker ile veritabanı satırlarını atlıyor. Wolverine dokümanı aynı yöntemi öneriyor. Commit'lenmiş OpenAPI dosyalarında hiçbir fark çıkmadı.
İkinci hata ilk fiyat değişikliğinde 500 olarak geldi:
[catalog] System.InvalidOperationException: The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support user-initiated
transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()' to execute all the operations in the transaction as a
retriable unit.
[catalog] at Wolverine.EntityFrameworkCore.DbContextOutbox`1.SaveChangesAndFlushMessagesAsync(CancellationToken token)
Aspire'ın AddSqlServerDbContext'i Entity Framework Core'un (EF Core) retry yapan execution strategy'sini varsayılan olarak açıyor. Bu strategy, kodun kendi açtığı transaction'a izin vermiyor; outbox ise entity'yi ve mesajı tek transaction'da yazmak için tam olarak bunu yapıyor. Ordering'de aynı sorun daha erken, açılışta çıktı: Wolverine, transaction middleware'ini bu strategy ile birlikte gördüğü handler için uygulamayı başlatmayı reddetti. Çözüm, retry'ı tek yerde toplamak:
// EF Core'un retry yapan execution strategy'si (Aspire'da varsayılan olarak açık) outbox'ın ihtiyaç duyduğu
// açık transaction'a izin vermiyor; service onu kapatıyor, retry'ı Wolverine'in error policy'lerine bırakıyor.
// Migrator onu koruyor: container yeni açıldığında ilk bağlantı denemeleri başarısız olabilir.
builder.AddSqlServerDbContext<CatalogDbContext>("catalogdb", settings => settings.DisableRetry = !DatabaseMigration.IsMigrationRun(args));
İlk denemede retry'ı her yerde kapattık ve bu sefer migrator düştü: SQL Server container'ı yeniden başladıktan hemen sonra ilk bağlantı error occurred during the pre-login handshake ile başarısız oldu ve tekrar denenmedi. Migrator'ın açık transaction kullanan bir outbox'ı olmadığı için retry'ı orada koruyoruz.

Catalog'da Transactional Outbox
03'teki ChangeBookAsync event'leri yalnız log'a yazıyordu ve yorumu sorunu adıyla söylüyordu: SaveChanges ile doğrudan publish arasında bir çökme event'i kaybettirir (dual write). Mekanizmayı Messaging ve Event-Driven Architecture serisinin Transactional Outbox ve Inbox yazısında, .NET'teki seçenekleri Message Queue'larla Çalışmak serisinin Transactional Outbox yazısında anlattık. Bookstore'da kod şu hâle geldi:
Book? book = await outbox.DbContext.Books.FirstOrDefaultAsync(b => b.Isbn == isbn, ct);
if (book is null)
{
return BookNotFound(isbn);
}
change(book);
foreach (object integrationEvent in book.Events)
{
// Henüz gönderilmedi: bellekte bekliyor, kitapla aynı transaction'da outbox tablosuna yazılacak
await outbox.PublishAsync(integrationEvent);
}
book.ClearEvents();
try
{
// Tek transaction: UPDATE Books + wolverine_outgoing_envelopes'a INSERT. Commit'ten sonra
// Wolverine mesajları RabbitMQ'ya gönderiyor ve outbox'tan siliyor.
await outbox.SaveChangesAndFlushMessagesAsync(ct);
}
outbox, endpoint'e enjekte edilen IDbContextOutbox<CatalogDbContext>. Minimal API endpoint'leri Wolverine handler'ı olmadığı için outbox'ı bu servis üzerinden kullanıyoruz; DbContext property'si aynı scope'taki context. Concurrency hatasında 409 dönmeye devam ediyoruz; o durumda bellekte bekleyen mesajlar hiç yazılmadan scope ile birlikte atılıyor.
Şimdi Önce Tahmin Et'teki ilk deney. aspire resource rabbitmq stop ile broker'ı durdurup fiyatı değiştirdik:
HTTP 204 in 0.063339s
message_type destination attempts owner_id
Bookstore.Contracts.Catalog.BookPriceChanged rabbitmq://exchange/bookstore.catalog 0 4
Cevap 204 ve 63 ms. Catalog'un log'u RabbitMQ'ya kanal açamadığını tekrar tekrar yazdı (Error trying to start a new Rabbit MQ channel for RabbitMqSender: rabbitmq://exchange/bookstore.catalog), ama event kaybolmadı: catalogdb'deki outbox tablosunda bekliyordu. RabbitMQ'yu 14:16:59'da başlattık, tablo 14:17:06'da boştu ve Ordering'in log'unda şu satır vardı:
[ordering] info: Bookstore.Contracts.Catalog.BookPriceChanged[0]
[ordering] Copy of 9780141439587 now costs 219.90 (v3)
İlk sorunun cevabı bu: broker kapalıyken yazma işlemi başarılı oluyor, event veritabanında bekliyor ve broker geldiğinde gidiyor. Bedeli de var: Catalog'un admin ekranı "kaydedildi" derken Ordering'in kopyası 40 saniye eski kaldı. Eventual consistency'nin somut hâli bu.
Ordering: Durable Inbox ve Kitap Kopyasını Güncellemek
Ordering, Catalog'un exchange'ine kendi queue'sunu bağlıyor. Queue'nun sahibi consumer olduğu için adı da onun: ordering.catalog-events.
opts.UseRabbitMqUsingNamedConnection("rabbitmq")
.AutoProvision()
// Bu queue'nun ve Catalog'un exchange'ine bağlanmasının sahibi Ordering
.DeclareExchange(Exchanges.Catalog, exchange => exchange.BindQueue(Queues.CatalogEvents));
// Durable inbox: mesaj RabbitMQ'ya onaylanmadan önce orderingdb'ye yazılıyor
opts.ListenToRabbitQueue(Queues.CatalogEvents).UseDurableInbox();
opts.UseEntityFrameworkCoreTransactions();
// OrderingDbContext alan bir handler'da SaveChanges ve outbox tek transaction'da
opts.Policies.AutoApplyTransactions();
Handler bir static class; Wolverine onu adından (Handler ile biten) ve Handle method'larından buluyor:
public static async Task Handle(
BookPriceChanged message, OrderingDbContext db, TimeProvider clock, ILogger logger, CancellationToken ct)
{
CatalogBook book = await FindAsync(db, message.Isbn, ct);
if (IsStale(book.Isbn, message.Version, book.PriceVersion, nameof(BookPriceChanged), logger))
{
return;
}
if (message.Currency != book.Currency)
{
throw new InvalidOperationException($"Price of {message.Isbn} came in {message.Currency}, the copy is in {book.Currency}.");
}
book.Price = message.NewPrice;
book.PriceVersion = message.Version;
book.UpdatedAtUtc = clock.GetUtcNow().UtcDateTime;
logger.LogInformation("Copy of {Isbn} now costs {Price} (v{Version})", book.Isbn, book.Price, message.Version);
// Burada SaveChanges yok: Wolverine'in transaction middleware'i onu inbox satırıyla birlikte commit ediyor
}
RabbitMQ en az bir kez teslim eder. Aynı mesaj iki kez gelebilir ve Wolverine'in durable listener'ı mesajları varsayılan olarak paralel işlediği için tek queue'da bile sıra garanti değil. IsStale, gelen versiyon kopyadakinden büyük değilse event'i log'layıp atlıyor. 03'te Catalog'un Version'ını her değişiklikte bir artırmıştık; bu kontrol o kararın karşılığı.
04'te kopyada tek bir Version kolonu vardı. Önce Tahmin Et'teki ikinci soru tam onu hedefliyor. Ordering'i durdurup Emma'nın adını (v6) ve fiyatını (v7) değiştirdik, iki mesajı Ordering'in queue'sundan alıp ters sırayla geri koyduk ve Ordering'i başlattık. Tek versiyonla fiyat v7 kopyayı v7'ye taşır, ardından gelen ad değişikliği v6 "eski" sayılıp atlanır; kitap eski adıyla kalırdı ve bunu fark eden kimse olmazdı. Fiyat ve başlık farklı alanlar olduğu için her birinin kendi versiyonu olmalı. Kopyaya PriceVersion ve TitleVersion kolonlarını bir migration ile ekledik (eski Version'ın değeri ikisine de kopyalandı). Aynı deney sonrasında:
[ordering] Copy of 9780141439587 renamed to Emma: A Novel (v6)
[ordering] Copy of 9780141439587 now costs 199.90 (v7)
Isbn Title Price PriceVersion TitleVersion
9780141439587 Emma: A Novel 199.90 7 6
Sonra iki tekrar senaryosu denedik. Önce fiyat v7 mesajını aynı message id ile yeniden yayınladık; Wolverine'in inbox'ı onu handler'a hiç ulaştırmadı. Sonra v5'lik eski bir fiyat event'ini yeni bir message id ile gönderdik; bu sefer inbox'ın tanıyacağı bir şey yoktu ve versiyon kontrolü devreye girdi:
[ordering] info: Wolverine.Runtime.WorkerQueues.DurableReceiver[0]
[ordering] Discarding a duplicate delivery of envelope dc270000-0000-0200-9244-08df425a07ac at rabbitmq://queue/ordering.catalog-events, which the inbox
already holds
[ordering] info: Bookstore.Contracts.Catalog.BookPriceChanged[0]
[ordering] Ignoring BookPriceChanged v5 for 9780141439587: copy is already at v7
İki katman iki farklı şeyi koruyor. Inbox aynı mesajın tekrarını yakalıyor: broker'ın yeniden teslimini ya da ack'i kaybolmuş bir mesajı. Versiyon kontrolü ise aynı bilginin farklı bir mesajla tekrar gelmesini: Catalog'un bir event'i yeniden yayınlamasını ya da eski bir mesajın geç gelmesini. Idempotent consumer'ın teorisi Messaging ve Event-Driven Architecture serisinin Idempotent Consumer yazısında, inbox'ın .NET uygulaması Message Queue'larla Çalışmak serisinin Idempotent Consumer ve Inbox yazısında.
OrderPlaced: Ordering'in Outbox'ı ve İlk Consumer
Ordering de kendi event'ini aynı yolla yayınlıyor. PlaceOrderHandler artık IDbContextOutbox<OrderingDbContext> alıyor ve domain event'i integration event'e çeviriyor:
db.Orders.Add(order);
// Domain event Ordering'in içinde kalıyor; service'ten çıkan, integration event
foreach (object domainEvent in order.DomainEvents)
{
if (domainEvent is Domain.OrderPlaced)
{
await outbox.PublishAsync(ToIntegrationEvent(order));
}
}
order.ClearDomainEvents();
// Orders/OrderItems INSERT'ü ve outbox satırı tek transaction'da, sonra mesaj RabbitMQ'ya
await outbox.SaveChangesAndFlushMessagesAsync(ct);
İlk consumer Notifications. Veritabanı olmadığı için inbox'ı da yok; şimdilik yalnız log yazıyor, e-posta ve SignalR 11'de geliyor. Ada'nın siparişinden sonra log'u şuydu:
[notifications] info: Bookstore.Contracts.Ordering.OrderPlaced[0]
[notifications] Order 01a1da6d-ba84-7382-8620-60e8b93ca01c placed by ada@example.com: 399.80 TRY, 1 line(s)
Sipariş hâlâ 05'teki gibi senkron gRPC rezervasyonuyla StockReserved durumunda oluşuyor. 07'de bu çağrı saga'nın bir adımına dönüşecek ve Inventory ile Payments da bu exchange'ten ya da Ordering'in göndereceği command'lardan beslenecek.
Retry, Dead Letter Queue ve Poison Message
Retry ve DLQ (Dead Letter Queue; işlenemeyen mesajların ayrıldığı queue) politikasının gerekçesi Message Queue'larla Çalışmak serisinin Retry ve Dead-Letter Queue yazılarında. Ordering'de iki kural var: geçici veritabanı hataları için bekleyerek üç retry, hiçbir zaman başarılı olamayacak bir hata için doğrudan DLQ.
// Geçici veritabanı hataları: giderek uzayan beklemelerle üç retry, sonra dead letter queue
opts.OnException<SqlException>().RetryWithCooldown(
TimeSpan.FromMilliseconds(200), TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5));
// Hiçbir zaman başarılı olamayacak bir mesaj doğrudan dead letter queue'ya; retry yalnız yük ekler
opts.OnException<InvalidOperationException>().MoveToErrorQueue();
İlk poison message denemesinde iki mesaj gönderdik: kopyada olmayan bir ISBN (kitabın uluslararası kimlik numarası) için fiyat event'i ve newPrice alanı bozuk bir JSON. İkisi de doğru şekilde DLQ'ya gitti, ama giden yer şaşırtıcıydı: wolverine-dead-letter-queue. Wolverine'in varsayılan DLQ'su her uygulamada aynı ada sahip; yani Ordering'in ve Notifications'ın poison message'ları aynı queue'da karışıyordu. Her listener'a kendi DLQ'sunu verdik:
opts.ListenToRabbitQueue(Queues.CatalogEvents)
.UseDurableInbox()
.DeadLetterQueueing(new DeadLetterQueue("ordering.dead-letter"));
Bu değişiklikten sonra Ordering açılmadı: PRECONDITION_FAILED - inequivalent arg 'x-dead-letter-exchange' for queue 'ordering.catalog-events' in vhost '/': received 'ordering.dead-letter'. RabbitMQ'da bir queue'nun argümanları oluşturulduktan sonra değişmez. Lokalde queue'yu silip yeniden oluşturmak yetti; production'da bu, yeni bir queue açıp eskisini boşaltmak ya da argümanı RabbitMQ policy'si ile vermek demek. Aynı deneyi tekrarladık ve ordering.dead-letter'daki mesajların header'larını management API'si ile okuduk (tip, exception-type, exception-message, original-destination, attempts):
Bookstore.Contracts.Catalog.BookPriceChanged | System.Text.Json.JsonException | The JSON value could not be converted to Bookstore.Contracts.Catalog.BookPriceChanged. Path: $.newPrice | LineNumber: 0 | BytePositionInLine: 48. | rabbitmq://queue/ordering.catalog-events |attempts None
Bookstore.Contracts.Catalog.BookPriceChanged | System.InvalidOperationException | Book 9780000000002 is not in Ordering's catalog copy. | rabbitmq://queue/ordering.catalog-events |attempts 1
Wolverine exception tipini, mesajını, stack trace'ini ve mesajın geldiği queue'yu header olarak ekliyor; aynı bilgi management UI'da queue'nun "Get messages" bölümünde de görülüyor, yani poison message'ın neden orada olduğunu anlamak için log aramak gerekmiyor. attempts 1, MoveToErrorQueue kuralının retry yapmadığını gösteriyor. Bozuk JSON ise handler'a hiç ulaşmadı: deserialization hatası doğrudan DLQ'ya gidiyor.
Üretimde Dikkat
Outbox tablosunu izle. wolverine_outgoing_envelopes'taki satır sayısı ve en eski satırın yaşı, broker'a ulaşılamadığını gösteren en erken sinyal. Yukarıdaki 40 saniyelik gecikme bir dakikaya, bir saate çıkıyorsa kullanıcı henüz bir şey fark etmeden alarm almak gerekiyor.
DLQ'ya alarm koy ve sahibini belirle. ordering.dead-letter'a düşen her mesaj Ordering ekibinin işi; DLQ'yu yeniden işleme süreci ve araçları Message Queue'larla Çalışmak serisinin Dead-Letter Queue yazısında.
Production'da kod üretimini açılışa bırakma. Wolverine handler'lar için C# kodu üretip derliyor; log'daki The Wolverine code generation mode is Dynamic satırı bunu söylüyor. Kodu build sırasında üretip TypeLoadMode.Static ile çalıştırmak açılışı hızlandırıyor ve Roslyn'i image'tan çıkarıyor; 14'te container image'larını hazırlarken buna döneceğiz.
Queue ve binding'leri kim oluşturuyor? AutoProvision() lokalde rahat. Production'da service hesabının exchange ve queue oluşturma yetkisi olmayabilir ve olmamalı da; o durumda topology'yi altyapı kodu kurar ve uygulama yalnız kullanır.
Bellek. AppHost'a Keycloak için WithExplicitStart() ekledik: 10. yazıya kadar onu kullanan yok ve SQL Server, RabbitMQ ve Redis'in yanında yaklaşık 500 MB yer kaplıyordu. Dashboard'dan ya da aspire resource keycloak start ile istenince başlıyor.
Sık Düşülen Tuzaklar
SaveChanges'tan sonra mesajı doğrudan broker'a göndermek. Çoğu zaman çalışır; process tam arada çökerse ya da broker o an kapalıysa event sessizce kaybolur. Outbox'ın tek işi bu boşluğu kapatmak.
Tek versiyonla farklı alanları korumak. Yukarıdaki deneyde gördüğümüz gibi, sıra bozulduğunda bir alanın güncellemesi diğerininkini "eski" saydırır.
Varsayılan DLQ'yu bütün service'lerde ortak bırakmak. Poison message'ın hangi service'ten geldiği ancak header okunarak anlaşılır ve alarmın sahibi belli olmaz.
Var olan bir queue'nun argümanını koddan değiştirmeye çalışmak. RabbitMQ PRECONDITION_FAILED ile reddeder ve consumer hiç açılmaz.
İki retry katmanını üst üste koymak. EF Core'un execution strategy'si ve Wolverine'in error policy'si aynı hatayı ayrı ayrı tekrar ederse, üç retry dokuza çıkar ve outbox'ın transaction'ı zaten çalışmaz.
Domain event'i olduğu gibi broker'a göndermek. Money ve OrderId tiplerini dışarı açmak, Ordering'in iç modelini consumer'ların contract'ı yapar.
Küçük Bir Deneme
★☆☆ Ordering'i aspire resource ordering stop ile durdur, Middlemarch'ın fiyatını üç kez değiştir ve RabbitMQ management UI'da ordering.catalog-events'te bekleyen mesajları say. Ordering'i başlattıktan sonra kopyada hangi fiyat ve hangi PriceVersion var?
★★☆ Catalog'a yeni bir kitap eklendiğinde Ordering'in kopyası bunu bilmiyor; DLQ'daki "is not in Ordering's catalog copy" mesajı tam da bu boşluk. Bir BookAdded integration event'i tasarla: Catalog'da hangi endpoint onu yayınlar, Ordering'de handler ne yapar ve BookPriceChanged BookAdded'dan önce gelirse ne olur?
★★★ ordering.dead-letter'daki mesajları sebebi düzeltildikten sonra ordering.catalog-events'e geri taşıyan küçük bir araç yaz (RabbitMQ management API'si ya da RabbitMQ.Client ile). Aynı message id'yi koruyunca inbox mesajı tekrar sayar mı, yeni bir id verince versiyon kontrolü ne yapar?
Service'ler artık birbirine kayıpsız mesaj gönderiyor, ama sipariş hâlâ 05'teki senkron gRPC çağrısına bağlı: Inventory kapalıyken sipariş alınamıyor ve ödeme hiç yok. Bir sonraki yazıda checkout'u Ordering'in sahibi olduğu bir saga'ya çeviriyoruz: Pending'den Confirmed'a giden durumlar, stok ve ödeme adımları, ödeme reddinde ve zaman aşımında compensation, ve bütün bunları test eden testler.