Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi CQRS ve Event Sourcing — #05

Decider Pattern ile Event-Sourced Aggregate: decide, evolve ve Given-When-Then Test'leri

Mert Özen 7 Kas 2026 14 dk 3 görüntülenme
Decider Pattern ile Event-Sourced Aggregate: decide, evolve ve Given-When-Then Test'leri

Decider pattern, event-sourced bir modelin karar mantığını iki saf fonksiyona ayırır: Decide command ve state'ten yeni event'leri, Evolve state ve bir event'ten yeni state'i üretir. Stok defteri için C# 14 switch expression'larıyla bir decider, zamanı TimeProvider ile command'a taşımak, veritabanısız Given-When-Then test'leri ve OOP aggregate stiliyle karşılaştırma, gerçek test çıktılarıyla.

Sürüm notu: Bu yazıdaki kodlar C# 14, .NET 10 ve xUnit v3 ile derlenip denenmiştir.

Önce Tahmin Et

Önceki yazıda stok defterini event sourcing ile kurduk ve rezervasyon kararını handler'ın içine bir if olarak koyduk. Şimdi yeni bir kural geldi: ödemesi 30 dakika içinde tamamlanmayan siparişin rezervasyonu düşsün, nüshalar tekrar satışa açılsın. Bir ekip arkadaşın kuralı şöyle yazmış ve test'ini de eklemiş:

namespace Bookstore.Inventory;

public static class NaiveExpiry
{
    // Yanlış: saat fonksiyonun içinden okunuyor; sonuç, test'in ne zaman çalıştığına bağlı
    public static IReadOnlyList<StockEvent> ReleaseExpired(StockItemId id, StockState state) =>
        [.. state.Reservations
            .Where(kv => kv.Value.ReservedAt + StockDecider.ReservationTimeout <= DateTimeOffset.UtcNow)
            .Select(kv => new ReservationReleased(id, kv.Key, kv.Value.Quantity, ReleaseReason.Expired, DateTimeOffset.UtcNow))];
}
namespace Bookstore.Inventory.Tests;

public sealed class NaiveExpiryTests
{
    [Fact]
    public void A_ten_minute_old_reservation_does_not_expire()
    {
        StockItemId dune = new(Guid.CreateVersion7());
        DateTimeOffset reservedAt = new(2026, 11, 2, 10, 0, 0, TimeSpan.Zero);
        StockState state = new StockEvent[]
        {
            new StockItemRegistered(dune, new BookId(Guid.CreateVersion7()), reservedAt),
            new StockReceived(dune, 5, reservedAt),
            new StockReserved(dune, new OrderId(Guid.CreateVersion7()), 1, reservedAt)
        }.Aggregate(StockDecider.InitialState, StockDecider.Evolve);

        // "10 dakika sonra" demek istiyoruz ama fonksiyon gerçek saati okuyor
        Assert.Empty(NaiveExpiry.ReleaseExpired(dune, state));
    }
}

Test, yazıldığı gün (2 Kasım sabahı) yeşildi. Bugün CI'da (continuous integration) çalışınca ne olur? Bu fonksiyonu, gerçek saati değiştirmeden ve bir veritabanı kurmadan, "29. dakikada düşmez, 30. dakikada düşer" diye nasıl test ederdin?

Decider Pattern: Beş Parçalık Bir Sözleşme

Önce cevap. Bugün test kırmızı:

failed Bookstore.Inventory.Tests.NaiveExpiryTests.A_ten_minute_old_reservation_does_not_expire (11ms)
  Assert.Empty() Failure: Collection was not empty
  Collection: [ReservationReleased { StockItemId = StockItemId { Value = 01a1b84b-7320-753b-adfb-97101acc51b0 }, At = 11/7/2026 11:15:14 PM +00:00, OrderId = OrderId { Value = 01a1b84b-7320-7317-9e82-7fa6ce599e85 }, Quantity = 1, Reason = Expired }]
