Event sourcing is a persistence pattern where the state of an entity is derived by replaying a sequence of immutable events, rather than storing the current state directly. Instead of a users table with a balance column, you store an event log of Deposited(100), Withdrawn(50), Deposited(200) — balance is computed as 100 – 50 + 200 = 250. The event log is the source of truth; the current state is a view derived from it. This enables complete audit history, temporal queries (“what was the balance on March 15?”), and event-driven integration.
Event Store Structure
An event store appends events to a stream, never updates or deletes. Schema: events (id UUID, stream_id VARCHAR, event_type VARCHAR, payload JSONB, sequence_number BIGINT, occurred_at TIMESTAMPTZ). stream_id identifies the entity (e.g., account-123). sequence_number is monotonically increasing per stream — enables optimistic concurrency. To load an entity: SELECT * FROM events WHERE stream_id = ? ORDER BY sequence_number ASC — replay all events to reconstruct state. Optimistic concurrency control: when appending, include the expected sequence number. INSERT INTO events (…, sequence_number) VALUES (…, {expected}) — if another process appended since you read (the sequence_number already exists), the INSERT fails with a unique constraint violation. Retry by re-loading the entity, re-applying your business logic, and re-appending with the new expected sequence number. This prevents lost updates without locks.
Snapshots for Performance
Replaying 10,000 events every time you load an account is too slow. Snapshots periodically capture the current state so you only replay events after the snapshot. Snapshot strategy: after every N events (e.g., 100), save a snapshot: INSERT INTO snapshots (stream_id, sequence_number, state_json, created_at) VALUES (…). On load: SELECT the latest snapshot, then SELECT events WHERE stream_id = ? AND sequence_number > {snapshot.sequence_number} ORDER BY sequence_number. Replay only the delta — typically < 100 events. Snapshot storage: keep the last 1-3 snapshots (in case the latest is corrupted). Snapshots are denormalized views — they can always be reconstructed from the event log. Never store only snapshots without the event log. Snapshot frequency trade-off: more frequent snapshots → faster reads, more write overhead. Every 100 events is typical for banking systems; every 1000 for systems with low read frequency.
Projections and Read Models
The event store is optimized for writes and single-entity reads. Complex queries (show all accounts with balance > $10,000, joined with customer names) require projections — read models built by consuming the event stream. A projection is an event consumer that maintains a denormalized view optimized for a specific query. Example: account_balances table (account_id, customer_name, current_balance, last_updated) — updated by consuming AccountOpened, MoneyDeposited, MoneyWithdrawn events. The projection processes events in order and updates the read model. Multiple projections can consume the same event stream for different read patterns. Eventual consistency: the read model lags behind the event store by the projection processing time (typically milliseconds). If a user deposits money and immediately queries their balance, the projection may not have processed the event yet — the UI should show the optimistic state or indicate the balance is being updated. CQRS (Command Query Responsibility Segregation) pairs naturally with event sourcing: commands write events; queries read from projections.
Event Versioning and Schema Evolution
Events are immutable — you cannot change a MoneyDeposited event stored in 2019. But your domain understanding evolves: the 2024 MoneyDeposited event needs a currency field that didn’t exist in 2019. Event versioning strategies: (1) Upcasting: when reading old events, a transformation function converts them to the latest version. An MoneyDeposited v1 event (amount only) is upcast to v2 (amount + currency, currency defaulting to USD). The application only deals with the latest event version. (2) New event types: instead of modifying MoneyDeposited, introduce MoneyDepositedV2 with additional fields. The event handler processes both versions with different logic. (3) Weak schema (JSONB): store event payload as JSONB. New fields are optional; code handles missing fields. Least structural but requires careful validation. Never delete event types — old events of that type exist in the log forever. Maintain handlers for all historical event versions for as long as you may need to replay the full event log (potentially forever).
When to Use Event Sourcing
Event sourcing adds significant complexity — only use it when the benefits outweigh the cost. Good fit: (1) Audit requirements — financial transactions, medical records, compliance — where a complete immutable history is required by law or regulation. Event sourcing gives this for free. (2) Temporal queries — “show me the state as of last Tuesday” — naturally supported by replaying events up to that timestamp. (3) Event-driven integration — downstream systems consume the event stream. Kafka + event sourcing is a natural combination. (4) Complex domain logic with rich history — a bank account, an order lifecycle, a booking system. Poor fit: simple CRUD applications (a settings page), reporting-heavy systems where current state queries dominate (use a regular database + CDC if you need events), systems where events don’t map naturally to the domain. The operational overhead: you need a snapshot strategy, projection infrastructure, and event schema governance before shipping to production.