Event Sourcing — When It's Worth the Complexity
The Promise and the Pain
Event sourcing stores every change as an immutable event instead of overwriting the current state. Instead of a balance: 500 column, you store: deposited 1000, withdrew 300, deposited 100, withdrew 300. The current state is derived by replaying events.
The promise: complete audit trail, time-travel queries, decoupled systems. The pain: eventual consistency, complex queries, event versioning, and a team that needs to think differently about data.
CRUD vs Event Sourcing
CRUD (traditional):
UPDATE accounts SET balance = balance - 100 WHERE id = 123;
→ Current state: balance = 500
→ What happened before? No idea (unless you add audit logging)
→ Can we undo? Only if we track what changed
Event Sourcing:
events: [
{ type: "AccountOpened", amount: 0, at: "2026-01-01" },
{ type: "MoneyDeposited", amount: 1000, at: "2026-01-15" },
{ type: "MoneyWithdrawn", amount: 300, at: "2026-02-01" },
{ type: "MoneyDeposited", amount: 100, at: "2026-02-15" },
{ type: "MoneyWithdrawn", amount: 300, at: "2026-03-01" },
]
→ Current state: replay events → balance = 500
→ What happened before? Everything is recorded
→ Can we undo? Apply a compensating event
When Event Sourcing Is Worth It
Strong fit:
✓ Financial systems (audit trail is legally required)
✓ Order management (need to know every state change)
✓ Collaborative editing (multiple users changing same data)
✓ Compliance-heavy domains (healthcare, banking, government)
✓ Systems that need temporal queries ("what was the state on March 1?")
✓ Complex business workflows with many state transitions
Not worth the complexity:
✗ Simple CRUD applications (user profiles, settings)
✗ Content management systems
✗ Analytics dashboards (read-heavy, no write concerns)
✗ Prototypes and MVPs (you'll rebuild anyway)
✗ Small teams without event-driven experience
A Practical Implementation
// Event definitions
type OrderEvent =
| { type: "OrderCreated"; orderId: string; customerId: string; items: OrderItem[] }
| { type: "PaymentReceived"; amount: number; method: string }
| { type: "OrderShipped"; trackingNumber: string; carrier: string }
| { type: "ItemReturned"; itemId: string; reason: string }
| { type: "RefundIssued"; amount: number; reason: string }
| { type: "OrderCanceled"; reason: string };
// Event store
interface EventStore {
append(streamId: string, events: DomainEvent[], expectedVersion: number): Promise<void>;
getEvents(streamId: string, fromVersion?: number): Promise<DomainEvent[]>;
}
// Aggregate: rebuild state from events
class OrderAggregate {
private state: OrderState = { status: "unknown", items: [], payments: [], totalPaid: 0 };
private version = 0;
static async load(eventStore: EventStore, orderId: string): Promise<OrderAggregate> {
const aggregate = new OrderAggregate();
const events = await eventStore.getEvents(`order-${orderId}`);
for (const event of events) {
aggregate.apply(event);
aggregate.version++;
}
return aggregate;
}
private apply(event: OrderEvent): void {
switch (event.type) {
case "OrderCreated":
this.state = { status: "created", items: event.items, payments: [], totalPaid: 0 };
break;
case "PaymentReceived":
this.state.payments.push({ amount: event.amount, method: event.method });
this.state.totalPaid += event.amount;
this.state.status = "paid";
break;
case "OrderShipped":
this.state.status = "shipped";
this.state.tracking = { number: event.trackingNumber, carrier: event.carrier };
break;
case "OrderCanceled":
this.state.status = "canceled";
break;
}
}
// Commands produce events (with business rule validation)
ship(trackingNumber: string, carrier: string): OrderEvent[] {
if (this.state.status !== "paid") {
throw new Error("Cannot ship an unpaid order");
}
return [{ type: "OrderShipped", trackingNumber, carrier }];
}
cancel(reason: string): OrderEvent[] {
if (this.state.status === "shipped") {
throw new Error("Cannot cancel a shipped order — use returns instead");
}
return [{ type: "OrderCanceled", reason }];
}
}Projections (Read Models)
Event sourcing separates writes (events) from reads (projections):
// Projection: build a read-optimized view from events
class OrderDashboardProjection {
async handle(event: OrderEvent & { streamId: string; timestamp: Date }) {
switch (event.type) {
case "OrderCreated":
await db.orderDashboard.create({
data: {
orderId: event.orderId,
customerId: event.customerId,
status: "created",
itemCount: event.items.length,
createdAt: event.timestamp,
},
});
break;
case "PaymentReceived":
await db.orderDashboard.update({
where: { orderId: extractOrderId(event.streamId) },
data: { status: "paid", totalPaid: { increment: event.amount } },
});
break;
case "OrderShipped":
await db.orderDashboard.update({
where: { orderId: extractOrderId(event.streamId) },
data: { status: "shipped", trackingNumber: event.trackingNumber },
});
break;
}
}
}The Hard Parts
Event versioning:
→ Events are immutable. You can't change old events.
→ When the schema changes, you need upcasters:
v1: { type: "OrderCreated", total: 100 }
v2: { type: "OrderCreated", total: { amount: 100, currency: "USD" } }
→ Upcaster transforms v1 events to v2 shape on read
Eventual consistency:
→ Projections lag behind events (milliseconds to seconds)
→ Users might not see their change immediately
→ Solution: "read your own writes" pattern
Snapshot optimization:
→ Replaying 10,000 events per aggregate is slow
→ Take periodic snapshots of aggregate state
→ Load from snapshot + replay only newer events
Event sourcing isn't an architecture you should adopt lightly. But for domains where the history of changes is as important as the current state — financial systems, order management, compliance-heavy applications — it provides capabilities that are nearly impossible to retrofit into a CRUD system. Start with one bounded context, keep your events small and focused, and invest in good projections for reads.