Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Domain-Driven Design — #12

Domain Event'ler: Raise, Topla, SaveChanges'te Dispatch Et

Mert Özen 7 Kas 2026 16 dk 4 görüntülenme
Domain Event'ler: Raise, Topla, SaveChanges'te Dispatch Et

Aggregate olanı geçmiş zamanlı, immutable bir domain event olarak yalnızca kendi listesine ekler; EF Core 10'un SaveChangesInterceptor'ı event'leri SaveChanges sırasında toplar, aynı transaction'daki handler'ları commit'ten önce, veritabanı dışına çıkan yan etkileri commit'ten sonra çalıştırır. Mediator'sız dispatcher, iç içe SaveChanges ve açık transaction tuzakları gerçek çıktılarla; integration event ve outbox farkı.

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

Önce Tahmin Et

Bir önceki yazıda kargo kuralını Order'a ve bir domain service'e taşıdık. İptal tarafı ise hâlâ şöyle: müşteri siparişini iptal ettiğinde service stoğu geri bırakıyor, e-postayı gönderiyor, sonra kaydediyor. Ödemesi başarısız olan siparişleri gece iptal eden job da aynı Order.Cancel'ı çağırıyor.

using Bookstore.Application;
using Bookstore.Infrastructure;
using Bookstore.Inventory;
using Bookstore.Ordering;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Before;

public sealed class CancelOrderService(BookstoreDbContext db, IEmailSender email, TimeProvider clock)
{
    public async Task CancelAsync(OrderId id, CancellationToken ct)
    {
        Order order = await db.Orders.SingleAsync(o => o.Id == id, ct);
        order.Cancel(CancellationReason.CustomerRequest, clock);

        foreach (OrderItem item in order.Items)
        {
            StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == item.BookId, ct);
            stock.Release(item.Quantity);
        }

        await email.SendAsync("Your order was cancelled", ct);
        await db.SaveChangesAsync(ct);
    }
}

// Ödemesi başarısız olan siparişleri gece iptal eden job
public sealed class PaymentFailedJob(BookstoreDbContext db, TimeProvider clock)
{
    public async Task RunAsync(IReadOnlyList<OrderId> failed, CancellationToken ct)
    {
        foreach (OrderId id in failed)
        {
            Order order = await db.Orders.SingleAsync(o => o.Id == id, ct);
            order.Cancel(CancellationReason.PaymentFailed, clock);
        }
        await db.SaveChangesAsync(ct);
    }
}

İki soru. Gece job'u 40 siparişi iptal ettiğinde o siparişlerdeki kitapların stoğuna ne olur? Müşteri iptalinde e-posta gittikten sonra SaveChangesAsync bir concurrency hatası fırlatırsa müşteri neye inanır, veritabanı neyi gösterir?

Domain Event Nedir?

İlk sorunun cevabı kodu okuyunca görünüyor: job stoğu hiç bırakmıyor. "Sipariş iptal edilince ayrılan nüshalar geri bırakılır" kuralı Order'da değil, CancelOrderService'in gövdesinde yaşıyor; iptal eden her yeni giriş noktası onu hatırlamak zorunda ve job hatırlamamış. 40 siparişin kitapları, depoda dururken satılamaz görünmeye devam ediyor. İkinci sorunun cevabı da sıralamada: e-posta gönderildi, kayıt başarısız oldu. Müşterinin elinde "iptal edildi" yazan bir e-posta var, veritabanında sipariş hâlâ Placed.

İki hatanın ortak kökü, "bir şey oldu" bilgisinin modelde bir adının olmaması. Domain event bu adı verir: domain'de geçmişte olmuş, domain expert'in önemsediği bir olgu. Eric Evans bu building block'u kitabında (2003) anlatmamıştı; 2009'daki "kitaptan beri ne öğrendim" konuşmasında ekledi ve 2015'te yayımladığı DDD (Domain-Driven Design) Reference'ta Partnership ve Big Ball of Mud ile birlikte kitaptan sonra gelen üç pattern'den biri olarak yer aldı. Serinin 06. yazısındaki EventStorming duvarında turuncu sticky'ler zaten buydu: OrderPlaced, OrderCancelled, StockReserved.

