Home›Database›Event Sourcing with Databases: Design, Patterns & Production Pitfalls
Advanced
7 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
NarenFounder & Principal Engineer
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
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
✦ Definition~90s read
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.
★
Imagine your bank never stored your 'current balance' — instead it kept every deposit and withdrawal ever made, and calculated your balance by replaying them.
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.
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.
package io.thecodeforge.eventsourcing;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
// Minimal in-memory event store — NOT production-readypublicfinalclassBasicEventStore {
privatefinalMap<String, List<Event>> store = newConcurrentHashMap<>();
publicvoidappend(String aggregateId, Event event) {
store.computeIfAbsent(aggregateId, k -> newArrayList<>()).add(event);
}
publicList<Event> readEvents(String aggregateId) {
return store.getOrDefault(aggregateId, List.of());
}
// Replay all events to compute current statepublicAccountStategetAccountState(String accountId) {
AccountState state = newAccountState(accountId);
for (Event e : readEvents(accountId)) {
state.apply(e);
}
return state;
}
public record Event(String type, String data, long timestamp) {}
publicstaticclassAccountState {
privatefinalString id;
privatedouble balance = 0.0;
AccountState(String id) { this.id = id; }
voidapply(Event e) {
switch (e.type()) {
case"AccountOpened" -> { /* init */ }
case"Deposited" -> balance += Double.parseDouble(e.data());
case"Withdrawn" -> balance -= Double.parseDouble(e.data());
}
}
publicdoublegetBalance() { 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.
thecodeforge.io
Event Sourcing Databases
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.
-- Production event store schema for PostgreSQLCREATETABLE io_thecodeforge.event_store (
aggregate_id VARCHAR(64) NOTNULL,
sequence_number INTEGERNOTNULL,
event_type VARCHAR(128) NOTNULL,
event_data JSONBNOTNULL,
created_at TIMESTAMPTZNOTNULLDEFAULTNOW(),
-- Ensure ordering and uniqueness per aggregatePRIMARYKEY (aggregate_id, sequence_number)
);
-- For projectors that scan by event typeCREATEINDEX idx_event_store_event_type
ON io_thecodeforge.event_store (event_type, created_at);
-- For querying events by time range (auditing)CREATEINDEX idx_event_store_created_at
ON io_thecodeforge.event_store (created_at);
-- For snapshots (separate table)CREATETABLE io_thecodeforge.snapshots (
aggregate_id VARCHAR(64) NOTNULL,
sequence_number INTEGERNOTNULL,
state_type VARCHAR(128) NOTNULL,
state_data JSONBNOTNULL,
created_at TIMESTAMPTZNOTNULLDEFAULTNOW(),
PRIMARYKEY (aggregate_id, sequence_number)
);
Output
CREATE TABLE / CREATE INDEX — no output
Mental Model
Mental Model: Event Store as Ledger
Think of your event store as a double-entry ledger where each row is a journal entry referencing an account (aggregate) and a sequence number.
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.
package io.thecodeforge.eventsourcing;
import java.time.Instant;
import java.util.List;
publicclassSnapshotService {
privatefinalEventStoreRepository eventStore;
privatefinalSnapshotRepository snapshotRepo;
private final int snapshotFrequency = 100; // snapshot every 100 eventspublicSnapshotService(EventStoreRepository eventStore, SnapshotRepository snapshotRepo) {
this.eventStore = eventStore;
this.snapshotRepo = snapshotRepo;
}
publicvoidcreateSnapshot(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 snapshotAggregateState 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 snapshotint totalEvents = (latest != null ? latest.sequenceNumber() : 0) + newEvents.size();
if (totalEvents - (latest != null ? latest.sequenceNumber() : 0) >= snapshotFrequency) {
Snapshot snapshot = newSnapshot(
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.
thecodeforge.io
Event Sourcing Databases
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.
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.
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)publicclassOrderPlacedUpcaster {
privatestaticfinalObjectMapper mapper = newObjectMapper();
publicstaticJsonNodeupcast(JsonNode oldEvent) {
if (!"OrderPlacedV1".equals(oldEvent.get("eventType").asText()))
return oldEvent; // not for usObjectNode 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.
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.
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.
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.
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 aggregateSELECT aggregate_id, count(*) as event_count,
max(sequence_number) - min(sequence_number) + 1 - count(*) as gaps
FROM io_thecodeforge.event_store
GROUPBY aggregate_id
HAVING gaps > 0;
-- 2. Verify latest snapshot sequence_number <= latest event sequence_numberSELECT 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
GROUPBY 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,
(SELECTmax(sequence_number) FROM io_thecodeforge.event_store) - checkpoint_seq AS lag
FROM io_thecodeforge.projection_checkpoints;
Output
Check queries — no output
Mental Model
Mental Model: Immutable Append Log vs Mutable Snapshot
Treat the event store as an immutable append-only journal, and snapshots as optimised caches of state. Never modify the journal, only the caches.
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.
Why Your Standard CRUD Setup Is Begging for Event Sourcing
Your typical CRUD app is a lie. You store the current state, overwrite it on every update, and pretend nothing happened before. That works fine until you need to explain how a customer's account went from $10,000 to zero in three minutes.
Event sourcing doesn't store state. It stores facts. Every change is an immutable event appended to a log. Want to know the balance at any point in time? Replay the events. Need to debug a race condition? Read the event stream. Auditors knocking? Hand them the entire history.
The tradeoff is real: you're trading simple reads for complex reconstruction. If your problem isn't auditability, temporal queries, or high-write contention, don't touch this pattern. But if you're building financial systems, inventory management, or anything where "how did we get here" matters more than "where are we now," CRUD is a liability.
Event sourcing swaps 'latest state' for 'immutable truth.' That's a powerful shift, but it demands discipline. You need idempotent events, careful versioning, and a strategy for snapshots. Skip any of those and you'll be debugging replay logic at 3 AM.
WhyEventSourcing.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
// io.thecodeforge — database tutorial
// CRUD approach vs event sourcing for account transfer
-- CRUD: only current state, no historyCREATETABLEaccounts (
account_id UUIDPRIMARYKEY,
balance DECIMAL(12,2) NOTNULL,
updated_at TIMESTAMPTZDEFAULTNOW()
);
-- UPDATE wipes prior state foreverUPDATE accounts SET balance = 0.00WHERE account_id = 'a1b2c3';
-- Event sourcing: every change is a rowCREATETABLEaccount_events (
event_id BIGSERIALPRIMARYKEY,
aggregate_id UUIDNOTNULL,
event_type VARCHAR(50) NOTNULL,
payload JSONBNOTNULL,
version INTNOTNULL,
recorded_at TIMESTAMPTZDEFAULTNOW()
);
INSERTINTOaccount_events (aggregate_id, event_type, payload, version)
VALUES ('a1b2c3', 'FundsWithdrawn', '{"amount": 5000}', 1);
Output
INSERT 0 1 -- Event appended, history preserved
⚠ Production Trap:
Don't fall for 'let's just add an audit table.' That duplicates writes, introduces consistency headaches, and still doesn't give you temporal reconstruction. If you need the full timeline, commit to event sourcing from the start. Retrofit costs triple.
🎯 Key Takeaway
If your business rules depend on 'what happened before,' CRUD is sabotage. Event sourcing is the only honest storage model.
Event Sourcing Is Not CQRS — Stop Confusing the Two
Every junior who reads a blog post about event sourcing immediately starts drawing boxes labeled 'Command' and 'Query.' That's CQRS. It's a separate pattern that often pairs with event sourcing, but they are not the same thing.
Event sourcing is about storage: how you persist facts. CQRS is about architecture: how you separate reads from writes. You can do event sourcing without CQRS by materializing state directly from events on read. Ugly but functional. You can also do CQRS with a plain relational database — just point your queries at a read replica.
Here's the confusion that causes real damage: teams slap CQRS on top of event sourcing too early, before they understand their query patterns. They build expensive projections for queries that could be answered by a simple snapshot. Then they complain event sourcing is slow.
Start with a single store. Write events, build snapshots, and query from snapshots. Only introduce separate read models when you have data — not guesses — proving you need them. Premature CQRS is the microservices of event sourcing: a solution in search of a problem.
When you do need CQRS (high read/write disparity, different read models for different consumers), design your projections as idempotent subscribers to the event stream. Miss an event? Reprocess from last checkpoint. That's fault tolerance, not failure.
CQRSvsEventSourcing.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — database tutorial
// Projection populated from events, no separate write model
CREATEMATERIALIZEDVIEW account_balances ASWITH event_audit AS (
SELECT
aggregate_id,
SUM(
CASEWHEN event_type = 'FundsDeposited'THEN (payload->>'amount')::NUMERICWHEN event_type = 'FundsWithdrawn'THEN -(payload->>'amount')::NUMERICELSE0END
) AS balance
FROM account_events
GROUPBY aggregate_id
)
SELECT * FROM event_audit;
-- Query snapshot directlySELECT * FROM account_balances WHERE aggregate_id = 'a1b2c3';
Output
aggregate_id | balance
--------------+---------
a1b2c3 | 5000.00
(1 row)
💡Senior Shortcut:
Start with a single PostgreSQL table for events and a materialized view for snapshots. Refresh the view on a schedule or via an event trigger. That's your read model. If you need sub-second queries on billion-row streams, then invest in CQRS. Not before.
🎯 Key Takeaway
Event sourcing stores facts. CQRS optimizes reads. They pair well, but they are not the same pattern. Start without CQRS until your queries prove otherwise.
What Are Event-Driven Microservices in Kafka?
Event-driven microservices decouple services by communicating through immutable event streams instead of direct HTTP calls. Kafka acts as the durable, ordered log where each service produces and consumes events. Unlike a shared database, Kafka stores events as topics — each event is a fact that any service can replay. This matches event sourcing's append-only nature: the Kafka log becomes your event store. Each microservice owns its state by reading from shared topics and projecting its local view. The WHY: synchronous coupling kills scalability. Event-driven architectures let services fail independently, scale separately, and evolve without coordinated deployments. The HOW: producers write events to partitioned topics, consumers track offsets, and brokers guarantee ordering per partition. Combine this with a schema registry to enforce event versions. Kafka handles replay via consumer group rebalancing — add instances to scale reads. Production trap: never treat Kafka as a message queue with ephemeral state; events are permanent records.
Never use Kafka as a database. It trades consistency for throughput. If you need point-in-time snapshots or transactional cross-topic reads, store events in a real database and mirror to Kafka for pub/sub.
🎯 Key Takeaway
Kafka replaces the event store log for distributed systems — every service replays its own projection.
thecodeforge.io
Event Sourcing Databases
Integrating with Webhooks and Database
Webhooks push event notifications to external systems when state changes. The WHY: polling is wasteful and slow — webhooks give instant, targeted delivery. The HOW: after appending an event to your event store, a background process (or database trigger) publishes the event to registered webhook endpoints. PostgreSQL LISTEN/NOTIFY or logical replication slots can notify subscribers without polling. For high throughput, push events into a queue (e.g., Redis, RabbitMQ) and have workers dispatch HTTP POSTs to URLs with retries. The event store remains the source of truth — webhooks are a downstream projection. Production trap: webhooks fail silently; implement exponential backoff and dead-letter queues. Never send raw internal event structures; map to a stable external schema. Use idempotency keys so external systems can safely retry. The key rule: persist before notify — the event store commit must be durable before any webhook fires.
WebhookDispatch.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — database tutorial
CREATETABLEwebhook_subscription (
id UUIDPRIMARYKEY,
url TEXTNOTNULL,
event_type VARCHAR(255) NOTNULL,
retry_count INTDEFAULT0,
created_at TIMESTAMPTZDEFAULTNOW()
);
CREATETABLEwebhook_delivery (
id UUIDPRIMARYKEY,
sub_id UUIDREFERENCESwebhook_subscription(id),
event_id UUIDNOTNULL,
status VARCHAR(20) DEFAULT'pending',
next_retry_at TIMESTAMPTZ,
payload JSONB
);
CREATEINDEX idx_delivery_retry
ONwebhook_delivery (status, next_retry_at)
WHERE status = 'pending';
Output
Query OK, 2 tables created.
⚠ Production Trap:
Writing webhook dispatch logic inside a database trigger creates non-deterministic failures under load. Separate the dispatch into an async worker that reads from a reliable queue.
🎯 Key Takeaway
Webhooks are a projection of events — always persist before notify, and use idempotency keys for safe retries.
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.
Why Your Standard CRUD Setup Is Begging for Event Sourcing
CQRSvsEventSourcing.sql
CREATE MATERIALIZED VIEW account_balances AS
Event Sourcing Is Not CQRS
EventStoreSchema.sql
CREATE TABLE event_stream (
What Are Event-Driven Microservices in Kafka?
WebhookDispatch.sql
CREATE TABLE webhook_subscription (
Integrating with Webhooks and Database
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.
Q02 of 05SENIOR
How do you handle schema evolution in an event-sourced system? Describe two approaches with their trade-offs.
ANSWER
Two approaches: versioned event types (e.g., OrderPlacedV1, OrderPlacedV2) and upcasters. Versioned types: each handler knows how to process multiple versions; flexible but leads to handler code proliferation. Upcasters: transform old events to latest format on read; centralizes logic but adds latency and requires maintaining transformation chain. I prefer upcasters for fewer event types, versioned types when handler logic differs significantly by version.
Q03 of 05SENIOR
What are the performance implications of snapshots in event sourcing? How do you choose the snapshot frequency?
ANSWER
Snapshots trade write overhead for read performance. Taking a snapshot consumes resources (read all events since last snapshot + serialize state). Frequency should be chosen so that replay time from the last snapshot stays under a target (e.g., 100ms). Formula: snapshot_interval = target_replay_time / avg_time_per_event. Also monitor snapshot creation time: if it takes longer than 1s, increase interval or tune serialization. A common starting point is every 100 events, adjusting based on production metrics.
Q04 of 05SENIOR
How would you test event sourcing in a microservices environment?
ANSWER
Test at multiple levels: (1) Unit test events — ensure each event type's apply function produces correct state. (2) Integration test event store — test append and replay with actual database. (3) Contract test projections — ensure event handlers produce correct read model output. (4) End-to-end test — simulate a series of commands and verify that projections reflect the expected state. Also, test projection rebuild by replaying a large set of events to ensure idempotency and performance.
Q05 of 05SENIOR
Describe a production incident you've witnessed or can imagine related to event sourcing. What was the root cause and how did you fix it?
ANSWER
A classic incident: events missing from a projection because the projector failed silently due to an unhandled event type. The root cause was a new event type was deployed but the projection didn't have a handler for it. The fix: add logging for unhandled events, and during rebuild, skip events that have no handler (but log them). Better: maintain a registry of expected event types and alert on unknown ones. I'd then rebuild the projection and ensure the handler is added.
01
Explain the difference between event sourcing and the event-driven architecture (EDA). Can they be combined?
SENIOR
02
How do you handle schema evolution in an event-sourced system? Describe two approaches with their trade-offs.
SENIOR
03
What are the performance implications of snapshots in event sourcing? How do you choose the snapshot frequency?
SENIOR
04
How would you test event sourcing in a microservices environment?
SENIOR
05
Describe a production incident you've witnessed or can imagine related to event sourcing. What was the root cause and how did you fix it?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Is event sourcing only for financial systems?
No. While banking is a classic use case, event sourcing applies wherever auditability, time travel, or multiple read models matter: e-commerce orders, inventory management, collaboration tools, and even game state.
Was this helpful?
02
How do I handle delete operations in event sourcing?
You don't delete events. Instead, append a 'DeletionRequested' or 'AccountClosed' event. The projection can filter out closed aggregates. To physically purge data for privacy regulations, you need a different strategy like anonymisation or a separate retention policy that truncates events after a period.
Was this helpful?
03
What's the difference between event sourcing and a change data capture (CDC) log?
CDC captures database row changes (INSERT/UPDATE/DELETE) at the database level. Event sourcing captures business events at the application level. CDC is limited to the table structure; event sourcing can capture richer semantics. They can complement: CDC can replicate events to other stores, but event sourcing remains the source of truth.
Was this helpful?
04
What database should I use for an event store?
Relational databases like PostgreSQL or MySQL work well — JSONB columns, transaction support, and robust indexing. NoSQL databases like DynamoDB or Cassandra are also used for high write throughput, but they lack transactional ordering guarantees across partitions. Choose based on whether you need consistent ordering per aggregate across all events.
Was this helpful?
05
How do I migrate from a traditional CRUD database to event sourcing without stopping the system?
Dual-write: write both to legacy DB and event store, using a migration script to backfill historical events from the legacy data. Run a verification step to compare states. Then switch reads to projections and cut over. This is risky — consider a big-bang migration with a weekend freeze.