Most apps store current state. User has $100. Order is shipped. Product has 50 in stock.
Event sourcing flips this. You store every change that happened. User deposited 50 more. Order was created, then paid, then shipped.
Sounds like extra work? Sometimes it is. But sometimes its exactly what you need.
Traditional vs Event Sourced
With events, you get full history. You know exactly how you got here.
When To Use It
Good fit:
- Financial systems (audit trails)
- Booking/reservation systems
- Collaboration tools (like Google Docs)
- Systems needing "time travel"
Not worth it:
- Simple CRUD apps
- Systems without audit needs
- When history doesnt matter
Basic Implementation
// Events are immutable facts
interface Event {
id: string;
type: string;
data: unknown;
timestamp: Date;
aggregateId: string;
}
// Example events for an order
type OrderEvent =
| { type: 'OrderCreated'; data: { items: Item[]; customerId: string } }
| { type: 'PaymentReceived'; data: { amount: number; method: string } }
| { type: 'OrderShipped'; data: { trackingNumber: string } }
| { type: 'OrderDelivered'; data: { signature: string } };
The Event Store
class EventStore {
async append(aggregateId: string, events: Event[]) {
// Events are append-only - never update or delete
await db.insert('events', events.map(e => ({
...e,
aggregateId,
timestamp: new Date()
})));
}
async getEvents(aggregateId: string): Promise<Event[]> {
return db.query(
'SELECT * FROM events WHERE aggregate_id = ? ORDER BY timestamp',
[aggregateId]
);
}
}
Rebuilding State
Current state comes from replaying events:
class Order {
status: string = 'pending';
items: Item[] = [];
total: number = 0;
trackingNumber?: string;
// Apply events to build state
apply(event: OrderEvent) {
switch (event.type) {
case 'OrderCreated':
this.items = event.data.items;
this.total = this.items.reduce((sum, i) => sum + i.price, 0);
break;
case 'PaymentReceived':
this.status = 'paid';
break;
case 'OrderShipped':
this.status = 'shipped';
this.trackingNumber = event.data.trackingNumber;
break;
case 'OrderDelivered':
this.status = 'delivered';
break;
}
}
static fromEvents(events: OrderEvent[]): Order {
const order = new Order();
events.forEach(e => order.apply(e));
return order;
}
}
Snapshots for Performance
Replaying thousands of events gets slow. Take snapshots:
class OrderRepository {
async get(orderId: string): Promise<Order> {
// Try to load from snapshot first
const snapshot = await this.getSnapshot(orderId);
const fromVersion = snapshot?.version ?? 0;
// Only replay events after snapshot
const events = await eventStore.getEvents(orderId, fromVersion);
const order = snapshot?.state ?? new Order();
events.forEach(e => order.apply(e));
return order;
}
async saveSnapshot(orderId: string, order: Order, version: number) {
await db.upsert('snapshots', {
aggregateId: orderId,
state: JSON.stringify(order),
version
});
}
}
CQRS: Separate Read and Write
Event sourcing pairs well with CQRS (Command Query Responsibility Segregation):
Write side deals with events. Read side maintains optimized views for queries.
The Gotchas
- Schema evolution - Events live forever. Changing their shape is tricky.
- Eventually consistent - Read models lag behind writes.
- Complexity - More moving parts than simple CRUD.
- Debugging - Following event chains takes practice.
Further Reading
- Event Sourcing by Martin Fowler
- Designing Data-Intensive Applications - Chapter 11 covers this well
Event sourcing isnt for everything. But when you need complete history, auditability, or the ability to rebuild state at any point in time - its incredibly powerful.