Koda dökerken üç kural var. İsim geçmiş zaman: OrderCancelled bir olgu, CancelOrder bir komut. Event immutable: olmuş bir şey sonradan değişmez, bu yüzden sealed record ve yalnız init'li property'ler. İçerik yeterli ama küçük: id'ler, miktarlar, sebep ve zaman. Microsoft'un eShop örneği event'in içine Order entity'sinin kendisini koyuyor; aynı process'te pratik ama handler'a aggregate'in tamamını ve onu değiştirme imkânını veriyor. Burada yalnız id ve değer taşıyoruz.

Raise ve Topla: Aggregate'te Event Listesi

Event'i yayınlamanın ilk yolu, Udi Dahan'ın 2008'de önerdiği static DomainEvents.Raise(...) çağrısıydı: entity event'i fırlatır, handler'lar o anda çalışır. Jimmy Bogard 2014'teki "A better domain events pattern" yazısında bunun yerine ertelemeyi önerdi: entity event'i yalnızca kendi listesine ekler, dağıtım kaydetme anında yapılır. Bugün .NET dünyasında yaygın olan, Microsoft Learn'deki eShop rehberinin de anlattığı yaklaşım bu:

namespace Bookstore.BuildingBlocks;

// Domain event: geçmişte olmuş, domain expert'in önemsediği bir şey
public interface IDomainEvent
{
    DateTimeOffset OccurredAt { get; }
}

public abstract class AggregateRoot
{
    private readonly List<IDomainEvent> _domainEvents = [];

    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents;

    // Raise yalnızca listeye ekler; kimse çağrılmaz
    protected void Raise(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);

    public void ClearDomainEvents() => _domainEvents.Clear();
}
using Bookstore.BuildingBlocks;
using Bookstore.SharedKernel;

namespace Bookstore.Ordering;

public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.CreateVersion7());
}

public readonly record struct BookId(Guid Value);
public readonly record struct CustomerId(Guid Value);

public sealed class DomainException(string code, string message) : Exception(message)
{
    public string Code { get; } = code;
}

public enum OrderStatus { Placed, PaymentPending, Paid, Shipped, Delivered, Cancelled }
public enum CancellationReason { CustomerRequest, PaymentFailed, OutOfStock }

public sealed record OrderedBook(BookId BookId, int Quantity);

public sealed record OrderPlaced(
    OrderId OrderId, CustomerId CustomerId, IReadOnlyList<OrderedBook> Books, Money Total, DateTimeOffset OccurredAt)
    : IDomainEvent;

public sealed record OrderCancelled(
    OrderId OrderId, IReadOnlyList<OrderedBook> Books, CancellationReason Reason, DateTimeOffset OccurredAt)
    : IDomainEvent;

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

    public OrderItem(BookId bookId, string title, Money unitPrice, int quantity) =>
        (BookId, Title, UnitPrice, Quantity) = (bookId, title, unitPrice, quantity);

    public BookId BookId { get; private set; }
    public string Title { get; private set; } = "";
    public Money UnitPrice { get; private set; }
    public int Quantity { get; private set; }
}

public sealed class Order : AggregateRoot
{
    private readonly List<OrderItem> _items = [];

    private Order() { } // EF Core

    public OrderId Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public IReadOnlyList<OrderItem> Items => _items;
    public Money Total => _items.Aggregate(Money.Zero(_items[0].UnitPrice.Currency), (sum, i) => sum + i.UnitPrice * i.Quantity);

    public static Order Place(CustomerId customerId, IEnumerable<OrderItem> items, TimeProvider clock)
    {
        Order order = new() { Id = OrderId.New(), CustomerId = customerId, Status = OrderStatus.Placed };
        order._items.AddRange(items);
        if (order._items.Count == 0)
            throw new DomainException("order.empty", "An order needs at least one item.");

        order.Raise(new OrderPlaced(order.Id, customerId, order.Books(), order.Total, clock.GetUtcNow()));
        return order;
    }

