The Deal aggregate from the last lesson decides and applies, but it forgets everything as soon as the program ends. In this lesson sales gets its event store: a table in SQL Server that events are only appended to, and an adapter that reads and writes it. You won't install a finished event store from a package; you'll build a small one of your own. It is easy to take in, and it teaches you what matters for every event store: order, versions and what happens when two writers write at the same time.

What an event store has to do

For the aggregate, the event store needs exactly two things:

  1. Read: all the events of one deal in their order, plus the version, meaning the number of events.
  2. Append: new events behind the version the writer read, and only then. If someone else has appended in the meantime, nothing is written, and the writer is told.

The second rule is called optimistic concurrency: nobody locks while reading, but when writing, the store checks whether the basis is still correct. You know it from lesson 2.4.10 as rowversion in a table holding the latest state. Here the stream's version is what the rowversion was there.

A stream is the sequence of events of one aggregate, here of one deal. The port lives in Kladde.Sales.Application; the model doesn't know it:

C#
// src/Kladde.Sales.Application/IEventStore.cs
using Kladde.Sales.Domain;
using Kladde.SharedKernel;

namespace Kladde.Sales.Application;

// The port: the events of one deal, read in order and appended only behind the version the caller saw.
public interface IEventStore
{
    Task<DealHistory> ReadAsync(TenantId tenant, DealId deal, CancellationToken cancellationToken = default);

    // False when someone else appended first: the stream no longer has expectedVersion events.
    Task<bool> TryAppendAsync(
        TenantId tenant, DealId deal, int expectedVersion, IReadOnlyList<DealEvent> events, CancellationToken cancellationToken = default);
}

// Version = the number of events in the stream; 0 for a deal that does not exist yet.
public sealed record DealHistory(int Version, IReadOnlyList<DealEvent> Events)
{
    public static DealHistory Empty { get; } = new(0, []);
}
  • Every call carries the TenantId. The same deal of another office is another stream (house rules: every key carries the tenant).
  • TryAppendAsync returns false when someone else was faster. That is an expected outcome, not an exceptional case: two people in the office will sometimes click at the same moment. An exception is thrown only when the database can't be reached.
  • The port speaks in events of the model, not in JSON and not in rows. How things are stored is known only to the adapter.