Test run summary: Failed!
  total: 8
  failed: 1
  succeeded: 7

Fonksiyon "şimdi"yi kendi içinden okuyor; test 2 Kasım 10:05'te çalışınca rezervasyon 5 dakikalıktı, bugün çalışınca 5 günlük. Kod değişmedi, sonuç değişti. Sorunun kökü, karar veren kodun dünyaya (saate) doğrudan erişmesi.

Jérémie Chassaing 2021'de yazdığı "Functional Event Sourcing Decider" yazısında event-sourced bir modelin karar mantığını beş parça ile tarif eder: command, event ve state tipleri; başlangıç state'i (initialState); command ile state'ten yeni event'leri üreten decide; state ile bir event'ten yeni state'i üreten evolve; ve state'in artık hiç değişmeyeceğini söyleyen isTerminal. decide ve evolve saf fonksiyonlardır: aynı girdiye her zaman aynı çıktıyı verirler, I/O yapmazlar, Chassaing'in vurguladığı gibi log bile yazmazlar. Saat, rastgelelik, veritabanı; hepsi dışarıda kalır ve gerekiyorsa girdinin bir parçası olarak içeri gelir. Bu yapıya decider pattern denir.

Bookstore'un stok defteri için tipler şunlar. Event'ler önceki yazıdakilerin aynısı, artı stok kalemini kapatan StockItemDiscontinued:

// Command'lar: niyet; zaman bilgisi dışarıdan gelir
public abstract record StockCommand(StockItemId StockItemId);
public sealed record RegisterStockItem(StockItemId StockItemId, BookId BookId, DateTimeOffset At) : StockCommand(StockItemId);
public sealed record ReceiveStock(StockItemId StockItemId, int Quantity, DateTimeOffset At) : StockCommand(StockItemId);
public sealed record ReserveStock(StockItemId StockItemId, OrderId OrderId, int Quantity, DateTimeOffset At) : StockCommand(StockItemId);
public sealed record ReleaseReservation(StockItemId StockItemId, OrderId OrderId, DateTimeOffset At) : StockCommand(StockItemId);
public sealed record ReleaseExpiredReservations(StockItemId StockItemId, DateTimeOffset Now) : StockCommand(StockItemId);
public sealed record ShipReservedStock(StockItemId StockItemId, OrderId OrderId, DateTimeOffset At) : StockCommand(StockItemId);
public sealed record AdjustStock(StockItemId StockItemId, int Delta, AdjustmentReason Reason, DateTimeOffset At) : StockCommand(StockItemId);
public sealed record DiscontinueStockItem(StockItemId StockItemId, DateTimeOffset At) : StockCommand(StockItemId);
// State: yalnızca karar vermek için gereken bilgi
public sealed record Reservation(int Quantity, DateTimeOffset ReservedAt);

public sealed record StockState(StockStatus Status, BookId BookId, int OnHand, ImmutableDictionary<OrderId, Reservation> Reservations)
{
    public int Reserved => Reservations.Values.Sum(r => r.Quantity);
    public int Available => OnHand - Reserved;
}

İki ayrıntı önemli. Command'lar zamanı kendileri taşıyor (At, Now): "şimdi"yi okuyan, command'ı oluşturan uygulama katmanı. State ise yalnızca karar vermek için gereken bilgiyi tutuyor: kitabın adı ya da depo rafı yok, çünkü hiçbir kural onlara bakmıyor.

decide: Command ve State'ten Event'ler

Karar mantığının tamamı tek bir switch expression:

using System.Collections.Immutable;

namespace Bookstore.Inventory;

public static class StockDecider
{
    public static readonly TimeSpan ReservationTimeout = TimeSpan.FromMinutes(30);

    public static StockState InitialState { get; } =
        new(StockStatus.NotRegistered, default, 0, ImmutableDictionary<OrderId, Reservation>.Empty);