    public void Cancel(CancellationReason reason, TimeProvider clock)
    {
        if (Status is OrderStatus.Cancelled)
            throw new DomainException("order.cancelled", $"Order {Id.Value} is already cancelled.");
        if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
            throw new DomainException("order.already-shipped", $"Order is {Status}; request a return instead.");

        Status = OrderStatus.Cancelled;
        Raise(new OrderCancelled(Id, Books(), reason, clock.GetUtcNow()));
    }

    private List<OrderedBook> Books() => [.. _items.Select(i => new OrderedBook(i.BookId, i.Quantity))];
}

AggregateRoot ve IDomainEvent bir domain kavramı değil, teknik bir building block. Serinin 05. yazısındaki context map'te Shared Kernel yalnızca Money'yi içeriyordu; bu iki tipi oraya değil, küçük bir Bookstore.BuildingBlocks namespace'ine koyduk. İçine iş kuralı girmediği sürece bir "Common.dll" çöplüğüne dönüşmez. Order.Cancel artık stoktan ya da e-postadan habersiz. Kendi state'ini değiştiriyor ve olanı kaydediyor; iptal edilmiş ya da kargoya verilmiş bir siparişte 08. yazıdaki gibi kodu olan bir DomainException fırlatıyor, böylece aynı olgu iki kez yayınlanamıyor. Event'in zamanı TimeProvider'dan geliyor, test'te sabitlenebilsin diye. Event üretmek için veritabanına gerek yok; aggregate'i test etmek, listeye bakmak kadar basit:

Order draft = Order.Place(ada, [new OrderItem(dune, "Dune", new Money(30m, "EUR"), 2)], clock);
Console.WriteLine($"Raised without a database: {string.Join(", ", draft.DomainEvents.Select(e => e.GetType().Name))}");
Raised without a database: OrderPlaced

Mediator'sız Bir Dispatcher

Listeyi handler'lara ulaştıracak bir şey lazım. eShop bunu MediatR ile yapıyor; burada iki interface ve bir abstract class yetiyor:

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

namespace Bookstore.Application;

public enum DispatchPhase { BeforeCommit, AfterCommit }

public interface IDomainEventHandler
{
    DispatchPhase Phase { get; }
    bool CanHandle(IDomainEvent domainEvent);
    Task HandleAsync(IDomainEvent domainEvent, CancellationToken ct);
}

// Generic taban: reflection yok, her handler kendi event tipini söylüyor
public abstract class DomainEventHandler<TEvent> : IDomainEventHandler where TEvent : IDomainEvent
{
    public virtual DispatchPhase Phase => DispatchPhase.BeforeCommit;

    public bool CanHandle(IDomainEvent domainEvent) => domainEvent is TEvent;

    Task IDomainEventHandler.HandleAsync(IDomainEvent domainEvent, CancellationToken ct) =>
        HandleAsync((TEvent)domainEvent, ct);

    protected abstract Task HandleAsync(TEvent domainEvent, CancellationToken ct);
}

public sealed class DomainEventDispatcher(IServiceProvider services)
{
    public async Task DispatchAsync(IReadOnlyList<IDomainEvent> events, DispatchPhase phase, CancellationToken ct)
    {
        IDomainEventHandler[] handlers = [.. services.GetServices<IDomainEventHandler>().Where(h => h.Phase == phase)];
        foreach (IDomainEvent domainEvent in events)
        {
            foreach (IDomainEventHandler handler in handlers.Where(h => h.CanHandle(domainEvent)))
                await handler.HandleAsync(domainEvent, ct);
        }
    }
}

Her handler DomainEventHandler<TEvent>'ten türüyor ve hangi aşamada çalışacağını Phase ile söylüyor. Dispatcher DI (dependency injection) container'dan bütün IDomainEventHandler'ları alıp event'in tipine uyanları sırayla çağırıyor; MakeGenericType ya da Invoke yok. Handler sayısı onlarla ölçülen bir uygulamada bu yeterince hızlı ve okunaklı; yüzlerce handler'da tip başına bir cache eklemek gerekir. MediatR kullanmak istersen aynı şekil INotification ve INotificationHandler<T> ile kurulur; Design Patterns serisinin "Mediator Pattern" yazısında bunun ne zaman gerçekten gerektiğini tartışmıştık.

