Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Messaging ve Event-Driven Architecture — #07

Transactional Outbox ve Inbox: Dual Write Problemini Anlamak ve Doğru Tasarlamak

Mert Özen 7 Kas 2026 16 dk 4 görüntülenme
Transactional Outbox ve Inbox: Dual Write Problemini Anlamak ve Doğru Tasarlamak

Veritabanına commit edip ayrıca broker'a publish etmek iki ayrı hata penceresi açar; çözüm, message'ı iş verisiyle aynı transaction'da bir outbox tablosuna yazıp ayrı bir relay ile yayınlamak ve relay'in ürettiği duplicate'leri consumer tarafında inbox ile elemektir. Dual write senaryoları, polling ve CDC relay karşılaştırması, PostgreSQL'de watermark ile message kaybı ve SKIP LOCKED ile çoklu relay gerçek çıktılarla.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; örnekler Entity Framework Core (EF Core) 10 ve SQLite, bir deneme de PostgreSQL 17 kullanıyor.

Önce Tahmin Et

Bookstore'un checkout'u siparişi kaydediyor, sonra Inventory stok ayırsın diye OrderPlaced integration event'ini broker'a gönderiyor. Kod, ilk akla gelen sırayla yazılmış:

namespace Bookstore.Ordering.Naive;

public sealed class PlaceOrderHandler(OrderingDbContext db, IMessagePublisher publisher, TimeProvider clock)
{
    public async Task<OrderId> HandleAsync(PlaceOrder command, CancellationToken ct)
    {
        Order order = Order.Place(command.CheckoutId, command.CustomerId, command.Lines, command.Currency);
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);

        MessageEnvelope envelope = MessageEnvelope.Create(
            OrderPlacedV1.From(order), OrderPlacedV1.TypeName, command.CheckoutId, clock);
        await publisher.PublishAsync(envelope, ct);
        return order.Id;
    }
}

İki soru. SaveChangesAsync başarılı olduktan sonra broker'a ulaşılamazsa ya da tam o anda deploy yüzünden process kapanırsa Inventory bu siparişten nasıl haberdar olur? Ekipten biri "o zaman önce publish edelim" derse hangi yeni hata kapıdan girer?

Dual Write: İki Kayıt, İki Ayrı Hata Penceresi

Handler aslında iki ayrı sisteme yazıyor: veritabanına ve broker'a. Literatürde buna dual write deniyor. İkisini tek bir atomik işlem yapan bir mekanizma yok, bu yüzden aralarında her zaman bir pencere kalıyor. Pencereyi görmek için broker'ı kapatıp handler'ı çalıştırdım, sonra sırayı ters çevirip aynı checkout'u iki kez gönderdim (kullanıcının çift tıklaması):

public sealed class PublishFirstHandler(OrderingDbContext db, IMessagePublisher publisher, TimeProvider clock)
{
    public async Task<OrderId> HandleAsync(PlaceOrder command, CancellationToken ct)
    {
        Order order = Order.Place(command.CheckoutId, command.CustomerId, command.Lines, command.Currency);
        MessageEnvelope envelope = MessageEnvelope.Create(
            OrderPlacedV1.From(order), OrderPlacedV1.TypeName, command.CheckoutId, clock);
        await publisher.PublishAsync(envelope, ct); // önce publish

        db.Orders.Add(order);
        await db.SaveChangesAsync(ct); // sonra commit
        return order.Id;
    }
}
1) Commit, then publish; broker is down
  handler failed: Broker unavailable
  orders in db: 1, messages on broker: 0
2) Publish, then commit; the same checkout is submitted twice
  click 2: SQLite Error 19: 'UNIQUE constraint failed: Orders.CheckoutId'.
  orders in db: 1, messages on broker: 2

Birinci senaryo commit-before-publish penceresi: sipariş veritabanında, event hiçbir yerde. Kullanıcı hata sayfası görüyor ama siparişi oluşmuş; stok ayrılmıyor, onay e-postası gitmiyor ve bunu kimse fark etmiyor, çünkü ortada bir exception log'u dışında iz yok. Retry da çözüm değil: handler'ı tekrar çalıştırmak yeni bir sipariş yaratır, publish'i tekrar denemek için ise process'in hâlâ ayakta olması gerekir. Çökme bu pencereye denk gelirse retry edecek kimse kalmaz.