    // decide: command + state -> yeni event'ler. Saf: I/O yok, saat yok, rastgelelik yok
    public static IReadOnlyList<StockEvent> Decide(StockCommand command, StockState state) => (command, state.Status) switch
    {
        (RegisterStockItem c, StockStatus.NotRegistered) => [new StockItemRegistered(c.StockItemId, c.BookId, c.At)],
        (RegisterStockItem, _) => throw new DomainException("stock.already-registered", "Stock item is already registered."),
        (_, StockStatus.NotRegistered) => throw new DomainException("stock.not-registered", "Stock item is not registered."),
        (_, StockStatus.Discontinued) => throw new DomainException("stock.discontinued", "Stock item is discontinued."),

        (ReceiveStock { Quantity: <= 0 }, _) => throw new DomainException("stock.invalid-quantity", "Quantity must be positive."),
        (ReceiveStock c, _) => [new StockReceived(c.StockItemId, c.Quantity, c.At)],

        (ReserveStock c, _) when state.Reservations.ContainsKey(c.OrderId) => [], // aynı sipariş: idempotent
        (ReserveStock c, _) when c.Quantity <= state.Available => [new StockReserved(c.StockItemId, c.OrderId, c.Quantity, c.At)],
        (ReserveStock c, _) => [new OutOfStock(c.StockItemId, c.OrderId, c.Quantity, state.Available, c.At)],

        (ReleaseReservation c, _) => state.Reservations.TryGetValue(c.OrderId, out Reservation? r)
            ? [new ReservationReleased(c.StockItemId, c.OrderId, r.Quantity, ReleaseReason.OrderCancelled, c.At)]
            : [],

        (ReleaseExpiredReservations c, _) => [.. state.Reservations
            .Where(kv => kv.Value.ReservedAt + ReservationTimeout <= c.Now)
            .OrderBy(kv => kv.Value.ReservedAt)
            .Select(kv => new ReservationReleased(c.StockItemId, kv.Key, kv.Value.Quantity, ReleaseReason.Expired, c.Now))],

        (ShipReservedStock c, _) => state.Reservations.TryGetValue(c.OrderId, out Reservation? r)
            ? [new ReservedStockShipped(c.StockItemId, c.OrderId, r.Quantity, c.At)]
            : throw new DomainException("stock.not-reserved", $"Order {c.OrderId.Value} has no reservation."),

        (AdjustStock c, _) when state.OnHand + c.Delta < state.Reserved =>
            throw new DomainException("stock.below-reserved", $"OnHand would drop below the {state.Reserved} reserved copies."),
        (AdjustStock c, _) => [new StockAdjusted(c.StockItemId, c.Delta, c.Reason, c.At)],

        (DiscontinueStockItem c, _) when state is { OnHand: 0, Reserved: 0 } => [new StockItemDiscontinued(c.StockItemId, c.At)],
        (DiscontinueStockItem, _) => throw new DomainException("stock.not-empty", "Only an empty stock item can be discontinued."),

        _ => throw new InvalidOperationException($"Unknown command {command.GetType().Name}.")
    };

Tuple pattern'i (command, state.Status) önce yaşam döngüsünü eliyor: kaydı olmayan ya da kapatılmış bir stok kalemine kayıt dışında hiçbir command uygulanamıyor. Sonra her command kendi kuralını söylüyor. Sonucun üç biçimi var: bir ya da daha fazla event (süresi dolan her rezervasyon ayrı bir ReservationReleased), boş liste (aynı siparişin ikinci rezervasyon isteği gibi, "yapılacak bir şey yok") ve DomainException. Ayrım DDD (Domain-Driven Design) serisindeki kuralla aynı: önceden bilinemeyen ama olağan bir sonuç (stok yetmedi) bir olgudur ve event olarak kaydedilir; kural ihlali (rezervasyonu olmayan siparişi kargoya vermek) çağıranın hatasıdır ve exception'dır.

Sondaki _ => throw kolu, StockCommand hiyerarşisinin derleyici açısından kapalı olmamasından geliyor; C# 15 ile gelecek union'lar bu tür hiyerarşileri exhaustive switch ile kapatmayı vaat ediyor.

evolve, initialState ve isTerminal