SaveChanges'ten Önce: Interceptor ve Tek Transaction

Dağıtımı application service'te SaveChangesAsync'ten önce elle çağırmak mümkün, ama o zaman açılıştaki sorun geri gelir: biri çağırmayı unutur. Kaydetmenin tek kapısı olan SaveChangesAsync'in içine girmek daha güvenli. EF Core'un SaveChangesInterceptor'ı bunun için var; genel kullanımı EF Core serisinin "EF Core Interceptor'ları ve Event'leri: Audit Field'ları ve Yavaş Sorgu Logu" yazısında anlatıldı.

12-domain-event-flow-tr.png

using Bookstore.Application;
using Bookstore.BuildingBlocks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Bookstore.Infrastructure;

// State tuttuğu için DbContext başına bir instance (scoped)
public sealed class DomainEventsInterceptor(DomainEventDispatcher dispatcher) : SaveChangesInterceptor
{
    private const int MaxRounds = 5;
    private readonly List<IDomainEvent> _dispatched = [];

    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct = default)
    {
        DbContext context = eventData.Context!;
        _dispatched.Clear(); // önceki başarısız denemeden kalan event'ler "sonra"ya taşınmasın
        // Handler'lar yeni event üretebilir; liste boşalana kadar tur at
        for (int round = 1; ; round++)
        {
            List<IDomainEvent> events = CollectAndClear(context);
            if (events.Count == 0)
                return result;
            if (round > MaxRounds)
                throw new InvalidOperationException($"Domain events did not settle after {MaxRounds} rounds.");

            await dispatcher.DispatchAsync(events, DispatchPhase.BeforeCommit, ct);
            _dispatched.AddRange(events);
        }
    }

    public override async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData, int result, CancellationToken ct = default)
    {
        IDomainEvent[] committed = [.. _dispatched];
        _dispatched.Clear();
        await dispatcher.DispatchAsync(committed, DispatchPhase.AfterCommit, ct);
        return result;
    }

    public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result) =>
        throw new NotSupportedException("Use SaveChangesAsync; domain event handlers are async.");

    private static List<IDomainEvent> CollectAndClear(DbContext context)
    {
        AggregateRoot[] aggregates = [.. context.ChangeTracker.Entries<AggregateRoot>()
            .Select(e => e.Entity)
            .Where(a => a.DomainEvents.Count > 0)];

        List<IDomainEvent> events = [.. aggregates.SelectMany(a => a.DomainEvents)];
        foreach (AggregateRoot aggregate in aggregates)
            aggregate.ClearDomainEvents();
        return events;
    }
}

Önemli bir detay EF Core'un kaynak kodunda: DbContext.SaveChangesAsync önce interceptor'ların SavingChangesAsync'ini çağırıyor, ancak ondan sonra DetectChanges yapıp SQL'i (Structured Query Language) üretiyor. Yani handler'ların aynı DbContext üzerinde yaptığı değişiklikler o kayda dahil oluyor; ayrı bir SaveChanges'a gerek yok. Döngü de bu yüzden var: StockItem.TryReserve kendi StockReserved event'ini üretiyor ve ikinci turda o da dağıtılıyor. Beş turdan sonra durmak, birbirini tetikleyen iki handler'ın sonsuz döngüsüne karşı bir sigorta.

