Stackbook Logo
← Back to Tracks

Event Sourcing Implementation: A Guided Track

Learn to implement event sourcing step by step: event design, event stores, projections, snapshots, schema evolution and the pitfalls to avoid.

5 min read

Event sourcing stores the state of an entity as an append-only sequence of events instead of overwriting the current state. The current state is derived by replaying those events. The approach gives a complete audit trail and enables temporal queries and multiple read models, but it changes how you design, test and operate a system. This track walks through a practical implementation and is honest about the costs.

Learning goals

  • Model a domain as commands and events.
  • Implement an aggregate that rebuilds from events and enforces invariants.
  • Build projections for queries and handle eventual consistency.
  • Plan for snapshots, event versioning and operational concerns.

Week 1: Model events

Events are facts in the past tense, named in the language of the business: AccountOpened, MoneyDeposited, MoneyWithdrawn. Commands are requests that may be rejected: WithdrawMoney.

Command: WithdrawMoney(account=A1, amount=50)
  -> load events for A1 -> rebuild balance -> check invariant balance >= amount
  -> append MoneyWithdrawn(account=A1, amount=50)

Guidelines for good events:

  • Record intent and business meaning, not just field changes. AddressCorrected and CustomerRelocated mean different things.
  • Make them immutable and self-contained enough to be understood later.
  • Include metadata: event id, aggregate id, version, timestamp, causation and correlation ids.

Exercise: list the events for a shopping cart or bank account, and for each, state which invariant is checked before it can occur.

Week 2: Build the aggregate and event store

An aggregate has a method to decide (command to events) and a method to apply (event to state).

class Account:
    def __init__(self):
        self.balance = 0
        self.version = 0

    def apply(self, event):
        if event["type"] == "MoneyDeposited":
            self.balance += event["amount"]
        elif event["type"] == "MoneyWithdrawn":
            self.balance -= event["amount"]
        self.version += 1

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")
        return [{"type": "MoneyWithdrawn", "amount": amount}]

The event store needs an append operation with optimistic concurrency: append only if the stream is still at the expected version, otherwise reject and retry. A minimal relational schema:

events(stream_id, version, type, payload, metadata, created_at)
primary key (stream_id, version)

The primary key on stream and version enforces the concurrency check. You can start with PostgreSQL; dedicated stores exist, but the pattern does not require them.

Exercise: implement load, decide and append for one aggregate, and write a test that two concurrent withdrawals cannot both succeed against insufficient funds.

Week 3: Projections and read models

Queries over event streams are inefficient, so you build projections: consumers that read events and maintain query-optimised views.

Event store --> Projector --> account_summary table
            --> Projector --> daily_totals report

Key practices:

  • Make projectors idempotent, storing the last processed position so reprocessing is safe.
  • Accept eventual consistency. A user may submit a command and immediately read stale data, so design the UI to handle it, for example by returning the new state from the command or by waiting for the projection to catch up.
  • Rebuild by replay: when you need a new view, create a new projection and replay history. This is a major benefit, but replay time grows with history.

Exercise: build a projection that maintains balances, kill it midway, restart it, and confirm results are correct without duplicates.

Week 4: Snapshots, versioning and operations

Snapshots store aggregate state periodically so you load the snapshot plus recent events rather than the full stream. Add them only when measured load times justify it; many aggregates have short streams.

Event versioning is the hardest long-term problem, because events are immutable but your understanding changes. Options:

| Technique | Use when | |---|---| | Add optional fields with defaults | Backward-compatible additions | | Upcasting on read | Convert old shapes to new in code | | New event type | Meaning changed | | Stream migration | Last resort; rewrites history carefully |

Never edit stored events in place as routine practice. For privacy rules such as erasure requests, plan in advance: store personal data separately from events, or encrypt it per subject and discard the key (crypto-shredding).

Exercise: change an event's shape, write an upcaster, and verify old streams still replay.

When not to use event sourcing

Avoid it for simple CRUD, where history has no value, and where the team lacks experience with eventual consistency. It is often best applied to specific aggregates, such as accounts or orders, within an otherwise conventional system. Event sourcing is distinct from CQRS, though they are often combined; you can use either alone.

Key takeaways

  • Event sourcing persists facts, not current state; state is derived by replaying events.
  • Name events in business language and check invariants in the aggregate before appending.
  • Use optimistic concurrency on the stream version.
  • Build idempotent projections and design for eventual consistency.
  • Add snapshots only when needed, and plan event versioning and privacy up front.
  • Apply it selectively where history and auditability pay for the added complexity.