Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Modular Monolith — #05

Module'ler Arası Async İletişim: Integration Event'ler, In-Memory Bus ve Outbox

Mert Özen 7 Kas 2026 11 dk 4 görüntülenme
Module'ler Arası Async İletişim: Integration Event'ler, In-Memory Bus ve Outbox

Module'ler birbirine Contracts'taki integration event'lerle haber verir; bunun için kırk satırlık kütüphanesiz bir in-process bus yeter, ama event'i yalnız bellekte yayınlamak handler hatasında ya da process çöktüğünde onu kaybettirir. Bookstore'da in-memory publish'in gönderiyi kaybettiğini, aynı transaction'a yazılan minimal bir outbox ve module başına inbox ile stoğun yalnız bir kez ayrıldığını gerçek SQL Server çıktılarıyla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14, .NET 10, Entity Framework Core (EF Core) 10 ve SQL (Structured Query Language) Server 2025 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'da sipariş verildikten sonra üç module'ün bir şey yapması gerekiyor: Inventory stok ayıracak, Payments ödeme kaydını açıp sağlayıcıdan cevap bekleyecek, Shipping de gönderi kaydı oluşturacak. Ordering bunları tek tek çağırmak yerine bir OrderPlaced event'i yayınlıyor ve bellekteki küçük bir bus onu üç handler'a dağıtıyor.

Cuma akşamı yapılan deploy'da Shipping'in Shipping:DefaultCarrier ayarı configuration'a eklenmeyi unutulmuş. Bir müşteri iki kitaplık bir sipariş veriyor. Müşteri ekranında ne görür? Veritabanında hangi tablolarda kayıt oluşur, hangilerinde oluşmaz? Pazartesi sabahı biri ayarı ekleyip uygulamayı yeniden başlattığında Shipping o siparişin gönderisini oluşturur mu?

Domain Event ile Integration Event Aynı Şey Değil

Domain-Driven Design (DDD) serisinin "Domain Event'ler: Raise, Topla, SaveChanges'te Dispatch Et" yazısında domain event'leri bir aggregate'in içinde raise edip SaveChanges sırasında aynı module'ün handler'larına dağıtmıştık. O event'ler module'ün içinde yaşar: internal olabilirler, entity'lere referans verebilirler ve aynı transaction'ın parçası olarak işlenebilirler.

Integration event ise module sınırını geçen bir mesaj. Bu yüzden kuralları farklı: Contracts project'inde public bir record olarak durur, serialize edilebilir olmalıdır, yalnız primitive tipler ve kendi küçük record'larını taşır, kendi kimliği vardır ve bir kez yayınlandıktan sonra geriye dönük uyumlu kalmak zorundadır. Bir domain event'in sonucunda bir integration event yayınlamak sık görülen bir akış: Order.Place bir domain event raise eder, Ordering'in içindeki bir handler onu OrderPlaced integration event'ine çevirir. Message türleri, event notification ile event-carried state transfer arasındaki fark ve schema evolution Messaging ve Event-Driven Architecture serisinin konusu; burada module bağlamındaki minimal uygulamaya odaklanıyoruz.

Contracts'taki Event Record'u

Ordering'in yayınladığı olgu:

using Bookstore.BuildingBlocks.Messaging;

namespace Bookstore.Ordering.Contracts;

public sealed record OrderPlacedLine(Guid BookId, int Quantity);

// Ordering'in yayınladığı olgu: diğer module'lerin ihtiyacı olan her şey içinde
public sealed record OrderPlaced(
    Guid EventId, DateTime OccurredAt, Guid OrderId,
    IReadOnlyList<OrderPlacedLine> Lines, decimal Total, string Currency) : IIntegrationEvent;

Event, diğer module'lerin işini yapmak için ihtiyaç duyduğu her şeyi taşıyor: hangi kitaptan kaç adet, toplam tutar ve para birimi. Inventory bu event'i aldığında Ordering'e "siparişte ne vardı?" diye geri sormak zorunda kalmamalı; aksi hâlde async iletişimin üstüne bir de sync bağımlılık eklemiş oluruz.