Interceptor state tutuyor (dağıtılmış event'lerin listesi), bu yüzden EF Core dokümantasyonundaki audit örneğinde olduğu gibi her DbContext instance'ına ayrı bir instance gerekiyor. Kayıt, scoped interceptor'ı aynı scope'tan alıyor; handler'lar da aynı scope'tan çözüldüğü için hepsi aynı DbContext'i görüyor:

ServiceCollection services = new();
services.AddSingleton(TimeProvider.System);
services.AddSingleton<ConsoleEmailSender>();
services.AddSingleton<IEmailSender>(sp => sp.GetRequiredService<ConsoleEmailSender>());
services.AddScoped<DomainEventDispatcher>();
services.AddScoped<DomainEventsInterceptor>();
services.AddScoped<IDomainEventHandler, ReserveStockWhenOrderPlaced>();
services.AddScoped<IDomainEventHandler, ReleaseStockWhenOrderCancelled>();
services.AddScoped<IDomainEventHandler, SendConfirmationWhenOrderPlaced>();
services.AddDbContext<BookstoreDbContext>((sp, options) => options
    .UseSqlite(connection)
    .AddInterceptors(sp.GetRequiredService<DomainEventsInterceptor>())
    .LogTo(m => Console.WriteLine($"  [db] {m}"),
        [RelationalEventId.TransactionCommitted, RelationalEventId.TransactionRolledBack],
        LogLevel.Debug, DbContextLoggerOptions.None));

Stok ayıran handler ile commit sonrası e-posta gönderen handler. StockItem.TryReserve, 08. yazıdaki karar gereği stok yetmediğinde exception değil false döndürüyor; o false'u kaydı durduracak bir DomainException'a çeviren, application layer'daki handler:

using Bookstore.Infrastructure;
using Bookstore.Inventory;
using Bookstore.Ordering;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Application;

// Aynı transaction: Order ile birlikte StockItem'lar da kaydedilir ya da hiçbiri
public sealed class ReserveStockWhenOrderPlaced(BookstoreDbContext db, TimeProvider clock)
    : DomainEventHandler<OrderPlaced>
{
    protected override async Task HandleAsync(OrderPlaced e, CancellationToken ct)
    {
        foreach (OrderedBook book in e.Books)
        {
            StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == book.BookId, ct);
            if (!stock.TryReserve(e.OrderId, book.Quantity, clock))
                throw new DomainException("order.out-of-stock", $"Only {stock.Available} of '{stock.Title}' available.");
            Console.WriteLine($"  [before commit] reserved {book.Quantity} x {stock.Title} ({stock.Available} left)");
        }
    }
}

public sealed class ReleaseStockWhenOrderCancelled(BookstoreDbContext db) : DomainEventHandler<OrderCancelled>
{
    protected override async Task HandleAsync(OrderCancelled e, CancellationToken ct)
    {
        foreach (OrderedBook book in e.Books)
        {
            StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == book.BookId, ct);
            stock.Release(book.Quantity);
            Console.WriteLine($"  [before commit] released {book.Quantity} x {stock.Title} ({stock.Available} left)");
        }
    }
}

// Commit'ten sonra: veritabanının dışına çıkan yan etki
public sealed class SendConfirmationWhenOrderPlaced(IEmailSender email) : DomainEventHandler<OrderPlaced>
{
    public override DispatchPhase Phase => DispatchPhase.AfterCommit;

    protected override Task HandleAsync(OrderPlaced e, CancellationToken ct) =>
        email.SendAsync($"Order confirmed, total {e.Total}", ct);
}

public interface IEmailSender
{
    Task SendAsync(string subject, CancellationToken ct);
}

public sealed class ConsoleEmailSender : IEmailSender
{
    public int Sent { get; private set; }

    public Task SendAsync(string subject, CancellationToken ct)
    {
        Sent++;
        Console.WriteLine($"  [after commit] email: {subject}");
        return Task.CompletedTask;
    }
}

Bu kısa örnekte handler'lar BookstoreDbContext'i doğrudan kullanıyor; Clean Architecture düzeninde aynı işi bir repository ya da Application'daki bir interface yapar (Clean, Onion ve Hexagonal Architecture serisinin 05. yazısı). Üç senaryo: Ada iki Dune sipariş ediyor; sonra stokta tek kalan Emma'dan iki tane istiyor; sonra ilk siparişini iptal ediyor.