İkinci senaryo publish-before-commit penceresi ve daha sinsi: CheckoutId üzerindeki unique index ikinci siparişi haklı olarak reddetti, ama event ondan önce yola çıkmıştı. Broker'da iki OrderPlaced var, veritabanında bir sipariş. Inventory iki kez stok ayıracak; ikinci event'in OrderId'si hiç var olmayan bir siparişi gösteriyor. Aynı pencere, validation hatası, deadlock ya da concurrency çakışması gibi commit'i düşüren her durumda açılır. Kısacası sıra değiştirmek sorunu çözmüyor, yalnızca hangi tarafın yalan söyleyeceğini değiştiriyor: ilkinde event eksik, ikincisinde event fazla.

Neden Two-Phase Commit Değil?

İki kaynağa atomik yazmanın klasik cevabı 2PC (Two-Phase Commit): bir koordinatör önce bütün katılımcılara "hazır mısın?" diye sorar, hepsi evet derse commit ettirir. .NET Framework'te bu TransactionScope ve MSDTC (Microsoft Distributed Transaction Coordinator) ile neredeyse görünmez çalışırdı. Bugün pratikte üç engel var. Birincisi, katılımcıların XA (X/Open'ın distributed transaction standardı) gibi bir protokolü desteklemesi gerekiyor; RabbitMQ'nun channel transaction'ları ve Kafka'nın transaction'ları yalnız kendi içlerinde geçerli, Azure Service Bus da Microsoft Learn'deki transaction sayfasına göre yalnız kendi entity'leri arasında transaction sunuyor ve message işleyen kodun çağırdığı veritabanlarının aynı scope'a otomatik katılmadığını açıkça yazıyor. İkincisi, .NET'te distributed transaction desteği .NET 7 ile geri geldi ama yalnız Windows'ta ve TransactionManager.ImplicitDistributedTransactions ile açıkça izin verilirse; Linux container'larında çalışan bir servis için bu kapı kapalı. Üçüncüsü ve en önemlisi, 2PC availability'yi çarpar: koordinatör ya da katılımcılardan biri yanıt vermezse ötekiler lock'larını tutarak bekler. Neden böyle olduğunu ilerideki Distributed Systems Temelleri serisinin "Distributed Transaction ve Two-Phase Commit" yazısında ayrıntılı ele alacağız. Buradaki sonuç yeterli: iki sisteme aynı anda yazamıyorsak tek bir sisteme yazmalıyız.

Outbox: Message'ı İş Verisiyle Aynı Transaction'a Yazmak

Chris Richardson'ın microservices.io'da Transactional Outbox adıyla tarif ettiği pattern tam olarak bunu yapıyor: gönderilecek message broker'a değil, iş verisinin bulunduğu veritabanındaki bir tabloya, aynı transaction içinde yazılır. Ayrı bir süreç (message relay) bu tabloyu okuyup broker'a taşır. Veritabanı artık tek doğruluk kaynağı; sipariş varsa message da vardır, sipariş yoksa message da yoktur.

07-outbox-flow-tr.png

public sealed class OutboxMessage
{
    private OutboxMessage() { } // EF Core

    public long Sequence { get; private set; }          // yazılma sırası (identity)
    public Guid MessageId { get; private set; }         // consumer'ın dedup key'i
    public string Type { get; private set; } = "";
    public Guid CorrelationId { get; private set; }
    public DateTimeOffset OccurredAt { get; private set; }
    public string Payload { get; private set; } = "";
    public DateTimeOffset? ProcessedAt { get; private set; }
    public int Attempts { get; private set; }

    public static OutboxMessage From(MessageEnvelope e) => new()
    {
        MessageId = e.MessageId, Type = e.Type, CorrelationId = e.CorrelationId, OccurredAt = e.OccurredAt, Payload = e.Payload
    };

    public MessageEnvelope ToEnvelope() => new(MessageId, Type, CorrelationId, OccurredAt, Payload);

    public void MarkProcessed(DateTimeOffset at) => ProcessedAt = at;

    public void RecordFailure() => Attempts++;
}

