Senior 6 min · March 06, 2026

Event Sourcing with Databases: Design, Patterns & Production Pitfalls

Event Sourcing with databases explained deeply — schema design, snapshot strategies, projection rebuilds, CQRS integration, and real production gotchas you won't find elsewhere.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Event sourcing stores every state change as an immutable event in an append-only log
  • Current state is derived by replaying events — never stored directly
  • Snapshot strategies prevent replay time from growing linearly with event count
  • Event store schema must index on aggregate ID, event type, and timestamp
  • Projections rebuilt from scratch by replaying history — design them idempotent
  • Biggest mistake: treating events as fixed — schema evolution is mandatory, not optional
Plain-English First

Imagine your bank never stored your 'current balance' — instead it kept every deposit and withdrawal ever made, and calculated your balance by replaying them. That's event sourcing. Instead of saving the current state of something, you save every change that ever happened to it. Your database becomes a permanent, ordered diary of facts, not a whiteboard that gets erased and rewritten.

Most applications are built around a simple mental model: save the current state, overwrite it when it changes, query it when you need it. That model works until it doesn't — and the moment it stops working, you feel it hard. You can't answer 'what did this record look like three weeks ago?' You can't audit who changed what and why. You can't replay business logic against historical data. You've lost information the moment you hit UPDATE.

Event sourcing flips this model on its head. Instead of storing current state, you store an immutable, append-only log of every state transition — every event that caused the world to change. The current state becomes a derived view, computed on demand by replaying that log. This gives you a complete audit trail, time-travel debugging, the ability to rebuild any projection from scratch, and a natural fit for event-driven architectures. The database stops being a snapshot of 'now' and becomes a ledger of 'everything that ever happened'.

By the end of this article you'll know how to design an event store schema from scratch, implement snapshot strategies to keep replay times sane at scale, wire up CQRS read models as SQL projections, handle schema evolution without breaking old events, and avoid the production mistakes that bite teams six months after they ship. This is the article I wish existed when I first built an event-sourced system in production.

What is Event Sourcing with Databases?

The core idea is simple: instead of updating a row in-place to reflect the latest state, you append an event to an append-only log. Each event captures what happened, when, and why. The current state of any entity — an account, an order, a document — is computed by replaying all its events in order.

This is fundamentally different from CRUD. In CRUD, an UPDATE erases the previous state. You lose history. Event sourcing preserves every fact forever. That's hugely powerful for auditing, debugging, and building multiple read models without fighting the write schema.

But it's also a trade-off. Replaying events on every read is slow — that's where snapshots come in. Storing immutable events makes schema evolution harder — you have to handle old event formats gracefully. And because you're appending to a single table at high concurrency, your database needs to handle write throughput, not just reads.

The snippet below shows a minimal Java example. This is not what you'd use in production, but it illustrates the append-replay loop.

io/thecodeforge/eventsourcing/BasicEventStore.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package io.thecodeforge.eventsourcing;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

// Minimal in-memory event store — NOT production-ready
public final class BasicEventStore {
    private final Map<String, List<Event>> store = new ConcurrentHashMap<>();
    
    public void append(String aggregateId, Event event) {
        store.computeIfAbsent(aggregateId, k -> new ArrayList<>()).add(event);
    }
    
    public List<Event> readEvents(String aggregateId) {
        return store.getOrDefault(aggregateId, List.of());
    }
    
    // Replay all events to compute current state
    public AccountState getAccountState(String accountId) {
        AccountState state = new AccountState(accountId);
        for (Event e : readEvents(accountId)) {
            state.apply(e);
        }
        return state;
    }
    
    public record Event(String type, String data, long timestamp) {}
    
    public static class AccountState {
        private final String id;
        private double balance = 0.0;
        
        AccountState(String id) { this.id = id; }
        
        void apply(Event e) {
            switch (e.type()) {
                case "AccountOpened" -> { /* init */ }
                case "Deposited" -> balance += Double.parseDouble(e.data());
                case "Withdrawn" -> balance -= Double.parseDouble(e.data());
            }
        }
        