Console.WriteLine("1) Place 2 x Dune");
await InScope(async db =>
{
    Order order = Order.Place(ada, [new OrderItem(dune, "Dune", new Money(30m, "EUR"), 2)], clock);
    db.Orders.Add(order);
    await db.SaveChangesAsync();
    placedId = order.Id;
});
Console.WriteLine("2) Place 1 x Dune + 2 x Emma");
await InScope(async db =>
{
    Order order = Order.Place(ada,
        [new OrderItem(dune, "Dune", new Money(30m, "EUR"), 1), new OrderItem(emma, "Emma", new Money(20m, "EUR"), 2)], clock);
    db.Orders.Add(order);
    try
    {
        await db.SaveChangesAsync();
    }
    catch (DomainException ex)
    {
        Console.WriteLine($"  SaveChanges failed: {ex.Message}");
    }
});
await InScope(async db =>
{
    StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == dune);
    Console.WriteLine($"  Orders: {await db.Orders.CountAsync()}, Dune available: {stock.Available}");
});
Console.WriteLine("3) Cancel the first order");
await InScope(async db =>
{
    Order order = await db.Orders.SingleAsync(o => o.Id == placedId);
    order.Cancel(CancellationReason.CustomerRequest, clock);
    await db.SaveChangesAsync();
});
1) Place 2 x Dune
  [before commit] reserved 2 x Dune (1 left)
  [db] Committed transaction.
  [after commit] email: Order confirmed, total 60.00 EUR
2) Place 1 x Dune + 2 x Emma
  [before commit] reserved 1 x Dune (0 left)
  SaveChanges failed: Only 1 of 'Emma' available.
  Orders: 1, Dune available: 1
3) Cancel the first order
  [before commit] released 2 x Dune (3 left)
  [db] Committed transaction.

Birinci senaryoda sıra figürdeki gibi: stok ayrıldı, transaction commit oldu, e-posta ondan sonra gitti. İkincide Dune için ayırma yapıldı, Emma'da TryReserve false döndü, handler exception fırlattı ve hiçbir şey yazılmadı: sipariş sayısı 1'de kaldı, Dune'un müsait stoğu da 1. Bellekte ayrılmış görünen Dune nüshası veritabanına hiç ulaşmadı. Üçüncüde iptal, stoğu servisler hatırlamadan geri bıraktı; gece job'u da aynı Cancel'ı çağırdığı için açılıştaki hata kendiliğinden kapandı. İptal için e-posta yok, çünkü OrderCancelled'ı dinleyen bir AfterCommit handler kaydetmedik; eklemek tek bir registration satırı.

SaveChanges'ten Sonra: Eventual Consistency

Stok ayırmayı siparişle aynı transaction'a koymak bir tercih ve bedeli var. Vaughn Vernon'ın Effective Aggregate Design (2011) makalelerindeki kurala göre bir transaction tek bir aggregate'i değiştirmeli, aggregate'ler arası kurallar eventual consistency ile sağlanmalı; serinin 09. yazısında aggregate sınırını bu kurala göre çizmiştik. Burada Order ve StockItem aynı transaction'da değişiyor. Bogard'ın yazısındaki pragmatik görüş ise, aynı komutun yan etkileri aynı veritabanındaysa tek transaction'ın kabul edilebilir olduğu. Microsoft'un eShop rehberi de bu yolu "en basit" diye seçiyor. Bookstore bugün tek veritabanlı bir monolith ve günde 800 sipariş alıyor; kampanya zirvesinde aynı kitaba gelen eşzamanlı siparişler StockItem satırında çakışmaya başlarsa, stok ayırmayı commit sonrasına taşıyıp OutOfStock durumunda siparişi compensating action olarak bir iptalle kapatmak gerekecek. Karar teknik değil, domain expert'le konuşulacak bir karar: "stok yoksa sipariş hiç oluşmasın" mı, "oluşsun, sonra iptal edilsin" mi?

Veritabanının dışına çıkan her yan etki ise commit'ten sonra çalışmalı. E-postayı SavingChangesAsync'te gönderirsen kayıt başarısız olduğunda geri alamazsın; açılıştaki ikinci hata bu. Ama "commit'ten sonra" ifadesinin bir dipnotu var. SavedChangesAsync, SaveChangesAsync bitince çalışır; çağıran kod kendi transaction'ını açmışsa bu henüz commit değildir:

