Event sourcing

Why a projection is a view, not the source of truth

The mistake

It is easy to read a projection as the real state, a cache of the account that the events feed. So when someone asks for a report the projection does not track, the instinct is to go back and change how events are recorded.

That has it backwards. The events are the source of truth. The balance, the statement, the audit trail: each is only a view, folded from the same stream. A projection holds nothing the events do not already hold. You can throw one away, add a new one, and rebuild it from events that were recorded long before it existed.

The machine

Simulator · Event sourcingprojectors

The stream, every projection and each replay run on the tested reducer.

4 events. Balance in sync at £120.

Drive it

The stream on the left is the truth. Each card on the right is one view of it, with its own position in the stream.

  • Turn on the Statement projector. It appears empty and behind by four. The events already happened, but this view has folded none of them yet.
  • Replay it, one event at a time. Watch the count and totals build up from facts that were on the stream before the projector was ever added.
  • Replay from start. Every projection resets to empty. Nothing is lost. Step through again and each view rebuilds to the exact same value.

The mechanism

Every change is stored as an event, an immutable fact: money deposited, money withdrawn. Events are only ever appended, never edited or deleted. That append-only stream is the one piece of state the system truly keeps.

A projector builds a read model by handling those events in order. Handling an event is a pure fold: take the current view, apply one fact, get the next view. So any projection is a fold over some position in the stream, a count of how many events it has taken in. A projector that is present when an event is stored handles it there and then, so it stays current. One you register later has folded nothing, and sits behind the stream until you replay it.

The position is the idea, not a field. The panel above shows it because that is what you need to see; the package does not record it anywhere. Nothing tracks how far a projector has read, and nothing notices that a newly registered one is behind. Knowing a projection needs rebuilding, and rebuilding it, are both your job.

To bring a lagging projector up to date you replay: reset it and feed it the stored events from the start. Because handling is a pure fold over facts that do not change, a replay is deterministic. It always lands on the same view. This is why a projection is safe to delete: it is not the truth, only a cached fold of it, and the truth is still sitting in the stream.

Seen this way, a new reporting need is not a schema change. It is a new projector over events you already have.

In your code

Event sourcing is a pattern, not a built in Laravel feature. The common way to add it to a Laravel app is the spatie/laravel-event-sourcing package, where a projector extends the package’s Projector base class and handles one event type per method:

class BalanceProjector extends Projector
{
    public function onMoneyDeposited(MoneyDeposited $event): void
    {
        Account::find($event->accountId)->increment('balance', $event->amount);
    }

    public function onMoneyWithdrawn(MoneyWithdrawn $event): void
    {
        Account::find($event->accountId)->decrement('balance', $event->amount);
    }

    // Without this, a replay adds every event on top of the balance that is
    // already there. The package only clears a projection if you tell it how.
    public function resetState(): void
    {
        Account::query()->update(['balance' => 0]);
    }
}

Register a second projector for a view you did not have before, then rebuild that one from the existing stream:

php artisan event-sourcing:replay "App\Projectors\StatementProjector"

Name the projector. Bare event-sourcing:replay replays every registered projector, which rebuilds views that were already correct.

The fine print

  • A replay does not clear anything by itself. The panel here resets a projection before rebuilding it, because that is what makes a replay land on the same value. spatie/laravel-event-sourcing only does that if the projector defines a resetState() method. Without one, a replay folds every event in a second time, on top of the numbers already stored.
  • Replaying does not re-fire side effects. Reactors are skipped during a replay, so rebuilding a projection does not send the welcome emails again.
  • Snapshots. Replaying millions of events is slow, so real systems store a periodic snapshot and replay only the events after it.
  • Concurrency and versioning. Two writers appending at once, and events whose shape changes over time, both need handling this page skips.
  • Aggregate boundaries. Here every event is accepted. A real aggregate guards its own rules, for example rejecting a withdrawal that would overdraw.
  • Async projectors and reactors. Projectors can run on a queue, and reactors handle side effects like sending mail. Neither changes the core idea.

Further reading

Spotted a problem, or have a way to make this clearer? Suggest an improvement.