Ortak interface'ler Bookstore.BuildingBlocks içinde:

namespace Bookstore.BuildingBlocks.Messaging;

// Module'ler arası yayınlanan olgu: geçmiş zaman, değişmez, kendi kimliğiyle
public interface IIntegrationEvent
{
    Guid EventId { get; }
    DateTime OccurredAt { get; }
}

public interface IIntegrationEventHandler<in TEvent> where TEvent : IIntegrationEvent
{
    Task HandleAsync(TEvent @event, CancellationToken ct);
}

public interface IEventBus
{
    Task PublishAsync(IIntegrationEvent @event, CancellationToken ct);
}

Bu yazıda BuildingBlocks'u ikiye böldük. Bookstore.BuildingBlocks hiçbir package'a bağımlı değil; Contracts project'leri yalnız onu görüyor. Outbox tablosu, EF Core konfigürasyonu ve background worker ise Bookstore.BuildingBlocks.Infrastructure'da; onu yalnız module'lerin Core project'leri kullanıyor. Böylece Ordering.Contracts'a referans veren bir module, EF Core'u ve SQL Server sürücüsünü transitive olarak almıyor.

Kütüphanesiz Bir In-Process Bus

.NET dünyasında bu işin hazır çözümleri var, ama bu yazının yazıldığı Kasım 2026'da lisans tablosu birkaç yıl öncesinden farklı. MediatR, Jimmy Bogard'ın Lucky Penny Software şirketine geçti; 2 Temmuz 2025'te çıkan 13.0'dan itibaren NuGet'teki sürümler RPL (Reciprocal Public License) 1.5 ya da ücretli ticari lisansla dağıtılıyor, Apache 2.0 lisanslı son sürüm Nisan 2025 tarihli 12.5.0. MassTransit'in v9 serisi Massient şirketi üzerinden ticari bir ürün; NuGet'teki güncel kararlı sürüm 9.2.5 ve sayfası paketin lisanslanması gerektiğini söylüyor. Apache 2.0 lisanslı v8 serisi hâlâ yama alıyor (son sürüm 21 Ekim 2026 tarihli 8.5.12). Wolverine ise MIT (Massachusetts Institute of Technology) lisanslı. Bu tablo değişebilir; bir kütüphane seçmeden önce lisans sayfasını o gün yeniden kontrol et.

Module'ler arası in-process dağıtım için bunların hiçbirine ihtiyacımız yok. Vertical Slice serisinin "Mediator'lı Slice: MediatR, Mediator (Source Generator) ve Wolverine Karşılaştırması" yazısı mediator kütüphanelerini ayrıntılı karşılaştırıyor; burada kırk satırlık kendi bus'ımızı yazıyoruz:

using Bookstore.BuildingBlocks.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Bookstore.BuildingBlocks.Infrastructure.Messaging;

// Kütüphanesiz bus: event'in tipine göre handler'ları bulur, her birini kendi scope'unda çalıştırır
internal sealed class InProcessEventBus(IServiceScopeFactory scopes, ILogger<InProcessEventBus> logger) : IEventBus
{
    public async Task PublishAsync(IIntegrationEvent @event, CancellationToken ct)
    {
        Type handlerType = typeof(IIntegrationEventHandler<>).MakeGenericType(@event.GetType());
        System.Reflection.MethodInfo handle = handlerType.GetMethod(nameof(IIntegrationEventHandler<>.HandleAsync))!;

        int count;
        using (IServiceScope probe = scopes.CreateScope())
            count = probe.ServiceProvider.GetServices(handlerType).Count();

        List<Exception> failures = [];
        for (int i = 0; i < count; i++)
        {
            // Her handler ayrı scope: ayrı DbContext, birinin hatası diğerini etkilemez
            using IServiceScope scope = scopes.CreateScope();
            object handler = scope.ServiceProvider.GetServices(handlerType).ElementAt(i)!;
            try
            {
                await (Task)handle.Invoke(handler, [@event, ct])!;
            }
            catch (Exception ex)
            {
                logger.LogError("{Handler} failed for {Event} {EventId}: {Message}",
                    handler.GetType().Name, @event.GetType().Name, @event.EventId, ex.Message);
                failures.Add(ex);
            }
        }

        if (failures.Count > 0)
            throw new AggregateException($"{failures.Count} handler(s) failed for {@event.GetType().Name}", failures);
    }
}