public sealed class PlaceOrderHandler(OrderingDbContext db, TimeProvider clock)
{
    public async Task<OrderId> HandleAsync(PlaceOrder command, CancellationToken ct)
    {
        Order order = Order.Place(command.CheckoutId, command.CustomerId, command.Lines, command.Currency);
        MessageEnvelope envelope = MessageEnvelope.Create(
            OrderPlacedV1.From(order), OrderPlacedV1.TypeName, command.CheckoutId, clock);

        db.Orders.Add(order);
        db.Outbox.Add(OutboxMessage.From(envelope));
        await db.SaveChangesAsync(ct); // sipariş ve message: tek transaction, ya ikisi ya hiçbiri
        return order.Id;
    }
}

Satır, serinin "Message Türleri" yazısında anlattığımız envelope'un serialize edilmiş hâli (örneği kısa tutmak için causation id'yi çıkardım): MessageId consumer'ın dedup key'i olacak, Type versiyonlu contract adı (bookstore.ordering.order-placed.v1), CorrelationId checkout'tan geliyor, Payload JSON (JavaScript Object Notation). Outbox tablosu sıradan bir tablo; EF Core onu siparişle aynı SQL (Structured Query Language) transaction'ında INSERT ediyor. Handler artık broker'ı tanımıyor bile; IMessagePublisher bağımlılığı kalktı. Aynı iki senaryoyu outbox'lı handler'la, broker kapalıyken çalıştırdım ve broker açılınca relay'i bir tur daha döndürdüm:

3) Outbox: same two scenarios
  click 2: SQLite Error 19: 'UNIQUE constraint failed: Orders.CheckoutId'.
  relay while broker is down: published 0
  relay after broker is back: published 1
  orders in db: 1, outbox rows: 1, attempts: 1, messages on broker: 1

Broker kapalıyken checkout yine de başarılı: sipariş ve outbox satırı birlikte yazıldı, kullanıcıya hata dönmedi. Çift tıklamadaki ikinci istek reddedildiğinde onun outbox satırı da yazılmadı; hayalet event yok. Relay ilk turda bir başarısız deneme kaydetti, broker dönünce message'ı yayınladı. İki pencere de kapandı, çünkü artık yalnızca bir yere, tek transaction'la yazıyoruz. Bedeli de görünüyor: event commit'ten hemen sonra değil, relay'in bir sonraki turunda yola çıkıyor. Bu gecikme, polling aralığı kadar eventual consistency demek.

Relay: Polling Publisher ve Transaction Log Tailing

Outbox'tan broker'a taşıma işinin iki yolu var. İlki polling publisher: belirli aralıklarla işlenmemiş satırları okuyan bir döngü. En basit hâli şu:

// Polling publisher: commit edilmiş ve henüz yayınlanmamış satırları sırayla yayınlar
public sealed class OutboxRelay(Func<OrderingDbContext> createDb, IMessagePublisher publisher, TimeProvider clock)
{
    public async Task<int> PublishPendingAsync(int batchSize, CancellationToken ct)
    {
        await using OrderingDbContext db = createDb();
        List<OutboxMessage> batch = await db.Outbox
            .Where(m => m.ProcessedAt == null)
            .OrderBy(m => m.Sequence)
            .Take(batchSize)
            .ToListAsync(ct);

        foreach (OutboxMessage message in batch)
        {
            try
            {
                await publisher.PublishAsync(message.ToEnvelope(), ct); // 1) broker onayladı
            }
            catch (BrokerUnavailableException)
            {
                message.RecordFailure();
                await db.SaveChangesAsync(ct);
                return 0; // sıra bozulmasın: sonrakileri deneme, bir sonraki turda baştan al
            }

            message.MarkProcessed(clock.GetUtcNow());
            await db.SaveChangesAsync(ct); // 2) işaretlendi; 1 ile 2 arasında çökersen tekrar yayınlanır
        }
        return batch.Count;
    }
}

Production'da bu method bir BackgroundService içinde PeriodicTimer ile döner, publish broker'ın onayını (publisher confirm, ack) bekler ve hata durumunda backoff uygular. Tamamı Backend › Message Queue'larla Çalışmak serisinin ".NET'te Transactional Outbox" yazısında; burada tasarım kararlarına odaklanıyoruz. İkinci yol transaction log tailing, yani CDC (Change Data Capture): relay tabloyu sorgulamak yerine veritabanının kendi transaction log'unu (PostgreSQL'de WAL (Write-Ahead Log), SQL Server'da CDC tabloları) okur. Bu işin bilinen aracı Debezium; 3.7 sürümünün Outbox Event Router SMT'si (Single Message Transform) varsayılan olarak id, aggregatetype, aggregateid, type ve payload kolonlarını bekliyor, topic'i aggregatetype'tan türetiyor ve aggregateid'yi Kafka message key'i yapıyor.