        public double getBalance() { return balance; }
    }
}
Output
(No output — in-memory demonstration)
Forge Tip
Type this code yourself rather than copy-pasting. The muscle memory of writing it will help it stick.
Production Insight
In production, never use in-memory lists. Use a real database with transactional writes.
An event store row typically has: aggregate_id, sequence_number, event_type, event_data (JSON/JSONB), and a timestamp.
Rule: ensure the primary key is (aggregate_id, sequence_number) to guarantee ordering per aggregate.
Key Takeaway
Event sourcing trades simplicity for auditability.
Current state is never stored — it's derived.
Snapshots will save you from O(n) replays at scale.
Should You Use Event Sourcing?
IfDo you need a full audit trail of every change?
UseStrong candidate — event sourcing gives you this by design.
IfCan you tolerate eventual consistency in read models?
UseYes, projections can be updated asynchronously — event sourcing works well.
IfIs your team experienced with CQRS and event-driven architectures?
UseIf not, the learning curve may be steep. Consider a simpler approach first.
IfAre you trying to replace a simple CRUD app with no audit requirements?
UseOverkill. Stick with traditional state persistence.

Event Store Schema Design

Your event store schema is the foundation of your entire system. Get it wrong and you'll fight ordering issues, slow queries, and painful migrations.

The essential columns: aggregate_id (string/ UUID), sequence_number (integer), event_type (string), event_data (JSON/JSONB), timestamp (transaction time). The primary key MUST be (aggregate_id, sequence_number). This enforces ordering per aggregate and allows fast replay.

You also need indexes for specific query patterns: projectors that need to scan events by type or by timestamp range. Consider a composite index on (event_type, timestamp) for subscriptions. But remember: you trade write throughput for read performance.

Avoid using high-latency sequences like UUIDs for sequence_number unless you can guarantee monotonic ordering. Database sequences (like SERIAL in PostgreSQL) work well for single-writer per aggregate. If you have concurrent writers for the same aggregate, you need optimistic locking with a version field.

Below is a PostgreSQL DDL for a production-grade event store table.

io/thecodeforge/eventsourcing/event_store_ddl.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Production event store schema for PostgreSQL
CREATE TABLE io_thecodeforge.event_store (
    aggregate_id   VARCHAR(64) NOT NULL,
    sequence_number INTEGER NOT NULL,
    event_type     VARCHAR(128) NOT NULL,
    event_data     JSONB NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    -- Ensure ordering and uniqueness per aggregate
    PRIMARY KEY (aggregate_id, sequence_number)
);

-- For projectors that scan by event type
CREATE INDEX idx_event_store_event_type 
    ON io_thecodeforge.event_store (event_type, created_at);

-- For querying events by time range (auditing)
CREATE INDEX idx_event_store_created_at 
    ON io_thecodeforge.event_store (created_at);

-- For snapshots (separate table)
CREATE TABLE io_thecodeforge.snapshots (
    aggregate_id    VARCHAR(64) NOT NULL,
    sequence_number INTEGER NOT NULL,
    state_type      VARCHAR(128) NOT NULL,
    state_data      JSONB NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (aggregate_id, sequence_number)
);
Output
CREATE TABLE / CREATE INDEX — no output
Mental Model: Event Store as Ledger
  • Aggregate ID = account number. Every event belongs to exactly one account.
  • Sequence number = increasing check number. Gaps indicate missing transactions.
  • Event type = transaction type (deposit, withdrawal, transfer).
  • Event data = transaction details (amount, counterparty, notes).
  • Timestamp = when the transaction was recorded (business time, not system time).
