The Event Sourcing pattern
Instead of saving the last state, save all happenings in order as events. We build the current state by running the events again. You get a full history that can be audited, but the system becomes much more complex.
Author: bezzad
The problem: the last state erases the history
In the online shop, the orders table keeps only the last state of each order: paid, total 900 thousand tomans, city Shiraz.
One day a customer complains: “My order went to Tehran, but my address is Shiraz!” The support team wants to know what happened:
- When did the address change? Before shipping or after it?
- What was the old address?
- Who changed it?
The table has no answer. Each update erased the old value. Only the last state is left.
The idea: save the events, not the state
In Event Sourcing, each change is an event: something that happened in the past and does not change anymore. Like “item added” or “address changed”. We save only these events.
- Only append. A new event is added to the end of the list. No event is deleted or edited.
- The current state is calculated. To know the state of an order, we run all its events in order from the start.
- A bank account is an old example. A bank does not see the balance as just one number. The balance is the sum of all deposits and withdrawals. An accounting ledger is the same: people do not erase a mistake, they add a correcting line.
Live example
Move the slider. Each time, the order state is built from the start, from the events:
State of order 42
See that “phone” was added once and then removed. There is no sign of it in the last state, but it is in the events. The sales team may want to know how many customers added a phone to the cart and then changed their mind. With a normal table, this data would be gone.
What do we get?
- A full history that can be audited. Each change is saved with its time and reason. This is very valuable for finance and legal areas.
- Time travel. You can ask “what state was this order in yesterday at 10 o’clock?”. You only need to run the events up to that moment.
- New read models from old data. If a new report is needed tomorrow, you build it from all past events. No data is lost.
- Finding bugs. You can take the events of a problem order and run exactly the same path again.
Code
Events and the Aggregate
Events are simple, unchangeable records:
public abstract record OrderEvent;
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId, string City) : OrderEvent;
public sealed record ItemAdded(string Sku, int Quantity, decimal Price) : OrderEvent;
public sealed record ItemRemoved(string Sku) : OrderEvent;
public sealed record AddressChanged(string City) : OrderEvent;
public sealed record OrderPaid(decimal Amount) : OrderEvent;
The Order class has two separate jobs. The public methods check the rules and create events. The Apply method only updates the state from the event and does not check any rule. This is because the event has already happened and cannot be rejected.
public sealed class Order
{
private readonly Dictionary<string, decimal> _lines = new();
private readonly List<OrderEvent> _newEvents = [];
public Guid Id { get; private set; }
public string City { get; private set; } = "";
public bool IsPaid { get; private set; }
public int Version { get; private set; } // events already stored
public decimal Total => _lines.Values.Sum();
public IReadOnlyList<OrderEvent> NewEvents => _newEvents;
public static Order FromHistory(IEnumerable<OrderEvent> history)
{
var order = new Order();
foreach (var e in history)
{
order.Apply(e);
order.Version++;
}
return order;
}
public void AddItem(string sku, int quantity, decimal price)
{
if (IsPaid) throw new InvalidOperationException("A paid order cannot change.");
Raise(new ItemAdded(sku, quantity, price));
}
public void Pay()
{
if (IsPaid) throw new InvalidOperationException("Order is already paid.");
if (_lines.Count == 0) throw new InvalidOperationException("Order is empty.");
Raise(new OrderPaid(Total));
}
private void Raise(OrderEvent e)
{
Apply(e);
_newEvents.Add(e);
}
// Only changes state. No rules here: the event already happened.
private void Apply(OrderEvent e)
{
switch (e)
{
case OrderPlaced p: Id = p.OrderId; City = p.City; break;
case ItemAdded a: _lines[a.Sku] = a.Quantity * a.Price; break;
case ItemRemoved r: _lines.Remove(r.Sku); break;
case AddressChanged c: City = c.City; break;
case OrderPaid: IsPaid = true; break;
}
}
}
Saving with a version check
The event store (Event Store) has two main jobs: reading all events of a stream (Stream), and adding new events only if the version has not changed:
public interface IEventStore
{
Task<IReadOnlyList<OrderEvent>> ReadAsync(Guid streamId, CancellationToken ct);
// Fails if someone else appended to the stream after expectedVersion.
Task AppendAsync(Guid streamId, int expectedVersion,
IReadOnlyList<OrderEvent> events, CancellationToken ct);
}
public sealed class PayOrderHandler(IEventStore store)
{
public async Task HandleAsync(Guid orderId, CancellationToken ct)
{
var order = Order.FromHistory(await store.ReadAsync(orderId, ct));
order.Pay();
await store.AppendAsync(orderId, order.Version, order.NewEvents, ct);
}
}
Why is the version check needed?
- Two requests at the same time read the order at version 5.
- The first request adds “paid” as the sixth event.
- The second request wants to add “item added”. But the version is not 5 anymore.
- The event store rejects it. The second request reads again and sees that the order is paid. So the rule “a paid order does not change” is kept.
In a relational database, this is done with a unique key on the stream id and the version number. You do not need to build these from zero. The Marten library on PostgreSQL and the KurrentDB database (which was called EventStoreDB before) have these features ready.
Read models (Projection)
Reading all events for each page is slow. For example, the “my orders” page cannot run hundreds of events for each order. So we build a read model:
- A background job listens to new events.
- With each event, it updates a simple table that is ready to show.
- Pages read only from this table. It is fast.
- If the read model breaks or its shape must change, you delete it and build it again from all the events.
This is the same as separating reads and writes (CQRS). Event Sourcing almost always comes with CQRS.
The hard parts
The Event Sourcing pattern is not free. Know these hard parts before you choose it:
- An old event does not change. If the shape of the ItemAdded event changes, the events from three years ago still have the old shape. The code must understand all versions. One way is to turn the old event into the new shape while reading (Upcasting).
- Long streams get slow. If an Aggregate has thousands of events, running all of them takes time. The solution is a snapshot (Snapshot): every few hundred events, save the state and continue from there.
- Deleting personal data is hard. Laws like GDPR say that a user’s personal data must be deletable. But an event is not deleted. Common ways: do not put personal data in the event, put only the id. Or encrypt personal data with a special key for each user, and to “delete” it, destroy the key.
- Querying directly is hard. You cannot ask “all orders over one million tomans” directly from the events. Each question needs a read model.
- The team must learn a new way of thinking. A mistake in the event design is expensive, because events stay forever.
Important rules
- Never edit or delete events. To fix something, add a compensating event.
- The event name is the business language. “Address changed”, not “row updated”.
- Always save with a version check. Otherwise two requests at the same time break the rules.
- The Apply method must not check any rule. A rule is checked only before the event is created.
- Events must be complete. Each event must have enough information to build the state, without needing outside data.
- Plan from the start for changes in the event shape. Versioning and Upcasting.
- Only where it is worth it. Usually for one or two important parts of the system, not all of it.
Common mistakes
| Mistake | Result | The right way |
|---|---|---|
| Event Sourcing for all parts of the system | Too much complexity for simple parts. | Only the parts where history has value. |
| Technical events like “row changed” | The history has no business meaning. | Events in the business language. |
| Saving without a version check | Two requests at the same time break the rule. | Save with the expected version. |
| Checking rules inside the Apply method | Old events are rejected while loading. | Rules only before the event is created. |
| Editing old events to fix a bug | The history cannot be trusted anymore. | A compensating event or Upcasting. |
| Full personal data inside the event | Deleting user data is not possible. | Only the id, or encryption with a key for each user. |
| Pages reading directly from events | Pages get slow. | A read model (Projection). |
When to use Event Sourcing?
Good fit
- History and auditing are part of the business: money, accounting, insurance.
- Questions like “what was the state on a certain date?” are important.
- The domain is complex and the events are already the business language.
Bad fit
- A simple app for saving and editing data (CRUD).
- The team has no experience with events, CQRS and eventual consistency.
- A change history table is enough. Build that, not Event Sourcing.
Summary in six lines
- In Event Sourcing, the events are the source of truth, not the last state.
- Events are only appended and are never changed or deleted.
- The current state is built by running the events again.
- Saving always has a version check, so requests at the same time do not break the rules.
- Pages read from read models that are built from the events.
- The cost is high: event versioning, Snapshots and personal data. Use it only where history really has value.