Polling publisherTransaction log tailing (CDC)
KurulumUygulamanın içinde bir döngüDebezium, Kafka Connect ve veritabanında log erişimi
GecikmePolling aralığı kadarLog'a yazılır yazılmaz, genelde daha düşük
Veritabanı yüküHer turda bir sorgu ve bir UPDATELog okuma; outbox tablosuna sorgu yok
SıraSorgunun ORDER BY'ına bağlıCommit sırası, log'dan geldiği gibi
İşlendi bilgisiSatırda (ProcessedAt)Relay'in log pozisyonunda (offset)
DuplicatePublish ile işaretleme arasında çökmedeOffset kaydedilmeden çökmede

Bookstore'un ölçeğinde (günde ~800 sipariş, kampanyada dakikada ~120) polling fazlasıyla yeterli. CDC, outbox tablosu üzerindeki sorgu yükü hissedilmeye başladığında ya da altyapıda zaten Kafka ve Debezium varsa anlam kazanır; ekibe getirdiği operasyon yükü, kazandırdığı birkaç yüz milisaniyeden çoğu zaman ağır basar.

Relay Duplicate Üretir: Inbox Neden Zorunlu

Tablodaki son satır önemli: iki relay türü de duplicate üretebilir. Polling relay broker'dan onayı aldıktan sonra, ProcessedAt'ı yazamadan çökerse yeniden başladığında aynı satırı tekrar yayınlar. Bunu yukarıdaki relay ile denedim; ilk turda publish'ten hemen sonra process çöküyor:

public sealed class ReserveStockWhenOrderPlaced(InventoryDbContext db, TimeProvider clock)
{
    private const string ConsumerName = "inventory.reserve-stock";

    public async Task<bool> HandleAsync(MessageEnvelope envelope, CancellationToken ct)
    {
        OrderPlacedV1 placed = JsonSerializer.Deserialize<OrderPlacedV1>(envelope.Payload)!;
        foreach (OrderedBookV1 book in placed.Books)
        {
            StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == book.BookId, ct);
            stock.Reserved += book.Quantity;
        }

        // İş değişikliği ve inbox kaydı aynı SaveChanges'ta: ya ikisi ya hiçbiri
        db.Inbox.Add(new InboxMessage(envelope.MessageId, ConsumerName, clock.GetUtcNow()));
        try
        {
            await db.SaveChangesAsync(ct);
            return true;
        }
        catch (DbUpdateException ex) when (ex.InnerException is SqliteException { SqliteErrorCode: 19 })
        {
            db.ChangeTracker.Clear(); // duplicate: hiçbir şey yazılmadı
            return false;
        }
    }
}
4) Relay crashes between publish and mark; consumer has an inbox
  relay #1: Process crashed (simulated)
  relay #2 (restart): published 1
  on broker: 2 messages, distinct ids: 1
  consumer: bookstore.ordering.order-placed.v1 applied
  consumer: bookstore.ordering.order-placed.v1 duplicate, skipped
  Dune: on hand 5, reserved 2

Broker'da iki message var ama MessageId'leri aynı, çünkü id outbox satırına commit anında yazıldı ve relay her seferinde aynısını kullanıyor. Bu, outbox'ın consumer'a verdiği en değerli şey: stabil bir dedup key. Inventory tarafındaki inbox tablosu (MessageId, Consumer) üzerinde primary key taşıyor; stok değişikliği ve inbox kaydı aynı transaction'da olduğu için ikinci teslimatta primary key ihlali bütün değişikliği geri çeviriyor ve Dune'un ayrılan stoğu 2'de kalıyor, 4'e çıkmıyor. Bu yüzden outbox tek başına bir pattern değil, bir çiftin yarısı: üretici tarafta "en az bir kez", tüketici tarafta "etkisi bir kez". Inbox'ın ayrı tablo mu entity üstünde mi olacağı, transaction'a katılamayan dış çağrılar ve dedup kayıtlarının saklama süresi serinin bir önceki yazısı "Idempotent Consumer: Aynı Message İki Kez Gelirse Ne Olur?"da anlatıldı; tam EF Core implementasyonu Backend serisindeki "Idempotent Consumer ve Inbox" yazısında.

Sıra, Watermark ve Birden Çok Relay