Production Insight
Using sequence_number from a database sequence works for single-writer aggregates but fails under concurrent writes — you'll get gaps and ordering conflicts.
Rule: write events for one aggregate in a single transaction with serializable isolation.
Watch out for hot rows: if you have many events for one aggregate, the B-tree primary key becomes a bottleneck.
Key Takeaway
Ordering per aggregate is guaranteed by the primary key (aggregate_id, sequence_number).
Never use timestamps as the ordering key — they are not unique or monotonic.
Indexes are for projectors, not for replay; replay uses primary key.

Snapshot Strategies for Efficient Replay

Replaying every event from the beginning of time on every read gets expensive fast. If an aggregate has 100k events, a single read takes 100k SQL queries or a huge IN clause. Snapshots are the answer.

A snapshot captures the aggregate state at a specific point (sequence number). On read, you load the latest snapshot and replay only events after that snapshot. This keeps replay cost constant relative to the window between snapshots, not total history.

Decide when to take snapshots: after every N events, or after a time interval. Use a background process (e.g., cron job or scheduled task) that reads events since the last snapshot and writes the aggregated state. Ensure snapshot creation is idempotent: if two snapshot jobs overlap, the later one wins.

Store snapshots in a separate table with the same primary key structure but with the state data. Projections also need snapshots if they're rebuilt often.

Here's a Java snippet for snapshot creation logic.

io/thecodeforge/eventsourcing/SnapshotService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package io.thecodeforge.eventsourcing;

import java.time.Instant;
import java.util.List;

public class SnapshotService {
    private final EventStoreRepository eventStore;
    private final SnapshotRepository snapshotRepo;
    private final int snapshotFrequency = 100; // snapshot every 100 events
    
    public SnapshotService(EventStoreRepository eventStore, SnapshotRepository snapshotRepo) {
        this.eventStore = eventStore;
        this.snapshotRepo = snapshotRepo;
    }
    
    public void createSnapshot(String aggregateId) {
        // Load latest snapshot (if any)
        Snapshot latest = snapshotRepo.findLatest(aggregateId).orElse(null);
        int fromSequence = (latest != null) ? latest.sequenceNumber() + 1 : 1;
        
        // Get current state by replaying from snapshot
        AggregateState state = (latest != null) 
            ? latest.restoreState()
            : AggregateState.initial(aggregateId);
        
        List<Event> newEvents = eventStore.readEventsSince(aggregateId, fromSequence);
        for (Event e : newEvents) {
            state.apply(e);
        }
        
        // If enough events have passed, persist snapshot
        int totalEvents = (latest != null ? latest.sequenceNumber() : 0) + newEvents.size();
        if (totalEvents - (latest != null ? latest.sequenceNumber() : 0) >= snapshotFrequency) {
            Snapshot snapshot = new Snapshot(
                aggregateId,
                totalEvents,
                state.getClass().getName(),
                state.toJson(),
                Instant.now()
            );
            snapshotRepo.save(snapshot);
        }
    }
}
Output
(No output — service class)
Common Snapshot Pitfall
If your snapshot creation process runs concurrently with event writes, you might capture partial state. Use optimistic locking or run snapshots against a read replica that is slightly behind the primary.
Production Insight
Choose snapshot frequency based on replay latency budget. If 10ms per 100 events is acceptable, snapshot every 1000 events writes.
Rule: always store the sequence number at which the snapshot was taken. Without it, you can't know where to resume replay.
Beware of "snapshot storm": if many aggregates hit the frequency threshold simultaneously, the background job can overwhelm your database.
Key Takeaway
Snapshots bound replay cost to window size, not total history.
Store the snapshot sequence number precisely — it's your replay resume point.
Idempotency in snapshot creation prevents corruption from concurrent runs.

Projections and CQRS Integration

Event sourcing naturally pairs with CQRS. The write side appends events, the read side builds projections — denormalised tables optimised for query patterns. Projections are updated asynchronously by subscribing to the event stream.

This decoupling lets you build multiple read models without touching the write schema. You can have a relational projection for reporting, a cache projection for APIs, and a search projection for Elasticsearch — all fed from the same event stream.