Bus, event'in runtime tipinden IIntegrationEventHandler<OrderPlaced> tipini kuruyor, DI (Dependency Injection) container'ından o tipe kayıtlı handler'ları buluyor ve her birini kendi scope'unda çalıştırıyor. Ayrı scope, her handler'ın kendi DbContext'iyle çalışması demek: Inventory'nin handler'ı Shipping'in yarım kalmış değişikliklerini görmez. Bir handler hata verirse diğerleri yine çalışıyor, sonunda hatalar bir AggregateException içinde çağırana dönüyor. nameof(IIntegrationEventHandler<>.HandleAsync) ifadesindeki bağlanmamış generic tip C# 14 ile gelen bir özellik. Yayınlayanın dinleyeni tanımadığı bu in-process yayının .NET'te bilinen bir örneği DiagnosticListener: 04. yazıda SQL komutlarını saydığımız CommandExecuted event'lerini EF Core bu yolla yayınlıyor ve onları kimin dinlediğini bilmiyor.

Handler'lar, DDD serisindeki adlandırmayı izliyor ve module'ün kayıt method'unda tanıtılıyor: Inventory ReserveStockWhenOrderPlaced, Shipping CreateShipmentWhenOrderPlaced, Payments RequestPaymentWhenOrderPlaced. Ordering bu handler'ların hiçbirini tanımıyor.

In-Memory Publish'in Sorunu

İlk akla gelen akış, siparişi kaydedip event'i hemen yayınlamak:

OrderPlaced placed = new(Guid.CreateVersion7(), now, order.Id,
    [.. order.Items.Select(i => new OrderPlacedLine(i.BookId, i.Quantity))], order.Total.Amount, order.Total.Currency);

db.Orders.Add(order);
if (options.UseOutbox)
{
    // Sipariş ve event aynı SaveChanges'te, yani aynı transaction'da
    db.OutboxMessages.Add(OutboxMessage.From(placed));
    await db.SaveChangesAsync(ct);
}
else
{
    await db.SaveChangesAsync(ct);          // 1) sipariş commit edildi
    await bus.PublishAsync(placed, ct);     // 2) event yalnız bellekte dağıtılıyor
}

Messaging:UseOutbox kapalıyken else dalı çalışıyor. Açılıştaki senaryoyu, yani Shipping ayarı eksik bir deploy'u oynattık. Müşterinin aldığı cevap:

HTTP 500

Log:

fail: Bookstore.BuildingBlocks.Infrastructure.Messaging.InProcessEventBus[0]
CreateShipmentWhenOrderPlaced failed for OrderPlaced 01a1b886-6850-778d-999b-8917303de0a1: Shipping:DefaultCarrier is not configured

Veritabanının durumu:

orders=1  reservations=2  shipments=0  payments=1  outbox pending=0

Sipariş commit edildi, stok ayrıldı, ödeme kaydı açıldı, gönderi yok. Müşteri ise 500 gördü ve büyük ihtimalle siparişi tekrar verecek; elimizde iki sipariş, iki rezervasyon ve iki ödeme kaydı olacak. Ayarı ekleyip uygulamayı yeniden başlattık:

orders=1  reservations=2  shipments=0  payments=1  outbox pending=0

Hiçbir şey değişmedi. Event yalnız process'in belleğinde yaşadı; handler başarısız olunca kayboldu ve onu tekrar deneyecek bir kayıt yok. Aynı şey handler hatası olmadan da olur: SaveChangesAsync ile PublishAsync arasındaki kısa anda process ölürse (deploy, bellek yetersizliği, container'ın yeniden başlatılması) sipariş veritabanında, event hiçbir yerde değildir. Messaging serisinin "Transactional Outbox ve Inbox: Dual Write Problemini Anlamak ve Doğru Tasarlamak" yazısı bunu dual write problemi olarak adlandırıyor: iki ayrı yere, iki ayrı hata penceresiyle yazmak.