Relay'i hızlandırmak isteyen biri genelde iki fikirle gelir. İlki, ProcessedAt güncellemesinden kurtulmak için "en son yayınladığım sequence'ı hatırlayayım, sonra sequence > last diye sorayım" demek, yani bir high-water mark. Kulağa doğru geliyor, çünkü identity kolonu artıyor. Ama identity değeri INSERT anında verilir, satır commit anında görünür olur ve bu iki sıra aynı değildir. MVCC (Multi-Version Concurrency Control) kullanan PostgreSQL'de iki eşzamanlı checkout ile denedim:

await using NpgsqlConnection checkout1 = await db.OpenConnectionAsync();
await using NpgsqlTransaction tx1 = await checkout1.BeginTransactionAsync();
long first = await InsertAsync(checkout1, tx1, "OrderPlaced (Ada)");   // henüz commit yok
await using NpgsqlConnection checkout2 = await db.OpenConnectionAsync();
long second = await InsertAsync(checkout2, null, "OrderPlaced (Alan)"); // hemen commit
Console.WriteLine($"  Ada got sequence {first} (not committed), Alan got {second} (committed)");

long lastSeen = 0;
foreach ((long seq, string type) in await ReadAsync(db, $"sequence > {lastSeen}"))
{
    Console.WriteLine($"  watermark relay publishes #{seq} {type}");
    lastSeen = seq;
}
await db.CreateCommand($"UPDATE outbox SET processed_at = now() WHERE sequence = {second}").ExecuteNonQueryAsync();

await tx1.CommitAsync();
Console.WriteLine($"  Ada's transaction commits; watermark relay asks for sequence > {lastSeen}: " +
                  $"{(await ReadAsync(db, $"sequence > {lastSeen}")).Count} rows");
foreach ((long seq, string type) in await ReadAsync(db, "processed_at IS NULL"))
    Console.WriteLine($"  flag relay finds #{seq} {type}");
5) High-water mark vs processed flag (PostgreSQL)
  Ada got sequence 1 (not committed), Alan got 2 (committed)
  watermark relay publishes #2 OrderPlaced (Alan)
  Ada's transaction commits; watermark relay asks for sequence > 2: 0 rows
  flag relay finds #1 OrderPlaced (Ada)

Ada'nın siparişi 1 numarayı aldı ama commit'i Alan'ınkinden sonra geldi. Watermark relay 2'yi gördü, çıtayı 2'ye çekti ve 1 numaralı satır bir daha hiçbir sorguya girmedi: Ada'nın event'i sessizce kayboldu. Satır başına processed_at flag'iyle soran relay ise onu buldu. Hiçbir hata, hiçbir log yok; ancak yük altında, eşzamanlı transaction'larda ortaya çıkan türden bir bug. Kural: polling relay'de "nerede kaldım" bilgisini sayaçta değil satırda tut. Aynı sebeple OccurredAt gibi timestamp'lerle watermark yapmak daha da kötü; saat değeri de commit sırasını vermez.

İkinci fikir birden çok relay instance'ı. Uygulama üç pod'da çalışıyorsa üç relay aynı satırları okuyup üç kez yayınlar. Duplicate'i inbox zaten eler, ama boşa iş ve sıra karmaşası kalır. Satırları "talep etmenin" yolu PostgreSQL'de FOR UPDATE SKIP LOCKED (SQL Server'da UPDLOCK, READPAST hint'leri):

const string claim = """
    SELECT sequence FROM outbox
    WHERE processed_at IS NULL
    ORDER BY sequence
    LIMIT 3
    FOR UPDATE SKIP LOCKED
    """;
await using NpgsqlConnection relayA = await db.OpenConnectionAsync();
await using NpgsqlTransaction txA = await relayA.BeginTransactionAsync();
List<long> a = await ClaimAsync(relayA, txA, claim);
await using NpgsqlConnection relayB = await db.OpenConnectionAsync();
await using NpgsqlTransaction txB = await relayB.BeginTransactionAsync();
List<long> b = await ClaimAsync(relayB, txB, claim);
Console.WriteLine($"  relay A claimed [{string.Join(", ", a)}], relay B claimed [{string.Join(", ", b)}]");
6) Two relay instances with FOR UPDATE SKIP LOCKED
  relay A claimed [1, 2, 3], relay B claimed [4, 5, 6]