Console.WriteLine("4) Explicit transaction, then rollback");
await InScope(async db =>
{
    await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
    db.Orders.Add(Order.Place(ada, [new OrderItem(emma, "Emma", new Money(20m, "EUR"), 1)], clock));
    await db.SaveChangesAsync();
    await tx.RollbackAsync();
    Console.WriteLine($"  Orders after rollback: {await db.Orders.CountAsync()}");
});
4) Explicit transaction, then rollback
  [before commit] reserved 1 x Emma (0 left)
  [after commit] email: Order confirmed, total 20.00 EUR
  [db] Rolled back transaction.
  Orders after rollback: 1
Emails sent: 2

E-posta gitti, transaction geri alındı, sipariş yok. Interceptor'ın "commit'ten sonra" varsayımı, transaction'ı SaveChangesAsync'in kendisinin açtığı durumda doğru. Uygulamada açık transaction kullanan yerler varsa, AfterCommit dağıtımını transaction'ı yöneten yere (application service ya da bir decorator) taşımak gerekir.

Doğru zamanda çalışsa bile AfterCommit handler'ın bir boşluğu kalıyor: commit ile e-posta arasında process çökerse e-posta hiç gitmez, çünkü event yalnızca bellekteydi. Bunu kapatmanın yolu, gönderilecek mesajı iş verisiyle aynı transaction'da bir tabloya yazıp ayrı bir süreçle yayınlamak, yani outbox pattern. Outbox ve güvenilir yayın ilerideki Messaging ve Event-Driven Architecture serisinin konusu; buradaki interceptor o serideki outbox'ın doğal giriş noktası olacak.

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

İkisi de "bir şey oldu" der, ama farklı okuyuculara:

Domain eventIntegration event
OkuyucuAynı bounded context, aynı processBaşka bounded context'ler, başka servisler
ZamanlamaSenkron ya da asenkron, genellikle aynı istek içindeHer zaman asenkron
Ne zaman yayınlanırCommit'ten önce de olabilirYalnız commit'ten sonra
ŞemaModelle birlikte serbestçe değişirContract; sürümlenir
TaşımaBellek içi dispatcherMessage broker, outbox

Serinin 05. yazısındaki context map'te Ordering, OrderPlaced'i Inventory'ye Published Language olarak yayınlıyordu. O yayın bir integration event'tir: Ordering'in iç modelindeki OrderPlaced record'u değil, ondan türetilmiş, sürümlenmiş, Inventory ekibiyle üzerinde anlaşılmış bir contract. Tipik akış, bir AfterCommit handler'ın (ya da daha güvenlisi, outbox'a yazan bir BeforeCommit handler'ın) domain event'i integration event'e çevirmesi. Domain event'i olduğu gibi dışarıya açmak, iç modeldeki her yeniden adlandırmayı diğer ekiplerin kırılan build'ine çevirir.

Sık Düşülen Tuzaklar

Handler'ın kendi SaveChanges'ını çağırması

Handler'ı yazan kişi "değişikliği kaydedeyim" diye tek satır ekler:

foreach (OrderedBook book in e.Books)
{
    StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == book.BookId, ct);
    if (!stock.TryReserve(e.OrderId, book.Quantity, clock))
        throw new DomainException("order.out-of-stock", $"Only {stock.Available} of '{stock.Title}' available.");
    await db.SaveChangesAsync(ct); // Tuzak: handler kendi SaveChanges'ını çağırıyor
2) Place 1 x Dune + 2 x Emma
  [db] Committed transaction.
  [before commit] reserved 1 x Dune (0 left)
  SaveChanges failed: Only 1 of 'Emma' available.
  Orders: 2, Dune available: 0

Stok yetmediği için sipariş reddedildi, ama veritabanında iki sipariş ve sıfır Dune var. İçteki SaveChangesAsync, dışarıdaki kaydın ortasında henüz yarım olan siparişi ve Dune ayırmasını kendi transaction'ında commit etti; Emma'daki hata bunu geri alamadı. Tek transaction vaadi tek bir satırla bozuldu. Kural: BeforeCommit handler'ları yalnızca değiştirir, kaydetmez.