    // evolve: state + event -> yeni state. Saf ve her zaman başarılı: olmuş şey reddedilmez
    public static StockState Evolve(StockState state, StockEvent e) => e switch
    {
        StockItemRegistered r => state with { Status = StockStatus.Active, BookId = r.BookId },
        StockReceived r => state with { OnHand = state.OnHand + r.Quantity },
        StockReserved r => state with { Reservations = state.Reservations.Add(r.OrderId, new Reservation(r.Quantity, r.At)) },
        ReservationReleased r => state with { Reservations = state.Reservations.Remove(r.OrderId) },
        ReservedStockShipped s => state with { OnHand = state.OnHand - s.Quantity, Reservations = state.Reservations.Remove(s.OrderId) },
        StockAdjusted a => state with { OnHand = state.OnHand + a.Delta },
        StockItemDiscontinued => state with { Status = StockStatus.Discontinued },
        _ => state // OutOfStock: kayıt için var, state'i değiştirmez
    };

    public static bool IsTerminal(StockState state) => state.Status == StockStatus.Discontinued;
}

Evolve hiçbir zaman exception fırlatmıyor ve hiçbir şeyi doğrulamıyor. Bu bilinçli: event olmuş bir şey; doğrulama, event üretilmeden önce Decide'da yapıldı. Evolve bir event'i reddederse, o event'i içeren stream bir daha okunamaz. IsTerminal ise bir kapatılmış stok kaleminin bellekten atılabileceğini ya da arşivlenebileceğini söylüyor; serinin sekizinci yazısındaki "closing the books" konusunun temeli.

Decider'ı Dünyaya Bağlamak

Saf fonksiyonlar tek başına bir şey kaydetmez. Onları event store'a bağlayan ince bir katman lazım ve bu katman her decider için aynı: stream'i oku, Evolve ile fold et, Decide'ı çağır, okuduğun sürümle append et.

namespace Bookstore.Inventory;

// Decider'ı dünyaya bağlayan ince katman: oku, fold et, karar ver, append et
public sealed class StockCommandHandler(InMemoryEventStore store)
{
    public IReadOnlyList<StockEvent> Handle(StockCommand command)
    {
        IReadOnlyList<StockEvent> history = store.ReadStream(command.StockItemId.Value);
        StockState state = history.Aggregate(StockDecider.InitialState, StockDecider.Evolve);
        IReadOnlyList<StockEvent> events = StockDecider.Decide(command, state);
        if (events.Count > 0)
            store.Append(command.StockItemId.Value, expectedVersion: history.Count, events);
        return events;
    }
}

public sealed class ReservationApi(StockCommandHandler handler, TimeProvider clock)
{
    // Zamanı decider değil, uygulama katmanı okur ve command'a koyar
    public IReadOnlyList<StockEvent> Reserve(StockItemId id, OrderId orderId, int quantity) =>
        handler.Handle(new ReserveStock(id, orderId, quantity, clock.GetUtcNow()));

    public IReadOnlyList<StockEvent> ReleaseExpired(StockItemId id) =>
        handler.Handle(new ReleaseExpiredReservations(id, clock.GetUtcNow()));
}

Zamanı TimeProvider'dan okuyan yer artık ReservationApi. .NET 8 ile gelen TimeProvider soyutlaması sayesinde test'te gerçek saat yerine Microsoft.Extensions.TimeProvider.Testing paketindeki FakeTimeProvider'ı verebiliyoruz:

using Microsoft.Extensions.Time.Testing;

namespace Bookstore.Inventory.Tests;