İş bölündü, ama bir bedelle: 3 ve 4 artık iki farklı relay'den, paralel yayınlanıyor. İkisi aynı siparişe aitse (OrderPlaced ve hemen ardından OrderCancelled) broker'a ters sırada varabilirler. Sıra gerekiyorsa iki seçenek var. Biri tek aktif relay: lease ya da leader election ile yalnız bir instance yayınlar, diğerleri bekler; basit ve Bookstore'un hacmi için yeterli. Diğeri aggregate'e göre bölmek: her relay yalnız hash(OrderId) % N dilimini işler, böylece aynı siparişin message'ları hep aynı relay'den ve sırayla çıkar. Hangisi seçilirse seçilsin, serinin "Message Ordering ve Partitioning" yazısındaki kural geçerli: consumer yine de sıraya güvenmemeli, version alanıyla eski message'ı tanıyabilmeli.

Tasarım Kararları: Dönüşüm, Satırın İçeriği, Temizlik ve Kütüphaneler

Domain event'i integration event'e nerede çevireceğiz? DDD (Domain-Driven Design) serisinin "Domain Event'ler" yazısında SaveChangesAsync içinde çalışan bir DomainEventsInterceptor kurmuş, AfterCommit handler'ın commit ile e-posta arasında çökmeye açık olduğunu ve bunun cevabının outbox olduğunu söylemiştik. Doğal yer orası: BeforeCommit aşamasında çalışan bir handler OrderPlaced domain event'ini OrderPlacedV1 contract'ına çevirip outbox'a ekler; interceptor aynı DbContext'te çalıştığı için satır siparişle aynı transaction'a girer. Dönüşüm bilinçli bir adım olmalı: domain event'in tamamını JSON'a döküp outbox'a atmak, iç modeldeki her yeniden adlandırmayı başka ekiplerin kırılan consumer'ına çevirir.

Outbox satırında ne olmalı? Message'ı yeniden üretmek için gereken her şey, yani envelope'un tamamı: MessageId, Type, CorrelationId, causation id, OccurredAt, gerekiyorsa W3C (World Wide Web Consortium) Trace Context'teki traceparent ve payload. Relay'in hiçbir şeyi hesaplamaması gerekir; satırı olduğu gibi taşır. Trace context'i neden satıra koyduğumuzu serinin son yazısında, observability başlığında göreceğiz.

Temizlik: işlenmiş satırlar sonsuza kadar tabloda kalırsa polling sorgusu yavaşlar. Yaygın çözüm, ProcessedAt üzerinde filtered index ve birkaç günden eski işlenmiş satırları silen zamanlanmış bir job. Saklama süresi, bir message'ı yeniden yayınlamak isteyebileceğin süreden kısa olmamalı; replay ihtiyacı yoksa birkaç gün yeter. CDC'de satırı INSERT'ten hemen sonra silmek bile mümkün, çünkü relay tabloyu değil log'u okuyor.

Bunu kendin mi yazarsın, kütüphane mi kullanırsın? .NET dünyasında outbox'ı hazır sunan kütüphanelerin lisans durumu Kasım 2026 itibarıyla şöyle:

  • MassTransit: 9.x sürümleri ticari lisanslı (Massient; NuGet paketinin lisans bağlantısı massient.com/license), 8.x Apache 2.0 kalıyor ve Massient'in sayfasına göre v8 desteği 31 Aralık 2026'da bitiyor.
  • Wolverine: açık kaynak (MIT lisansı); EF Core ve Marten ile outbox/inbox sunuyor.
  • NServiceBus: RPL (Reciprocal Public License) 1.5 ya da ticari lisans; kendi kodunu RPL ile açmayacaksan ticari lisans gerekiyor.
  • DotNetCore.CAP: açık kaynak (MIT lisansı); outbox'ı merkeze alan bir event bus.

Tablo ve relay toplamda birkaç yüz satır kod; kütüphanenin asıl değeri retry, backoff, temizlik, inbox ve observability'yi birlikte getirmesi. Seçimin ayrıntısı ve MassTransit/Wolverine outbox configuration'ı Backend serisinin konusu.

Sık Düşülen Tuzaklar

Outbox'ı başka bir transaction'a yazmak

