Event sourcing

3 min read
On trails:EDAPATstep 6/7

In most systems the database holds the current state of each order and an update overwrites the old value. In event sourcing the database holds the events instead: OrderPlaced, ItemAdded, PaymentReceived. They are only ever appended, never changed. The current state of an order is worked out by applying its events in order. A bank statement works this way: it lists deposits and withdrawals, and the balance is what they add up to.

Six order events in sequence feed a fold, Apply(state, event), which produces the current order state: shipped, item B, total 15, paid. EVENTS, APPENDED IN ORDER 1 OrderPlaced 2 ItemAdded A 10 3 ItemAdded B 15 4 ItemRemoved A 10 5 PaymentReceived 15 6 OrderShipped Apply(state, event) once per event CURRENT STATE Order Status: Shipped Items: B Total: 15 Paid: yes the events are the record, the state is derived by folding them
The state of one order, derived from its stream of events.

In code

The state is a fold, a function applied to every event in turn, starting from an empty state. Apply is a pure function: it takes the state and one event and returns the new state, with no database call and no clock. That makes it easy to test, and it makes replay deterministic.

order-fold.csC#
public enum OrderStatus { None, Placed, Shipped } public abstract record OrderEvent;public record OrderPlaced(Guid OrderId) : OrderEvent;public record ItemAdded(string Sku, decimal Price) : OrderEvent;public record ItemRemoved(string Sku, decimal Price) : OrderEvent;public record PaymentReceived(decimal Amount) : OrderEvent;public record OrderShipped : OrderEvent; public record Order(Guid Id, OrderStatus Status, IReadOnlyList<string> Skus, decimal Total, bool Paid){    public static Order Empty => new(Guid.Empty, OrderStatus.None, [], 0m, false);     // Pure: the same state and event always give the same result.    public static Order Apply(Order order, OrderEvent evt) => evt switch    {        OrderPlaced e => order with { Id = e.OrderId, Status = OrderStatus.Placed },        ItemAdded e => order with { Skus = [..order.Skus, e.Sku], Total = order.Total + e.Price },        ItemRemoved e => order with        {            Skus = order.Skus.Where(s => s != e.Sku).ToList(),            Total = order.Total - e.Price        },        PaymentReceived => order with { Paid = true },        OrderShipped => order with { Status = OrderStatus.Shipped },        _ => order    };     // The fold: start empty and apply every event in order.    public static Order Rebuild(IEnumerable<OrderEvent> history) =>        history.Aggregate(Empty, Apply);}

Rebuild folds the whole history into the current Order. Each order has its own stream of events, so rebuilding one order reads only that stream.

Projections and snapshots

Reading the current state by replaying events is slow when queries span many orders, and an event store is hard to query in other ways. So the events are also folded into projections, read models such as a table of orders per customer, kept up to date as events arrive. This is where CQRSCQRSSeparate models for changing data and for reading it.Opens in a new tab usually comes in. A new projection can be built later from the old events.

A long stream is slow to replay. A snapshot saves the folded state at some event number, and a rebuild starts from the snapshot and applies only the events after it. It is an optimisation, and the events remain the record.

Tools

You can use an ordinary relational table as an append-only event store. Purpose-built options add per-stream reads, optimistic concurrency and projections. Marten is a .NET library that stores events in PostgreSQL. KurrentDB is a database built for event streams. It was called EventStoreDB before the rename that came with version 25.0. A message broker such as Kafka carries events to other services, but it does not give per-entity stream reads or concurrency checks, so it does not replace an event store.

Marten rebuilds an aggregate with AggregateStreamAsync. As in its quick start, the aggregate is a record with static Create and Apply methods.

marten.csC#
await using var session = store.LightweightSession(); session.Events.StartStream(orderId, new OrderPlaced(orderId));session.Events.Append(orderId, new ItemAdded("sku-1", 10m));await session.SaveChangesAsync(); // Rebuild the current state by replaying the stream.var order = await session.Events.AggregateStreamAsync<OrderView>(orderId); public sealed record OrderView(Guid Id, decimal Total){    public static OrderView Create(OrderPlaced placed) => new(placed.OrderId, 0m);    public static OrderView Apply(ItemAdded added, OrderView view) =>        view with { Total = view.Total + added.Price };}

Lines 3 to 5 start the stream and append to it. Line 8 replays it into an OrderView. The store is a configured DocumentStore.

The catch

Events are permanent, so a mistake stays in the history. A wrong event is corrected by appending another one, and a changed event shape has to be handled when old events are read (see event versioningEvent versioningChange an event's shape by adding to it, and run a second version beside the first when you must break it.Opens in a new tab). Replaying must not repeat side effects such as sending an email or charging a card, because the outside world cannot tell a replay from the real thing. Personal data in an immutable store conflicts with deletion requests, which has to be designed for from the start. Microsoft’s guidance says that for most systems, and most parts of a system, traditional data management is enough. Event sourcing fits a part such as a ledger or an order process, where the history itself matters.

Events in the store are fine-grained and internal. What other services receive may be a separate, more stable event (see anatomy of an eventAnatomy of an eventAn envelope that says which event it is and when it happened, and a payload that carries the facts.Opens in a new tab).


Sources