public sealed class ReservationApiTests
{
    [Fact]
    public void A_reservation_expires_after_30_minutes_of_fake_time()
    {
        FakeTimeProvider clock = new(new DateTimeOffset(2026, 11, 2, 10, 0, 0, TimeSpan.Zero));
        InMemoryEventStore store = new();
        StockCommandHandler handler = new(store);
        ReservationApi api = new(handler, clock);
        StockItemId dune = new(Guid.CreateVersion7());
        OrderId adaOrder = new(Guid.CreateVersion7());

        handler.Handle(new RegisterStockItem(dune, new BookId(Guid.CreateVersion7()), clock.GetUtcNow()));
        handler.Handle(new ReceiveStock(dune, 5, clock.GetUtcNow()));
        api.Reserve(dune, adaOrder, 2);

        clock.Advance(TimeSpan.FromMinutes(29));
        Assert.Empty(api.ReleaseExpired(dune));

        clock.Advance(TimeSpan.FromMinutes(1));
        StockEvent released = Assert.Single(api.ReleaseExpired(dune));
        Assert.Equal(new ReservationReleased(dune, adaOrder, 2, ReleaseReason.Expired, clock.GetUtcNow()), released);
        Assert.Equal(5, store.ReadStream(dune.Value).Aggregate(StockDecider.InitialState, StockDecider.Evolve).Available);
    }
}

Bu test 29. dakikada hiçbir şeyin düşmediğini, 30. dakikada tam bir ReservationReleased üretildiğini ve stoğun yeniden 5'e döndüğünü doğruluyor; saat 2 Kasım 10:00'da donmuş, Advance çağrılmadıkça ilerlemiyor. Aynı handler'ı sonraki yazılarda Marten'in event store'una bağlamak için yalnızca InMemoryEventStore'un yerini değiştirmek yetecek; decider'a dokunulmayacak.

Given-When-Then Test'leri

Decider'ın asıl kazancı test tarafında. Event-sourced bir modelin davranışı tek bir cümleyle anlatılabilir: geçmişte şunlar oldu (Given), şu command geldi (When), şu event'ler üretilmeli (Then). Microsoft Learn'deki Event Sourcing pattern sayfası da bu stili, iş mantığını veritabanı, queue ya da projection olmadan test etmenin yolu olarak anar. Yardımcı iki method yeterli:

namespace Bookstore.Inventory.Tests;

public static class Spec
{
    // Given: geçmiş event'ler, When: command, Then: beklenen yeni event'ler
    public static void Verify(IEnumerable<StockEvent> given, StockCommand when, params StockEvent[] then)
    {
        StockState state = given.Aggregate(StockDecider.InitialState, StockDecider.Evolve);
        Assert.Equal(then, StockDecider.Decide(when, state));
    }

    public static DomainException Rejects(IEnumerable<StockEvent> given, StockCommand when)
    {
        StockState state = given.Aggregate(StockDecider.InitialState, StockDecider.Evolve);
        return Assert.Throws<DomainException>(() => StockDecider.Decide(when, state));
    }
}
namespace Bookstore.Inventory.Tests;

public sealed class StockDeciderTests
{
    private static readonly StockItemId Dune = new(Guid.Parse("0195f0a0-0000-7000-8000-0000000000d1"));
    private static readonly BookId DuneBook = new(Guid.Parse("0195f0a0-0000-7000-8000-000000000001"));
    private static readonly OrderId AdaOrder = new(Guid.Parse("0195f0a0-0000-7000-9000-0000000000a1"));
    private static readonly OrderId AlanOrder = new(Guid.Parse("0195f0a0-0000-7000-9000-0000000000b1"));
    private static readonly DateTimeOffset T0 = new(2026, 11, 2, 10, 0, 0, TimeSpan.Zero);

    private static StockEvent[] Stocked(int copies) =>
        [new StockItemRegistered(Dune, DuneBook, T0), new StockReceived(Dune, copies, T0)];

    [Fact]
    public void Reserve_when_enough_copies_are_available() =>
        Spec.Verify(
            given: Stocked(5),
            when: new ReserveStock(Dune, AdaOrder, 2, T0.AddMinutes(1)),
            then: new StockReserved(Dune, AdaOrder, 2, T0.AddMinutes(1)));