SaveChangesFailed'a güvenmek

Handler'dan fırlayan exception SaveChangesFailedAsync'i tetiklemez; denedim, çağrılmadı. EF Core kaynak kodunda SavingChanges aşaması hata yakalayan bloğun dışında kalıyor. DbUpdateConcurrencyException da dokümantasyona göre bu method'u değil ThrowingConcurrencyException'ı çağırıyor. Interceptor bu yüzden temizliği hata yolunda değil, her SavingChangesAsync'in başında yapıyor.

Event'i entity'den hemen dağıtmak

Static DomainEvents.Raise ile handler'lar entity method'unun ortasında çalışır: aggregate'in invariant'ları henüz tamamlanmamışken başka aggregate'ler değişir, unit test'te entity'yi çağırmak e-posta gönderir. Ertelemeli dağıtımda entity yalnızca listeye ekler; ne zaman ve hangi transaction'da dağıtılacağına infrastructure karar verir.

Komut kılığında event

SendCancellationEmail ya da ReleaseStock adında bir event, aslında bir komuttur: tek bir alıcısı ve beklenen bir sonucu vardır. Event'in adı olanı anlatır (OrderCancelled); kimin, nasıl tepki vereceğini handler'lar belirler. Event adını domain expert'in cümlesinde geçebilecek bir olgu olarak sına.

DbContext pooling ve senkron SaveChanges

AddDbContextPool kullanıldığında options bir kez kurulur ve havuzdaki bütün instance'lar onu paylaşır; DbContext başına state tutan bu interceptor o kayıtla uyuşmaz. Senkron SaveChanges da async handler'ları çağıramaz; interceptor bu yüzden senkron yolu NotSupportedException ile kapatıyor, EF Core dokümantasyonunun audit örneğinde önerdiği gibi.

Ne Zaman Kullanma

Tek bir aggregate'in içinde kalan kural için event gereksiz bir dolambaç: "son satır çıkarılamaz" Order'ın kendi method'unda kalmalı. Bir olgunun tek bir tepkisi varsa ve o tepki hep aynı use case'te gerekiyorsa, application service'te doğrudan çağırmak daha okunaklı olabilir; event'in değeri, tepki sayısı açık uçlu olduğunda ve olguyu farklı giriş noktalarından (checkout, job, admin ekranı) üreten kod tepkileri bilmemeliyse ortaya çıkar. Akışı takip etmek de zorlaşır: order.Cancel()'ı okuyan biri stoğun nerede bırakıldığını görmek için handler'ları aramak zorunda. Bu yüzden handler adları olgu ve tepkiyi birlikte söylemeli: ReleaseStockWhenOrderCancelled.

Küçük Bir Deneme

★☆☆ OrderCancelled için bir AfterCommit handler ekle ve müşteri iptalinde e-postanın yalnızca commit'ten sonra gittiğini çıktıda gör. Açılıştaki CancelOrderService'ten hangi satırlar silinebilir?

★★☆ StockReserved'ı dinleyen ve aynı kitabın müsait stoğu 0'a düşünce bir OutOfStock event'i üreten bir handler yaz. Interceptor kaç tur dönüyor? Bunu bir sayaçla ölç.

★★★ Stok ayırmayı commit sonrasına taşı: ReserveStockWhenOrderPlaced AfterCommit'te yeni bir scope ve yeni bir DbContext ile çalışsın, stok yetmezse siparişi CancellationReason.OutOfStock ile iptal etsin. İkinci senaryonun çıktısı nasıl değişiyor ve müşteriye ne zaman, hangi e-posta gidiyor?

Elimizde artık tactical DDD'nin bütün parçaları var: entity, value object, aggregate, domain service ve domain event. Bir sonraki yazıda hepsini bir arada kullanacağız: Bookstore'un Ordering context'ini public setter'lı bir CRUD (Create, Read, Update, Delete) kodundan, her adımda test'leri yeşil tutarak zengin bir modele taşıyacağız.