Minimal Bir Outbox

Çözüm, event'i de veritabanına, siparişle aynı transaction'da yazmak. Yukarıdaki kodun if dalı tam olarak bunu yapıyor: OutboxMessage satırı siparişle aynı SaveChanges çağrısında ekleniyor, ya ikisi birden commit edilir ya hiçbiri.

// Event, iş verisiyle aynı transaction'da yazılan bir satır olarak saklanır
public sealed class OutboxMessage
{
    public Guid Id { get; init; }
    public required string Type { get; init; }
    public required string Payload { get; init; }
    public DateTime OccurredAt { get; init; }
    public DateTime? ProcessedAt { get; set; }
    public int Attempts { get; set; }
    public string? LastError { get; set; }

    public static OutboxMessage From(IIntegrationEvent @event) => new()
    {
        Id = @event.EventId,
        Type = @event.GetType().FullName!,
        Payload = JsonSerializer.Serialize(@event, @event.GetType()),
        OccurredAt = @event.OccurredAt
    };
}

Outbox tablosu Ordering'in kendi schema'sında, ordering.OutboxMessages. Her module'ün kendi outbox'ı ve kendi worker'ı var:

// Module başına bir worker: kendi outbox tablosunu okur, bus'a verir, işlendi diye işaretler
internal sealed class OutboxProcessor<TDbContext>(
    IServiceScopeFactory scopes, IEventBus bus, EventTypeMap types, TimeProvider clock,
    ILogger<OutboxProcessor<TDbContext>> logger, TimeSpan interval) : BackgroundService
    where TDbContext : DbContext
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using PeriodicTimer timer = new(interval, clock);
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            using IServiceScope scope = scopes.CreateScope();
            TDbContext db = scope.ServiceProvider.GetRequiredService<TDbContext>();
            List<OutboxMessage> batch = await db.Set<OutboxMessage>()
                .Where(m => m.ProcessedAt == null && m.Attempts < 5)
                .OrderBy(m => m.OccurredAt)
                .Take(20)
                .ToListAsync(stoppingToken);

            foreach (OutboxMessage message in batch)
            {
                try
                {
                    IIntegrationEvent @event = (IIntegrationEvent)JsonSerializer.Deserialize(message.Payload, types.Resolve(message.Type))!;
                    await bus.PublishAsync(@event, stoppingToken);
                    message.ProcessedAt = clock.GetUtcNow().UtcDateTime;
                }
                catch (Exception ex)
                {
                    message.Attempts++;
                    message.LastError = ex.Message;
                    logger.LogWarning("Outbox message {Id} failed (attempt {Attempts})", message.Id, message.Attempts);
                }
                await db.SaveChangesAsync(stoppingToken);
            }
        }
    }
public static IServiceCollection AddOrderingModule(this IServiceCollection services, string connectionString, bool useOutbox)
{
    services.AddDbContext<OrderingDbContext>(o => o.UseSqlServer(connectionString));
    services.AddSingleton(new OrderingOptions(useOutbox));
    services.AddScoped<PlaceOrderHandler>();
    services.AddIntegrationEvent<OrderPlaced>();
    if (useOutbox)
        services.AddOutboxProcessor<OrderingDbContext>(TimeSpan.FromSeconds(5));
    return services;
}

05-outbox-flow-tr.png

Worker beş saniyede bir işlenmemiş satırları okuyor, event'i bus'a veriyor ve başarılı olursa ProcessedAt'i dolduruyor. Bir handler hata verirse satır Attempts sayacını artırıp bir sonraki turda yeniden deneniyor; beş denemeden sonra kenara çekiliyor ve birinin bakması gerekiyor. Aynı deploy hatasını bu kez outbox açıkken oynattık:

{"orderId":"01a1b886-a30c-742f-9ecf-33c83a2717b9","lines":2,"total":30.00,"currency":"EUR"}
HTTP 201

Müşteri siparişini başarıyla verdi; Shipping'in sorunu artık onun sorunu değil. Birkaç saniye sonra:

