Skip to content

6. Domain Events

When someone borrows a book, several things need to happen: create the loan, update the patron's active loan count, maybe check the waitlist. You could jam all of that into the BorrowBook handler, but it would grow fast. Domain events let you split the "something happened" from the "react to it."

The Event Queue

src/Library.Lending/Runtime.cs (add to existing)
[EventQueue("LendingDomainEvents")]
public static partial class LendingDomainEvents;

This generates an in-process channel backed by System.Threading.Channels.Channel<T>. Events published here stay inside Lending — they don't cross context boundaries. That's what integration events are for (Chapter 8).

Define Events

src/Library.Lending/Domain/DomainEvent.cs
public static class DomainEvent
{
    public sealed record BookBorrowed(BookId BookId, PatronId PatronId);
    public sealed record BookReturned(BookId BookId, PatronId PatronId);
}

Plain records. No base class, no marker interface. Just data.

Publish from Handlers

Update BorrowBook to publish after saving:

from _ in LendingStore.Loans.Save<LendingRuntime>(loan)
          >> LendingDomainEvents.Enqueue<LendingRuntime>(new DomainEvent.BookBorrowed(cmd.BookId, cmd.PatronId))
select id;

The >> operator sequences: save the loan, then enqueue the event. If the save fails, the event is never published.

Handle Events

src/Library.Lending/Domain/EventHandlers/LendingDomainEventHandlers.cs
[EventQueueHandler<LendingRuntime>("LendingDomainEvents")]
public static class LendingDomainEventHandlers
{
    public static Eff<LendingRuntime, Unit> Handle(DomainEvent.BookBorrowed evt) =>
        from patron in LendingStore.Patrons.Require<LendingRuntime>(evt.PatronId)
        from _ in LendingStore.Patrons.Save<LendingRuntime>(
            patron with { ActiveLoanCount = patron.ActiveLoanCount + 1 })
        select unit;

    public static Eff<LendingRuntime, Unit> Handle(DomainEvent.BookReturned evt) =>
        from patron in LendingStore.Patrons.Require<LendingRuntime>(evt.PatronId)
        from _ in LendingStore.Patrons.Save<LendingRuntime>(
            patron with { ActiveLoanCount = Math.Max(0, patron.ActiveLoanCount - 1) })
        select unit;
}

[EventQueueHandler<LendingRuntime>("LendingDomainEvents")] tells the generator this class handles events from the LendingDomainEvents queue. Each Handle method matches by parameter type — DomainEvent.BookBorrowed goes to the first method, DomainEvent.BookReturned to the second.

The generator produces a background worker that reads from the channel and dispatches to these handlers. It runs automatically when the app starts.

Test Events

Unit tests can verify events were published using the Create() path:

[Test]
public async Task BorrowBook_PublishesDomainEvent()
{
    var runtime = TestLendingRuntime.Create();
    var patronId = PatronId.New();
    runtime.PatronStore.Seed(new Patron { Id = patronId, Name = "Alice", Email = "alice@example.com" });

    await LendingDispatch.BorrowBook(patronId, BookId.New()).RunAsync(runtime);

    runtime.LendingDomainEvents.AssertContains<DomainEvent.BookBorrowed>();
}

Create() gives you the test runtime with spy stores and event queue contexts. AssertContains<T>() checks that an event of that type was enqueued. No timing issues, no background workers — the events are captured synchronously.

Next → Jobs