    [Fact]
    public void Reserve_more_than_available_records_out_of_stock() =>
        Spec.Verify(
            given: [.. Stocked(3), new StockReserved(Dune, AdaOrder, 2, T0)],
            when: new ReserveStock(Dune, AlanOrder, 2, T0.AddMinutes(1)),
            then: new OutOfStock(Dune, AlanOrder, Requested: 2, Available: 1, T0.AddMinutes(1)));

    [Fact]
    public void Reserving_twice_for_the_same_order_is_idempotent() =>
        Spec.Verify(
            given: [.. Stocked(5), new StockReserved(Dune, AdaOrder, 2, T0)],
            when: new ReserveStock(Dune, AdaOrder, 2, T0.AddMinutes(1)),
            then: []);

    [Fact]
    public void Only_reservations_older_than_30_minutes_expire() =>
        Spec.Verify(
            given: [.. Stocked(5), new StockReserved(Dune, AdaOrder, 1, T0), new StockReserved(Dune, AlanOrder, 1, T0.AddMinutes(25))],
            when: new ReleaseExpiredReservations(Dune, Now: T0.AddMinutes(35)),
            then: new ReservationReleased(Dune, AdaOrder, 1, ReleaseReason.Expired, T0.AddMinutes(35)));

    [Fact]
    public void Shipping_requires_a_reservation() =>
        Assert.Equal("stock.not-reserved",
            Spec.Rejects(Stocked(5), new ShipReservedStock(Dune, AdaOrder, T0)).Code);

    [Fact]
    public void Adjustment_cannot_drop_below_reserved_copies() =>
        Assert.Equal("stock.below-reserved",
            Spec.Rejects([.. Stocked(3), new StockReserved(Dune, AdaOrder, 2, T0)],
                new AdjustStock(Dune, -2, AdjustmentReason.Damaged, T0)).Code);

    [Fact]
    public void A_discontinued_stock_item_is_terminal()
    {
        StockEvent[] history = [new StockItemRegistered(Dune, DuneBook, T0), new StockItemDiscontinued(Dune, T0)];
        StockState state = history.Aggregate(StockDecider.InitialState, StockDecider.Evolve);

        Assert.True(StockDecider.IsTerminal(state));
        Assert.Equal("stock.discontinued", Spec.Rejects(history, new ReceiveStock(Dune, 1, T0)).Code);
    }
}
Test run summary: Passed!
  total: 9
  failed: 0
  succeeded: 9
  skipped: 0
  duration: 676ms

Dokuz test'in tamamı (decider test'leri, ReservationApi test'i ve aşağıdaki karşılaştırma test'i) bir saniyenin altında bitiyor. Hiçbirinde mock yok, veritabanı yok, saat yok. Given kısmı domain expert'in okuyabileceği bir olgu listesi: "5 nüsha geldi, Ada'nın siparişine 2 ayrıldı". Bir production hatasını yeniden üretmek için o stream'in event'lerini Given'a kopyalamak yeterli. Then tarafında karşılaştırma record equality ile yapılıyor; event'ler değer tipli record'lar olduğu için Assert.Equal alan alan karşılaştırıyor.

OOP Aggregate ile Karşılaştırma

.NET dünyasında event-sourced aggregate'in yaygın biçimi decider değil, OOP (Object-Oriented Programming) stili bir class: state private alanlarda tutulur, public method'lar kuralı kontrol edip bir event "raise" eder, private bir Apply method'u o event'i state'e uygular ve event kaydedilmeyi bekleyen bir listeye eklenir. Aynı rezervasyon kuralı bu stilde:

using System.Collections.Immutable;

namespace Bookstore.Inventory.Oop;

// Aynı kurallar, klasik event-sourced aggregate stiliyle: state alanlarda, Apply method'ları onu değiştirir
public sealed class StockItem
{
    private readonly List<StockEvent> _uncommitted = [];
    private ImmutableDictionary<OrderId, Reservation> _reservations = ImmutableDictionary<OrderId, Reservation>.Empty;