But projections introduce complexity: eventual consistency, idempotency, and rebuild capability. When a projection fails, you need to rebuild it from scratch by replaying all events. Idempotency ensures that replaying events multiple times doesn't double-count. Your projection handlers must be deterministic and handle duplicate events gracefully.

Use a dedicated projection table that tracks the last processed position (often the event store's transaction ID or sequence number). This allows the projection to resume after a crash without missing events.

Here's a simple projector example for reading account balances.

io/thecodeforge/eventsourcing/AccountBalanceProjector.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package io.thecodeforge.eventsourcing.projectors;

import io.thecodeforge.eventsourcing.*;
import java.sql.Connection;
import java.sql.PreparedStatement;

public class AccountBalanceProjector {
    private final Connection conn;
    private final EventStore eventStore;
    private long lastProcessedPosition = 0;
    
    public AccountBalanceProjector(Connection conn, EventStore eventStore) {
        this.conn = conn;
        this.eventStore = eventStore;
    }
    
    public void processNextBatch() {
        List<Event> events = eventStore.readEventsAfter(lastProcessedPosition, 100);
        for (Event e : events) {
            switch (e.type()) {
                case "Deposited" -> applyDeposit(e);
                case "Withdrawn" -> applyWithdrawal(e);
                // ignore other event types
            }
            lastProcessedPosition = e.globalPosition();
        }
        // Save checkpoint (normally in a transaction)
        saveCheckpoint(lastProcessedPosition);
    }
    
    private void applyDeposit(Event e) {
        String accountId = e.aggregateId();
        double amount = Double.parseDouble(e.data());
        try (PreparedStatement stmt = conn.prepareStatement(
            "UPDATE account_balances SET balance = balance + ? WHERE account_id = ?")) {
            stmt.setDouble(1, amount);
            stmt.setString(2, accountId);
            stmt.executeUpdate();
        } catch (SQLException ex) { throw new RuntimeException(ex); }
    }
    // ... withdrawal method similar
}
Output
(No output — projector code)
CQRS Truth
Projections are your read model. They should reflect exactly what your queries need — nothing more. If you denormalise too much, rebuilding becomes expensive. Too little, you end up joining multiple projections.
Production Insight
Projection lag is the most common operational issue. Monitor the lag between event store and projection positions. Set alarms if lag exceeds a threshold (e.g., 5 minutes).
Rule: make projections idempotent by using upserts or checking before update.
A full rebuild can take hours on large event stores — test this early.
Key Takeaway
Projections decouple reads from writes: each projection is a specialised view.
Always track the position checkpoint — it's your recovery point.
Idempotency is non-negotiable: replaying events must be safe.

Schema Evolution: Handling Event Versioning

Events are immutable, but your understanding of the domain changes. You'll need to add fields, rename them, or change structure. The challenge: you have millions of events in the old format. You can't go back and update them — they're immutable.

Two strategies: versioned event types and upcasters.

Versioned event types: each event type carries a version number (e.g., OrderPlacedV1, OrderPlacedV2). New events are written in the new version. On replay, you check the version and apply the appropriate handler. Old events stay as-is.

Upcasters: conversion functions that transform an old event into the new format on-the-fly during replay. This centralizes the transformation and keeps application code dealing only with the latest version. Upcasters run after deserialization but before handler application.

Choose upcasters over versioned types if the number of event types grows large — you avoid handler proliferation. Choose versioned types if different handlers need different logic per version.

Real-world tools like EventStoreDB or Axon Framework have built-in upcaster support. For custom databases, you implement upcasters in your application layer.

Here's an upcaster example in Java.

io/thecodeforge/eventsourcing/upcaster/OrderPlacedUpcaster.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package io.thecodeforge.eventsourcing.upcaster;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;

// Converts OrderPlacedV1 to OrderPlacedV2 (adds currency field)
public class OrderPlacedUpcaster {
    private static final ObjectMapper mapper = new ObjectMapper();
    
    public static JsonNode upcast(JsonNode oldEvent) {
        if (!"OrderPlacedV1".equals(oldEvent.get("eventType").asText()))
            return oldEvent; // not for us
        
        ObjectNode v2 = oldEvent.deepCopy();
        v2.put("eventType", "OrderPlacedV2");
        v2.put("currency", "USD"); // default for old events
        // Optionally add a field to mark it was upcasted
        v2.put("_upcasted", true);
        return v2;
    }
}
Output
(No output — upcaster utility)
Upcaster Ordering
Upcasters can chain: V1 → V2 → V3. They must be applied in sequence. Store the upcaster version in the event metadata so you know which transformations have been applied already.
Production Insight
Forgetting to upcast during projection rebuild is a silent failure — your projection processes old events in old format and produces wrong data.
Rule: test rebuild with the full history including old events before deploying schema changes.
Upcasters add latency to replay; measure their impact during snapshot creation.
Key Takeaway
Events are immutable — schema evolution must be backward-compatible.
Upcasters transform old events to new format at read time.
Versioned event types keep old handlers around; upcasters keep code cleaner.

Production Pitfalls: Real-World Mistakes and Fixes

After running event sourcing in production for months, teams hit predictable but painful problems. Here are the three that hurt most.

  1. Missing per-aggregate ordering. As covered in the incident report, without a correct sequence number and single-writer per aggregate, events get reordered. The fix: enforce that all events for an aggregate are appended by the same thread/process, or use optimistic locking with a version field in the aggregate.
  2. Snapshot corruption due to concurrent writes. If your snapshot job reads events while a new event is being written, it may snap an incomplete state. The snapshot appears valid but when replaying after it, you get duplicate or missing events. Solution: run snapshots on a read replica with a known lag, or use snapshot isolation level.
  3. Projection rebuild timeout. When a projection fails and needs a full rebuild, replaying millions of events can exceed HTTP timeout for APIs or exhaust memory. Test rebuild times regularly. Consider rebuilding via a background job with progress tracking, and serve stale read models until rebuild completes.

Below is a checklist for avoiding these pitfalls.

io/thecodeforge/eventsourcing/checklist.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- Pitfall prevention checklist (run after deployment)

-- 1. Check for ordering gaps per aggregate
SELECT aggregate_id, count(*) as event_count,
       max(sequence_number) - min(sequence_number) + 1 - count(*) as gaps
FROM io_thecodeforge.event_store
GROUP BY aggregate_id
HAVING gaps > 0;

-- 2. Verify latest snapshot sequence_number <= latest event sequence_number
SELECT s.aggregate_id, s.sequence_number as snapshot_seq, 
       max(e.sequence_number) as latest_event_seq
FROM io_thecodeforge.snapshots s
JOIN io_thecodeforge.event_store e ON s.aggregate_id = e.aggregate_id
GROUP BY s.aggregate_id, s.sequence_number
HAVING s.sequence_number > max(e.sequence_number);

-- 3. Find projection lag: difference between event store max sequence and projection checkpoint
-- (Requires a projection_checkpoint table)
SELECT projector_name, 
       (SELECT max(sequence_number) FROM io_thecodeforge.event_store) - checkpoint_seq AS lag
FROM io_thecodeforge.projection_checkpoints;
Output
Check queries — no output
Mental Model: Immutable Append Log vs Mutable Snapshot
  • Journal = truth. Append only, never update or delete.
  • Snapshot = derived cache. Can be recomputed from journal at any time.
  • Projections = further derived caches. Also recomputable.
  • Read models will always be eventually consistent with the journal.
  • If journal is corrupted, you lose everything — back up the journal.
Production Insight
Most production issues with event sourcing are not about the events themselves but about the derived artifacts: snapshots, projections, and their consistency.
Rule: invest in monitoring the health of your projection system before anything else.
A single corrupted snapshot can cause incorrect state for all subsequent reads until the snapshot is rebuilt.
Key Takeaway
Ordering, snapshot consistency, and projection rebuild are the top three failure modes.
Monitor projection lag and snapshot freshness.
Regularly test full rebuilds on production-like data volume.
● Production incidentPOST-MORTEMseverity: high

The Missing Events Incident — Why Ordering Broke Our Audit Trail

Symptom
When replaying events for a single aggregate, events appeared out of order. The audit trail showed sequences like A, C, B instead of A, B, C. Some business logic produced incorrect state because assumptions about event order were violated.
Assumption
We assumed that inserting events with increasing timestamps would preserve insertion order across all aggregates. We used a composite primary key (aggregate_id, timestamp) and relied on the database's default ordering by primary key to replay events correctly per aggregate.
Root cause
The application ran on multiple servers with unsynchronised system clocks. Two events from different aggregates (A and B) were committed almost simultaneously on different nodes. Node A's clock was 50ms behind Node B's. Event B was written first (by Node B) with a later timestamp, and event A was written second with an earlier timestamp. When querying by aggregate_id for a third aggregate, the snapshot replay was unaffected, but global event queries and certain projections that assumed monotonic global order broke. Additionally, the lack of an explicit sequence number per aggregate meant we couldn't sort events reliably within an aggregate.
Fix
We introduced a strict ordering mechanism: each aggregate has an auto-incrementing sequence number stored in a separate table or generated via a database sequence. The primary key became (aggregate_id, sequence_number). We also ensured that event publishing used a single-writer per aggregate to avoid concurrent inserts. For global ordering, we added a logical wall clock like Google's TrueTime API or a single-source-of-truth timestamp server.
Key lesson
  • Never rely on physical clocks for event ordering — use sequence numbers per aggregate.
  • If you need global event order, use a distributed sequencer or accept eventual consistency.
  • Always test event replay under concurrent write load across time-skewed machines.
Production debug guideSymptom → Action guide for common event store failures4 entries
Symptom · 01
Projection is stale — read model shows older data than latest event
Fix
Check projection subscriber lag: monitor the position offset. Verify the projection function didn't throw. Look at logs for unhandled event types. If yes, add missing handlers and trigger a full rebuild.
Symptom · 02
Event replay returns wrong aggregate state
Fix
Replay the aggregate event stream manually with timestamps. Check if events are out of order. If so, fix the ordering mechanism (add sequence numbers). Also verify snapshot doesn't corrupt state.
Symptom · 03
Event store query times explode as event count grows
Fix
Check if snapshots are configured. Without snapshots, every read replays all events. Add periodic snapshots. Also verify indexes on aggregate_id and timestamp.
Symptom · 04
Old events fail to deserialize during replay
Fix
Likely schema evolution broke backward compatibility. Ensure upcasters exist for all old event versions. Check the event version field in the stored data. If missing, you need a migration.
★ Quick Debug Cheat Sheet for Event SourcingRun these commands and checks when things go wrong in your event-sourced system.
Missing events in stream
Immediate action
Check the event store for gaps in sequence numbers per aggregate. Then verify the publisher acknowledged commit.
Commands
SELECT aggregate_id, sequence_number FROM events WHERE aggregate_id = '<id>' ORDER BY sequence_number;
Check application logs for commit errors or timeouts.
Fix now
If gap exists and events are lost, restore from backup or re-publish from source of truth. If publisher missed confirm, implement retry with idempotency keys.
Projection rebuild runs forever+
Immediate action
Count total events and check if the process is making progress. Kill the process if it loops.
Commands
SELECT COUNT(*) FROM events; -- compare with projection progress pointer
Check for infinite loop in projection code (e.g., handler firing a new event inadvertently).
Fix now
Add a circuit breaker: if projection doesn't advance after 10k events, halt and investigate.
Snapshot loads but state is corrupt+
Immediate action
Rollback to previous snapshot and replay events from that point.
Commands
Find last known good snapshot: SELECT * FROM snapshots WHERE aggregate_id = '<id>' ORDER BY version DESC LIMIT 5;
Apply events after that snapshot to rebuild state and compare.
Fix now
If snapshot creation logic is buggy, disable snapshots temporarily and rely on full replay. Fix the snapshot logic in a patch.
Event Sourcing vs Traditional State Persistence
AspectEvent SourcingTraditional CRUD
State storageDerived from event logStored directly in row
Audit trailInherent — every change is recordedNot available unless manually added
Time travelTrivial: replay events to any pointRequires slow-change dimension (SCD) or snapshots
Read model flexibilityEasily build multiple projectionsLimited to table structure
Write performanceSingle append per transactionUpdate with potential indexing overhead
Schema evolutionComplex — must handle old event versionsStandard ALTER TABLE or migration
Operational complexityHigh — need snapshot jobs, projection managementLow — standard DB operations
Learning curveSteep — new mental modelGentle — familiar CRUD

Key takeaways

1
Event sourcing preserves every state change in an append-only log
full audit trail by default.
2
Snapshots are critical for performance
they cap replay cost to a window, not entire history.
3
Event schema evolution is inevitable
use versioned types or upcasters to handle old events.
4
Projections decouple reads from writes
make them idempotent and rebuildable.
5
Monitor projection lag and snapshot freshness; test rebuilds early and often.

Common mistakes to avoid

5 patterns
×

Using timestamp as event ordering key

Symptom
Events from different aggregates appear out of order; replays produce inconsistent state when applied across aggregates (if global ordering assumed).
Fix
Use a monotonically increasing sequence number per aggregate, never rely on timestamps for ordering.
×

Not implementing snapshots from day one

Symptom
Replay times grow linearly with event count. After a few thousand events, reads time out or become unbearably slow.
Fix
Schedule snapshot creation early. Even a simple periodic snapshot (every 100 events) will keep replay cost bounded.
×

Treating events as immutable and never evolving the schema

Symptom
When business requirements change, old events deserialize with missing fields, causing projection errors or null pointer exceptions.
Fix
Plan for schema evolution: add event version field from the start and build upcasters for any structural changes.
×

Projection handler not idempotent

Symptom
During projection rebuild, events applied twice cause duplicate records (e.g., double-counted deposits).
Fix
Use upsert operations exclusively. Ensure the projection logic checks for existing records before inserting, or use on-conflict-update (PostgreSQL).
×

Running snapshot creation concurrently with event writes without isolation

Symptom
Snapshots contain partial state — missing event or one event less. When the snapshot is used, subsequent replays produce incorrect aggregate state.
Fix
Read events for snapshot at a consistent point using database snapshot isolation or a dedicated read replica.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between event sourcing and the event-driven archi...
Q02SENIOR
How do you handle schema evolution in an event-sourced system? Describe ...
Q03SENIOR
What are the performance implications of snapshots in event sourcing? Ho...
Q04SENIOR
How would you test event sourcing in a microservices environment?
Q05SENIOR
Describe a production incident you've witnessed or can imagine related t...
Q01 of 05SENIOR

Explain the difference between event sourcing and the event-driven architecture (EDA). Can they be combined?

ANSWER
Event sourcing is a persistence pattern: you store events as the primary record of state. Event-driven architecture is a communication pattern: services react to events asynchronously. They can be combined (CQRS/ES + event bus), but they are independent concepts. Many teams use EDA without event sourcing (just messaging) and vice versa.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is event sourcing only for financial systems?
02
How do I handle delete operations in event sourcing?
03
What's the difference between event sourcing and a change data capture (CDC) log?
04
What database should I use for an event store?
05
How do I migrate from a traditional CRUD database to event sourcing without stopping the system?
🔥

That's Database Design. Mark it forged?

6 min read · try the examples if you haven't

Previous
Denormalisation in Databases
13 / 16 · Database Design
Next
CQRS with Databases