Outbox satırını ayrı bir DbContext ya da ayrı bir connection ile, "aynı veritabanı nasılsa" diye yazmak pattern'i sessizce iptal eder. İki ayrı commit yine iki ayrı penceredir. Satır, iş verisiyle aynı SaveChangesAsync'te ya da açıkça paylaşılan aynı transaction'da yazılmalı.

"Hızlı yol" ile commit'ten hemen sonra publish etmek

Gecikmeyi azaltmak için handler'ın commit'ten sonra message'ı doğrudan da yayınlaması, relay'in de aynı satırı sonra yayınlaması yaygın bir optimizasyon. Duplicate'i inbox eler, ama doğrudan publish başarılı olunca satırı işaretlemeyi unutan bir kod, her message'ı iki kez yayınlar. Yapacaksan, publish başarılı olunca satırı aynı kod yolunda işaretle ve relay'i yalnız kaçanlar için bırak.

Sayaçla ya da saatle watermark

Yukarıdaki PostgreSQL denemesinde görüldü: identity ya da zaman damgasıyla "sonuncudan büyükler" diye sormak, commit sırası insert sırasından farklı olduğunda message kaybettirir. Hata yalnız eşzamanlı yük altında ortaya çıkar ve test ortamında neredeyse hiç görünmez.

Zehirli satırda takılan relay

Serialize edilemeyen ya da broker'ın sürekli reddettiği bir satır, sırayı korumak için bekleyen relay'i sonsuza kadar kilitler. Attempts sayacının bir eşiği olmalı; eşiği aşan satır ayrı bir "failed" durumuna alınıp alarm üretmeli, kalanlar devam etmeli. Sıranın önemli olduğu durumda yalnız o aggregate'in satırlarını bekletmek, bütün tabloyu durdurmaktan iyidir.

Relay'i izlememek

Outbox, broker kesintisini kullanıcıdan gizler; bu bir özellik, ama izlemezsen kesintiyi senden de gizler. En eski işlenmemiş satırın yaşı (outbox lag) bir alarm metriği olmalı.

Ne Zaman Kullanma

Message kaybolursa bir sonraki işlemin zaten düzelteceği durumlarda outbox gereksiz bir ağırlık: kısa ömürlü bir cache'i invalidate eden bir ipucu event'i ya da birkaç saniyede bir yeniden gönderilen bir durum bildirimi gibi. Event'in kendisinin doğruluk kaynağı olduğu sistemlerde de farklı bir yol var: event sourcing'de önce event store'a ya da log'a yazılır, kendi read model'in de dahil herkes oradan beslenir; dual write hiç oluşmaz. Bu yaklaşım ilerideki CQRS (Command Query Responsibility Segregation) ve Event Sourcing serisinin konusu. Son olarak, aynı process içindeki bir module'e haber vermek için broker gerekmiyorsa, aynı transaction'daki bir domain event handler'ı outbox'tan daha basittir.

Küçük Bir Deneme

★☆☆ Örnekteki OutboxRelay'e bir PeriodicTimer döngüsü ekle ve broker'ı 3 saniye kapalı tut. Checkout'lar başarılı olmaya devam ediyor mu, Attempts kaçta kalıyor, broker dönünce message'lar hangi sırayla yayınlanıyor?

★★☆ PostgreSQL denemesini relay'e uygula: PublishPendingAsync'i FOR UPDATE SKIP LOCKED ile satır talep eden bir sorguya çevir ve iki relay'i aynı anda çalıştır. Aynı siparişe ait iki message'ın (OrderPlaced, OrderCancelled) sırasının bozulduğu bir zamanlama bulabiliyor musun? hash(OrderId) % 2 ile bölünce ne değişiyor?

★★★ DDD serisindeki DomainEventsInterceptor'a BeforeCommit aşamasında çalışan bir WriteOutboxWhenOrderPlaced handler'ı ekle. Handler bir exception fırlatırsa sipariş ve outbox satırı ne oluyor? Domain event'te olup contract'a girmemesi gereken alanları listele.

Outbox ile inbox birlikte, tek bir servisin "yazdım ve haber verdim" sözünü güvenilir hâle getiriyor. Ama Bookstore'da bir sipariş tek servisle bitmiyor: stok ayrılıyor, ödeme alınıyor, kargo oluşturuluyor ve bunlardan biri başarısız olursa öncekilerin geri alınması gerekiyor. Bir sonraki yazıda bu birden çok adımlı iş akışını, saga pattern'ini ve choreography ile orchestration arasındaki seçimi ele alacağız.