warn: Bookstore.BuildingBlocks.Infrastructure.Messaging.OutboxProcessor[0]
Outbox message 01a1b886-a30d-7a57-9d35-b4eb174ad063 failed (attempt 1)
orders=1  reservations=2  shipments=0  payments=1  outbox pending=1
Type Attempts LastError
---- -------- ---------
Bookstore.Ordering.Contracts.OrderPlaced 2 1 handler(s) failed for OrderPlaced (Shipping:DefaultCarrier is not configured)

Ayarı ekleyip uygulamayı yeniden başlattık. Worker bekleyen satırı tekrar denedi:

orders=1  reservations=2  shipments=1  payments=1  outbox pending=0

Gönderi oluştu ve dikkat edilmesi gereken sayı reservations=2. Event bu sürede üç kez yayınlandı ve her seferinde Inventory'nin handler'ı da çağrıldı; ama stok bir kez ayrıldı. Bunun nedeni bir sonraki bölümde.

Bu outbox bilinçli olarak minimal. Birden fazla worker instance'ının aynı satırları alması, sıralama garantisi, tablonun temizlenmesi ve polling yerine transaction log tailing gibi konular Messaging serisinin outbox yazısında ayrıntılı ele alınıyor. Modular monolith'te tek bir uygulama instance'ı çalışıyorsa bu hâli çoğu zaman yeterli; birden fazla instance'a çıkacaksan önce o yazıyı oku.

Idempotent Handler: Inbox

Outbox at-least-once teslimat sağlar: event en az bir kez ulaşır, ama birden fazla da ulaşabilir. Bir handler'ın hata vermesi, aynı event'in o turda başarılı olan handler'lara da yeniden gitmesi demek. Handler'ların bunu fark etmesi gerekiyor:

// Handler adı DDD serisindeki kalıpla: <Tepki>When<Olgu>
internal sealed class ReserveStockWhenOrderPlaced(InventoryDbContext db, TimeProvider clock) : IIntegrationEventHandler<OrderPlaced>
{
    public async Task HandleAsync(OrderPlaced @event, CancellationToken ct)
    {
        if (await db.AlreadyProcessedAsync(@event.EventId, nameof(ReserveStockWhenOrderPlaced), ct))
            return;   // aynı event ikinci kez geldi: hiçbir şey yapma

        foreach (OrderPlacedLine line in @event.Lines)
        {
            StockItem item = await db.StockItems.SingleAsync(s => s.BookId == line.BookId, ct);
            item.Reserved += line.Quantity;
            db.Reservations.Add(new StockReservation(Guid.CreateVersion7(), @event.OrderId, line.BookId, line.Quantity));
        }
        db.MarkProcessed(@event.EventId, nameof(ReserveStockWhenOrderPlaced), clock.GetUtcNow().UtcDateTime);
        await db.SaveChangesAsync(ct);   // rezervasyon ve inbox kaydı tek transaction
    }
}
using Bookstore.BuildingBlocks.Messaging;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.BuildingBlocks.Infrastructure.Messaging;

public static class InboxExtensions
{
    // Aynı event bu handler'a daha önce geldiyse true
    public static Task<bool> AlreadyProcessedAsync(this DbContext db, Guid eventId, string handler, CancellationToken ct) =>
        db.Set<ProcessedMessage>().AnyAsync(m => m.MessageId == eventId && m.Handler == handler, ct);

    public static void MarkProcessed(this DbContext db, Guid eventId, string handler, DateTime now) =>
        db.Set<ProcessedMessage>().Add(new ProcessedMessage { MessageId = eventId, Handler = handler, ProcessedAt = now });
}

Her handler, işlediği event'in kimliğini kendi module'ünün ProcessedMessages tablosuna, iş değişikliğiyle aynı SaveChanges içinde yazıyor. Event ikinci kez geldiğinde kayıt orada olduğu için hiçbir şey yapmadan dönüyor. Senaryonun sonunda tablolar:

Schema Handler
------ -------
inventory ReserveStockWhenOrderPlaced
shipping CreateShipmentWhenOrderPlaced
payments RequestPaymentWhenOrderPlaced