    public StockItemId Id { get; private set; }
    public int OnHand { get; private set; }
    public int Available => OnHand - _reservations.Values.Sum(r => r.Quantity);
    public int Version { get; private set; }
    public IReadOnlyList<StockEvent> UncommittedEvents => _uncommitted;

    public static StockItem Rehydrate(IEnumerable<StockEvent> history)
    {
        StockItem item = new();
        foreach (StockEvent e in history)
        {
            item.Apply(e);
            item.Version++;
        }
        return item;
    }

    public void Reserve(OrderId orderId, int quantity, DateTimeOffset at)
    {
        if (_reservations.ContainsKey(orderId))
            return;
        Raise(quantity <= Available
            ? new StockReserved(Id, orderId, quantity, at)
            : new OutOfStock(Id, orderId, quantity, Available, at));
    }

    private void Raise(StockEvent e)
    {
        Apply(e);
        _uncommitted.Add(e);
    }

    private void Apply(StockEvent e)
    {
        switch (e)
        {
            case StockItemRegistered r: Id = r.StockItemId; break;
            case StockReceived r: OnHand += r.Quantity; break;
            case StockReserved r: _reservations = _reservations.Add(r.OrderId, new Reservation(r.Quantity, r.At)); break;
            case ReservationReleased r: _reservations = _reservations.Remove(r.OrderId); break;
            case ReservedStockShipped s: OnHand -= s.Quantity; _reservations = _reservations.Remove(s.OrderId); break;
            case StockAdjusted a: OnHand += a.Delta; break;
        }
    }
}

İki modelin aynı girdide aynı event'i ürettiğini bir test'le doğruluyoruz:

namespace Bookstore.Inventory.Tests;

public sealed class OopComparisonTests
{
    [Fact]
    public void Oop_aggregate_and_decider_produce_the_same_events()
    {
        StockItemId hobbit = new(Guid.CreateVersion7());
        OrderId adaOrder = new(Guid.CreateVersion7());
        DateTimeOffset at = new(2026, 11, 2, 10, 0, 0, TimeSpan.Zero);
        StockEvent[] history = [new StockItemRegistered(hobbit, new BookId(Guid.CreateVersion7()), at), new StockReceived(hobbit, 1, at)];

        Oop.StockItem item = Oop.StockItem.Rehydrate(history);
        item.Reserve(adaOrder, 2, at);

        StockState state = history.Aggregate(StockDecider.InitialState, StockDecider.Evolve);
        Assert.Equal(StockDecider.Decide(new ReserveStock(hobbit, adaOrder, 2, at), state), item.UncommittedEvents);
    }
}

Davranış aynı; farklar yapıda:

OOP aggregateDecider
StateMutable alanlar, class'ın içindeImmutable record, fonksiyonların dışında
Event uygulamaRaise hem Apply çağırır hem listeye eklerDecide event döndürür, Evolve ayrı çağrılır
PersistenceUncommitted event listesi ve Version aggregate'teHepsi handler'da; decider yalnızca fonksiyon
TestRehydrate, method çağır, listeyi okuDecide(when, fold(given))
OkunabilirlikC# ekipleri için tanıdıkSwitch expression'a ve fonksiyonel düşünmeye alışmak gerekir

OOP stilinin bir tuzağı da kodda görünüyor: Raise, event'i önce kendi state'ine uyguluyor, sonra listeye ekliyor. Bir method iki event üretiyorsa, ikinci event'e karar verirken ilkinin etkisini görmüş state'e bakıyorsun; bu bazen istenen, bazen sinsi bir hata. Decider'da Decide her zaman tek bir, değişmemiş state'e bakar. Seçim bir zevk meselesi değil, ekip meselesi: OOP aggregate'lere alışkın bir ekip için aynı disiplinle (Apply saf, method'lar I/O'suz) yazılmış bir aggregate da gayet iyi çalışır. Decider'ın asıl farkı, saflığı yapının kendisiyle zorlaması.

