Event-Sourced Aggregates with the Decider Pattern: decide, evolve, and Given-When-Then Tests
The decider pattern splits an event-sourced model's decision logic into two pure functions: Decide produces new events from a command and a state, Evolve produces a new state from a state and an event. A decider for the stock ledger with C# 14 switch expressions, moving time into the command with TimeProvider, database-free Given-When-Then tests and a comparison with the OOP aggregate style, with real test output.
Version note: The code in this post was built and run with C# 14, .NET 10 and xUnit v3.
Guess First
In the previous post we built the stock ledger with event sourcing and put the reservation decision inside the handler as an if. Now a new rule has arrived: if an order's payment isn't completed within 30 minutes, its reservation should drop and the copies should go back on sale. A teammate has written the rule like this and added a test:
namespace Bookstore.Inventory;
public static class NaiveExpiry
{
// Wrong: the clock is read inside the function; the result depends on when the test runs
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);
// We mean "10 minutes later", but the function reads the real clock
Assert.Empty(NaiveExpiry.ReleaseExpired(dune, state));
}
}
The test was green on the day it was written (the morning of 2 November). What happens when it runs in CI (continuous integration) today? How would you test this function as "does not drop at minute 29, drops at minute 30" without changing the real clock and without setting up a database?
The Decider Pattern: A Five-Part Contract
The answer first. Today the test is red:
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
The function reads "now" from inside itself; when the test ran at 10:05 on 2 November the reservation was 5 minutes old, today it is 5 days old. The code didn't change, the result did. The root of the problem is code that makes decisions reaching directly into the world (the clock).
In his 2021 article "Functional Event Sourcing Decider", Jérémie Chassaing describes the decision logic of an event-sourced model in five parts: the command, event and state types; the initial state (initialState); decide, which produces new events from a command and a state; evolve, which produces a new state from a state and one event; and isTerminal, which says the state will never change again. decide and evolve are pure functions: they always return the same output for the same input, do no I/O, and, as Chassaing stresses, don't even write logs. The clock, randomness, the database all stay outside and, when needed, come in as part of the input. This structure is called the decider pattern.
For Bookstore's stock ledger the types are as follows. The events are the same as in the previous post, plus StockItemDiscontinued, which closes a stock item:
// Commands: intent; time comes from the outside
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: only the information needed to decide
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;
}
Two details matter. Commands carry their own time (At, Now): the application layer that creates the command is the one that reads "now". The state holds only the information needed to make decisions: no book title, no warehouse shelf, because no rule looks at them.
decide: Events from a Command and a State
All of the decision logic is a single 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 -> new events. Pure: no I/O, no clock, no randomness
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) => [], // same order: 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}.")
};
The tuple pattern (command, state.Status) first takes care of the life cycle: no command other than registration can be applied to a stock item that is not registered or has been discontinued. Then each command states its own rule. The result has three forms: one or more events (every expired reservation is a separate ReservationReleased), an empty list ("nothing to do", like a second reservation request for the same order), and a DomainException. The split is the same rule as in the DDD (Domain-Driven Design) series: an ordinary outcome that can't be known in advance (not enough stock) is a fact and is recorded as an event; a rule violation (shipping an order that has no reservation) is the caller's mistake and is an exception.
The _ => throw arm at the end exists because the StockCommand hierarchy is not closed as far as the compiler is concerned; the unions coming with C# 15 promise to close such hierarchies with an exhaustive switch.
evolve, initialState, and isTerminal
// evolve: state + event -> new state. Pure and always succeeds: what happened cannot be rejected
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: recorded for the history, does not change state
};
public static bool IsTerminal(StockState state) => state.Status == StockStatus.Discontinued;
}
Evolve never throws and validates nothing. That is deliberate: an event is something that happened; validation was done in Decide, before the event was produced. If Evolve rejected an event, the stream containing it could never be read again. IsTerminal says that a discontinued stock item can be unloaded from memory or archived; it is the basis of the "closing the books" topic in the eighth post of the series.
Connecting the Decider to the World
Pure functions don't save anything on their own. You need a thin layer that connects them to the event store, and that layer is the same for every decider: read the stream, fold it with Evolve, call Decide, append with the version you read.
namespace Bookstore.Inventory;
// The thin layer that connects the decider to the world: read, fold, decide, append
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)
{
// The application layer, not the decider, reads the time and puts it in the command
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()));
}
The place that reads time from TimeProvider is now ReservationApi. Thanks to the TimeProvider abstraction introduced in .NET 8, in tests we can pass FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package instead of the real clock:
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);
}
}
This test verifies that nothing drops at minute 29, that exactly one ReservationReleased is produced at minute 30, and that the stock is back to 5; the clock is frozen at 10:00 on 2 November and doesn't move unless Advance is called. To connect the same handler to Marten's event store in later posts, replacing InMemoryEventStore will be enough; the decider won't be touched.
Given-When-Then Tests
The decider's real payoff is on the testing side. The behavior of an event-sourced model can be described in a single sentence: these things happened in the past (Given), this command arrived (When), these events should be produced (Then). The Event Sourcing pattern page on Microsoft Learn also mentions this style as the way to test business logic without databases, queues or projections. Two helper methods are enough:
namespace Bookstore.Inventory.Tests;
public static class Spec
{
// Given: past events, When: a command, Then: the expected new events
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
All nine tests (the decider tests, the ReservationApi test, and the comparison test below) finish in under a second. None of them has a mock, a database or a clock. The Given part is a list of facts a domain expert can read: "5 copies arrived, 2 were set aside for Ada's order". To reproduce a production bug, copying that stream's events into Given is enough. On the Then side the comparison uses record equality; since the events are value-like records, Assert.Equal compares them field by field.
Comparison with an OOP Aggregate
In the .NET world the common form of an event-sourced aggregate is not a decider but an OOP (Object-Oriented Programming) style class: state is kept in private fields, public methods check the rule and "raise" an event, a private Apply method applies that event to the state, and the event is added to a list waiting to be saved. The same reservation rule in that style:
using System.Collections.Immutable;
namespace Bookstore.Inventory.Oop;
// The same rules in the classic event-sourced aggregate style: state in fields, Apply methods change it
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;
}
}
}
We verify with a test that both models produce the same event for the same input:
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);
}
}
The behavior is the same; the differences are structural:
| OOP aggregate | Decider | |
|---|---|---|
| State | Mutable fields inside the class | An immutable record, outside the functions |
| Applying events | Raise both calls Apply and adds to the list | Decide returns events, Evolve is called separately |
| Persistence | Uncommitted event list and Version on the aggregate | All in the handler; the decider is only functions |
| Testing | Rehydrate, call a method, read the list | Decide(when, fold(given)) |
| Readability | Familiar to C# teams | Takes getting used to switch expressions and functional thinking |
One trap of the OOP style is visible in the code too: Raise applies the event to its own state first and then adds it to the list. If a method produces two events, when deciding on the second one you are looking at a state that has already seen the effect of the first; sometimes that's intended, sometimes it's a sneaky bug. In a decider, Decide always looks at a single, unchanged state. The choice is not a matter of taste but of the team: for a team used to OOP aggregates, an aggregate written with the same discipline (Apply pure, methods without I/O) works perfectly well. The decider's real difference is that it enforces purity through its structure.
Common Pitfalls
Decide reaching directly into the world
DateTimeOffset.UtcNow, Guid.NewGuid(), Random.Shared, a repository call... All of them make Decide impure and produce results that depend on time, order or environment, like the test in the opening. Put the time and new ids into the command, and inject TimeProvider into the layer that creates the command. If a decision needs another aggregate's data, the handler reads it and puts it in the command, or that need is telling you the boundary is drawn in the wrong place.
Side effects or validation in Evolve
Evolve runs again on every rehydration, every projection rebuild, every test. An e-mail call inside it goes out again each time; a validation inside it makes the stream unreadable when it meets an old event that doesn't fit today's version of the rule. Evolve only computes state.
Growing the state like a screen model
It is tempting to add the book title, the last reservation date or total sales to the state; "it's right there anyway". But the state exists only for what Decide needs, and every field is recomputed on every rehydration. What a screen needs is a projection's job.
Turning an expected outcome into an exception
Throwing an exception when stock runs out means that information never appears in the stream. The answer to "how many orders failed because of stock" after a campaign lies in the OutOfStock events; exceptions only end up in logs.
When Not to Use It
The decider is a way of writing event sourcing; everything that makes event sourcing unnecessary makes it unnecessary too. For an aggregate you don't event-source and whose state you keep in a table, the Evolve half of the decider is wasted; there, a behavior-rich entity as in the DDD series is more natural. If a team already runs event sourcing with OOP aggregates in a disciplined way, a rewrite just to be more functional isn't needed either. The decider shines for streams whose decision logic is complex and whose rules change often, and which therefore need fast, readable tests: Bookstore's stock ledger is exactly that.
A Small Experiment
★☆☆ Add an arm that rejects a ReserveStock command with a zero or negative quantity with the code stock.invalid-quantity, and write its test first. None of the existing tests should break.
★★☆ Add a ReservationTimeout parameter to the ReleaseExpiredReservations command and lower the timeout to 15 minutes during campaign week. Can you make the change only in the layer that creates the command? Test minutes 14 and 15 with Given-When-Then.
★★★ Chassaing mentions that deciders can be composed. Define a generic Decider<TCommand, TState, TEvent> record (with Decide, Evolve, InitialState and IsTerminal fields) and turn StockCommandHandler into a generic handler that works with any decider. Then add a second decider (a campaign quota, for example) and run it with the same handler.
Our decider and handler are ready, but the events still live in a Dictionary. In the next post we move to a real event store: StartStream, Append, optimistic concurrency with an expected version, and FetchForWriting with Marten on PostgreSQL; and the choice between KurrentDB and writing your own event store on a SQL (Structured Query Language) table.