Her module'ün inbox'ı kendi schema'sında, çünkü "bu event'i işledim mi" sorusu handler'ın kendi verisiyle aynı transaction'da cevaplanmalı. Ortak tek bir inbox tablosu, module'leri yine aynı tabloya yazdırır. Idempotency'nin başka yolları da var (doğal anahtar üzerinde unique index, "zaten ayrılmış mı" kontrolü); Messaging serisinin "Idempotent Consumer: Aynı Message İki Kez Gelirse Ne Olur?" yazısı bu seçenekleri karşılaştırıyor.

Process Çökerse

Son olarak açılıştaki ikinci risk: commit ile dağıtım arasında process'in ölmesi. Outbox açıkken bir sipariş verdik ve cevap gelir gelmez process'i kill -9 ile, worker'ın ilk turundan önce öldürdük:

HTTP 201
orders=1  reservations=0  shipments=0  payments=0  outbox pending=1

Sipariş ve outbox satırı var, başka hiçbir şey yok. Uygulamayı yeniden başlattık:

orders=1  reservations=2  shipments=1  payments=1  outbox pending=0

Event diskte beklediği için kaybolmadı. In-memory publish ile aynı çökme, sonsuza kadar eksik bir gönderi ve ödeme kaydı bırakırdı.

Sık Düşülen Tuzaklar

Event'i commit'ten önce yayınlamak

PublishAsync'i SaveChangesAsync'ten önce çağırmak ters yönde aynı hatayı üretir: handler'lar stok ayırır, sonra Ordering'in commit'i başarısız olur ve var olmayan bir sipariş için ayrılmış stok kalır.

Handler'ları idempotent yazmamak

Outbox'ı kurup handler'ları idempotent yazmamak, kayıp event yerine çift işlenen event üretir. Senaryodaki iki tekrar denemede inbox olmasaydı dört rezervasyon olurdu.

Event'e entity koymak ya da çok az veri koymak

Event'te Order entity'si taşımak Ordering'in iç modelini Contracts'a sızdırır. Tersine, yalnız OrderId taşımak her handler'ı Ordering'e geri sormaya zorlar. Event, tüketicilerin ihtiyaç duyduğu olguları kendi record'larıyla taşımalı.

Bütün module'ler için tek outbox ve tek worker

Tek bir OutboxMessages tablosu, her module'ü aynı tabloya yazdırır ve 06. yazıdaki schema izolasyonunu deler. Outbox yayınlayan module'ün verisidir.

In-process bus'ı sync çağrı yerine kullanmak

Event'ler "bir şey oldu" der, "şunu yap ve bana cevap ver" demez. Checkout'un stok sorusu hâlâ sync bir contract çağrısı; event'e çevirmek, cevabı beklemek için ikinci bir event ve bir bekleme mekanizması gerektirir.

Küçük Bir Deneme

★☆☆ Notification module'ünü ekle: OrderPlaced geldiğinde notifications.EmailMessages tablosuna bir kayıt atan idempotent bir handler. Ordering'de hiçbir satırın değişmediğini doğrula.

★★☆ Inventory'nin stok ayıramadığı durumda bir OutOfStock event'i yayınlamasını sağla (Inventory'nin kendi outbox'ıyla). Ordering bu event'i dinleyip siparişi iptal etsin. İki module arasında artık iki yönde event akıyor; bu bir döngüsel bağımlılık mı?

★★★ Uygulamayı iki instance olarak çalıştır ve aynı anda 50 sipariş ver. Aynı outbox satırını iki worker'ın birden aldığı durumu yakalayabiliyor musun? Satırı kilitlemek için SQL Server'da UPDLOCK, READPAST hint'leriyle bir sorgu yaz ve sonucu Messaging serisinin outbox yazısındaki önerilerle karşılaştır.

Bu yazıda module'lerin birbirine haber vermesini kalıcı hâle getirdik. Bir sonraki yazıda her module'ün verisini gerçekten kendisine ait kılıyoruz: module başına DbContext, HasDefaultSchema, module başına migrations history tablosu, schema seviyesinde yetkilendirme ve "raporlama için join lazım" sorusunun cevapları.