Sık Düşülen Tuzaklar

Decide'ın dünyaya doğrudan erişmesi

DateTimeOffset.UtcNow, Guid.NewGuid(), Random.Shared, bir repository çağrısı... Hepsi Decide'ı saf olmaktan çıkarır ve açılıştaki test gibi zamana, sıraya ya da ortama bağlı sonuçlar üretir. Zamanı ve yeni id'leri command'a koy, command'ı oluşturan katmana TimeProvider inject et. Karar için başka bir aggregate'in verisi gerekiyorsa, onu handler okuyup command'a koyar ya da bu ihtiyaç, sınırın yanlış çizildiğini söyler.

Evolve'da yan etki ya da doğrulama

Evolve her rehydration'da, her projection rebuild'inde, her test'te yeniden çalışır. İçindeki bir e-posta çağrısı her seferinde tekrar gider; içindeki bir doğrulama, kuralın bugünkü hâline uymayan eski bir event'te stream'i okunamaz hâle getirir. Evolve yalnızca state'i hesaplar.

State'i ekran modeli gibi büyütmek

State'e kitap adını, son rezervasyon tarihini, toplam satışı eklemek cazip; "nasılsa elimde". Ama state yalnızca Decide'ın ihtiyacı için var ve her alan her rehydration'da yeniden hesaplanıyor. Ekranın ihtiyacı bir projection'ın işi.

Beklenen sonucu exception yapmak

Stok yetmediğinde exception fırlatmak, bu bilginin stream'de hiç yer almaması demek. Kampanya sonrasında "kaç sipariş stok yüzünden düştü" sorusunun cevabı OutOfStock event'lerinde; exception'lar ise yalnızca log'da kalır.

Ne Zaman Kullanma

Decider, event sourcing'in bir yazım biçimi; event sourcing'i gereksiz kılan her şey onu da gereksiz kılar. Event sourcing kullanmadığın, state'i tabloda tuttuğun bir aggregate için decider'ın Evolve yarısı boşa düşer; orada DDD serisindeki gibi davranışlı bir entity daha doğal. Ekip OOP aggregate'lerle event sourcing'i zaten disiplinli yürütüyorsa, sırf daha fonksiyonel diye bir yeniden yazım da gerekmez. Decider'ın parladığı yer, karar mantığı karmaşık ve kuralları sık değişen, bu yüzden test'lerin hızlı ve okunaklı olması gereken stream'ler: Bookstore'un stok defteri tam olarak bu.

Küçük Bir Deneme

★☆☆ ReserveStock için 0 ya da negatif adetle gelen command'ı stock.invalid-quantity koduyla reddeden kolu ekle ve önce test'ini yaz. Mevcut testlerden hiçbiri kırılmamalı.

★★☆ ReleaseExpiredReservations command'ına bir ReservationTimeout parametresi ekle ve kampanya haftasında süreyi 15 dakikaya indir. Değişikliği yalnızca command'ı oluşturan katmanda yapabiliyor musun? Given-When-Then ile 14. ve 15. dakikayı test et.

★★★ Chassaing decider'ların birleştirilebildiğinden bahseder. Generic bir Decider<TCommand, TState, TEvent> record'u tanımla (Decide, Evolve, InitialState, IsTerminal alanlarıyla) ve StockCommandHandler'ı her decider ile çalışan generic bir handler'a çevir. Sonra ikinci bir decider (örneğin bir kampanya kotası) ekleyip aynı handler'la çalıştır.

Decider ve handler'ımız hazır, ama event'ler hâlâ bir Dictionary'de yaşıyor. Bir sonraki yazıda gerçek bir event store'a geçiyoruz: Marten ile PostgreSQL üzerinde StartStream, Append, expected version ile optimistic concurrency ve FetchForWriting; KurrentDB ve kendi SQL (Structured Query Language) tablonla event store yazmak arasındaki seçim.