Loading content...
Every software system you've ever built probably shares a fundamental assumption: the database stores the current state of things. A user has an account balance of $500. An order is in 'shipped' status. An inventory count shows 47 units. But what if this assumption—treating current state as truth—is fundamentally limiting? What if the most powerful architectural insight is that events, not state, should be the source of truth?
This is the core insight of event sourcing: instead of storing what things are, we store what happened. Instead of overwriting a user's balance from $400 to $500, we append an event: FundsDeposited { amount: $100 }. The current balance is derived by replaying all events, not by reading a mutable field.
By the end of this page, you will understand the fundamental paradigm shift of event sourcing, why immutable event logs provide capabilities impossible with traditional CRUD systems, and how treating events as the authoritative record transforms how you design, debug, audit, and evolve distributed systems.
Traditional database systems give us a powerful abstraction: tables with rows that represent the current state of entities. When something changes, we UPDATE the row. This model is intuitive, widely understood, and works well for many applications. But it has a fundamental limitation that becomes increasingly painful at scale and in complex domains:
You lose history.
When you update a user's email from old@example.com to new@example.com, the old email is gone. When you change an order status from 'pending' to 'shipped', you've overwritten the previous state. The database tells you what is, not what happened.
Many teams attempt to fix the 'lost history' problem by adding audit log tables. But now you have two sources of truth that can diverge: the actual data and the audit log. The audit log becomes a second-class citizen—often incomplete, inconsistently structured, and rarely tested. Event sourcing makes the event log the only source of truth, eliminating this duality.
Event sourcing inverts the traditional data model. Instead of storing the current state and discarding how we got there, we store every event that occurred and derive current state by replaying those events.
Traditional CRUD model:
Account { id: 123, balance: 500, email: "user@example.com" }
Event sourcing model:
AccountCreated { id: 123, email: "user@example.com", timestamp: T1 }
FundsDeposited { id: 123, amount: 300, timestamp: T2 }
FundsDeposited { id: 123, amount: 200, timestamp: T3 }
To know the current balance, you replay events: 0 + 300 + 200 = 500.
| Aspect | Traditional CRUD | Event Sourcing |
|---|---|---|
| Data stored | Current state only | Complete history of events |
| UPDATE operation | Overwrites existing data | Appends new event, old data preserved |
| DELETE operation | Removes data permanently | Appends deletion event, data reconstructible |
| History | Lost (unless manually logged) | First-class citizen, always available |
| Temporal queries | Require separate time-series design | Naturally supported via replay |
| Audit compliance | Requires additional infrastructure | Built into the architecture |
| Storage growth | Relatively stable | Grows with every change |
| Read performance | Fast: read current state | Requires projection or caching |
The key insight: Events are immutable facts. OrderPlaced happened at 2:34 PM on January 5th. You cannot un-happen it. You can compensate for it (perhaps with OrderCancelled), but the original event remains a permanent part of the record.
This immutability is what gives event sourcing its power. Because events are never modified or deleted, you have:
Events in an event-sourced system are not arbitrary log messages. They are carefully designed domain events that capture business-meaningful occurrences. An event represents something that happened in the past—a fact that is immutable and complete.
A well-designed event includes:
OrderPlaced, PaymentReceived, InventoryAdjusted. Never use present or future tense.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// Base event structureinterface DomainEvent<T> { // Identity eventId: string; // Unique identifier for this event instance aggregateId: string; // Which entity this event belongs to aggregateType: string; // Type of aggregate (e.g., "Order", "Account") sequenceNumber: number; // Order within the aggregate's event stream // Timing occurredAt: Date; // When the event happened recordedAt: Date; // When the event was persisted // Type and Data eventType: string; // e.g., "OrderPlaced", "PaymentReceived" eventVersion: number; // Schema version for evolution payload: T; // The actual event data // Context metadata: { correlationId: string; // Links related events across services causationId: string; // The event or command that caused this userId?: string; // Who triggered this action traceId?: string; // Distributed tracing identifier };} // Concrete event exampleinterface OrderPlacedPayload { customerId: string; items: Array<{ productId: string; quantity: number; unitPrice: number; }>; shippingAddress: { street: string; city: string; country: string; postalCode: string; }; totalAmount: number; currency: string;} // Usageconst orderPlaced: DomainEvent<OrderPlacedPayload> = { eventId: "evt_1234567890", aggregateId: "order_abc123", aggregateType: "Order", sequenceNumber: 1, occurredAt: new Date("2024-01-15T14:34:00Z"), recordedAt: new Date("2024-01-15T14:34:00.123Z"), eventType: "OrderPlaced", eventVersion: 1, payload: { customerId: "cust_xyz789", items: [ { productId: "prod_001", quantity: 2, unitPrice: 29.99 }, { productId: "prod_002", quantity: 1, unitPrice: 49.99 }, ], shippingAddress: { street: "123 Main St", city: "Seattle", country: "US", postalCode: "98101", }, totalAmount: 109.97, currency: "USD", }, metadata: { correlationId: "corr_web_session_456", causationId: "cmd_place_order_789", userId: "user_john_doe", traceId: "trace_abc123xyz", },};Events should be named in past tense because they represent something that has already happened. Use domain language that business stakeholders understand: InvoiceSent (not SendInvoice), InventoryReserved (not ReserveInventory). The name should be specific enough that its meaning is unambiguous: CustomerAddressUpdated is clearer than CustomerModified.
In event sourcing, events are grouped into event streams by aggregate. An aggregate is a consistency boundary—a cluster of domain objects that must be modified atomically. Each aggregate has its own event stream, and events within a stream are strictly ordered.
Why aggregates matter:
When you append events to a stream, you typically use optimistic concurrency: you specify the expected current version, and the append fails if someone else has appended events in the meantime. This ensures atomic updates within an aggregate without global locking.
Loading an aggregate:
To work with an aggregate, you load all events from its stream and replay them to reconstruct current state. This is called rehydration.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
// Aggregate base classabstract class Aggregate<TState> { protected state: TState; private version: number = 0; private uncommittedEvents: DomainEvent<unknown>[] = []; constructor(private readonly aggregateId: string) { this.state = this.initialState(); } protected abstract initialState(): TState; protected abstract apply(event: DomainEvent<unknown>): void; // Rehydrate from event stream loadFromHistory(events: DomainEvent<unknown>[]): void { for (const event of events) { this.apply(event); this.version = event.sequenceNumber; } } // Apply a new event (during command handling) protected raiseEvent<T>(eventType: string, payload: T): void { const event: DomainEvent<T> = { eventId: generateUUID(), aggregateId: this.aggregateId, aggregateType: this.constructor.name, sequenceNumber: this.version + this.uncommittedEvents.length + 1, occurredAt: new Date(), recordedAt: new Date(), eventType, eventVersion: 1, payload, metadata: { correlationId: getCurrentCorrelationId(), causationId: getCurrentCommandId(), }, }; this.apply(event); this.uncommittedEvents.push(event); } getUncommittedEvents(): DomainEvent<unknown>[] { return [...this.uncommittedEvents]; } getVersion(): number { return this.version; }} // Concrete Order aggregateinterface OrderState { orderId: string; status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled'; items: OrderItem[]; totalAmount: number; paymentReceivedAt?: Date; shippedAt?: Date;} class OrderAggregate extends Aggregate<OrderState> { protected initialState(): OrderState { return { orderId: '', status: 'pending', items: [], totalAmount: 0, }; } protected apply(event: DomainEvent<unknown>): void { switch (event.eventType) { case 'OrderPlaced': const placed = event.payload as OrderPlacedPayload; this.state.orderId = event.aggregateId; this.state.status = 'pending'; this.state.items = placed.items; this.state.totalAmount = placed.totalAmount; break; case 'PaymentReceived': this.state.status = 'paid'; this.state.paymentReceivedAt = event.occurredAt; break; case 'OrderShipped': this.state.status = 'shipped'; this.state.shippedAt = event.occurredAt; break; case 'OrderCancelled': this.state.status = 'cancelled'; break; } } // Command handlers placeOrder(items: OrderItem[], shippingAddress: Address): void { if (this.state.orderId) { throw new Error('Order already exists'); } this.raiseEvent('OrderPlaced', { items, shippingAddress, totalAmount: calculateTotal(items) }); } receivePayment(amount: number, transactionId: string): void { if (this.state.status !== 'pending') { throw new Error('Order is not pending payment'); } if (amount < this.state.totalAmount) { throw new Error('Insufficient payment amount'); } this.raiseEvent('PaymentReceived', { amount, transactionId }); } ship(trackingNumber: string): void { if (this.state.status !== 'paid') { throw new Error('Order must be paid before shipping'); } this.raiseEvent('OrderShipped', { trackingNumber }); }}Keep aggregates small. Each aggregate should represent a single consistency boundary. If you find an aggregate with thousands of events or complex internal state, consider splitting it. Large aggregates lead to slow rehydration, high contention, and difficult evolution.
The fundamental operation in event sourcing is projection: transforming a stream of events into a useful state representation. This is also called folding because you're folding a sequence of events into a single value.
Mathematically, projection is a left fold over the event stream:
state = fold(applyEvent, initialState, events)
Where applyEvent(currentState, event) → newState.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// Pure projection functionfunction projectOrderState(events: DomainEvent<unknown>[]): OrderState { const initialState: OrderState = { orderId: '', status: 'pending', items: [], totalAmount: 0, }; return events.reduce((state, event) => { switch (event.eventType) { case 'OrderPlaced': { const payload = event.payload as OrderPlacedPayload; return { ...state, orderId: event.aggregateId, status: 'pending', items: payload.items, totalAmount: payload.totalAmount, }; } case 'PaymentReceived': return { ...state, status: 'paid', paymentReceivedAt: event.occurredAt }; case 'OrderShipped': return { ...state, status: 'shipped', shippedAt: event.occurredAt }; case 'OrderCancelled': return { ...state, status: 'cancelled' }; case 'ItemAdded': { const item = event.payload as OrderItem; const newItems = [...state.items, item]; return { ...state, items: newItems, totalAmount: calculateTotal(newItems), }; } case 'ItemRemoved': { const { productId } = event.payload as { productId: string }; const newItems = state.items.filter(i => i.productId !== productId); return { ...state, items: newItems, totalAmount: calculateTotal(newItems), }; } default: return state; // Unknown events are ignored (forward compatibility) } }, initialState);} // Time-travel: project state at a specific point in timefunction projectOrderStateAt( events: DomainEvent<unknown>[], asOfDate: Date): OrderState { const filteredEvents = events.filter(e => e.occurredAt <= asOfDate); return projectOrderState(filteredEvents);} // Usage examplesasync function demonstrateProjection() { const eventStore = getEventStore(); const orderId = 'order_123'; // Get current state const currentEvents = await eventStore.readStream(orderId); const currentState = projectOrderState(currentEvents); console.log('Current state:', currentState); // Get state as of last week const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const historicalState = projectOrderStateAt(currentEvents, lastWeek); console.log('State last week:', historicalState); // Answer: "What was the order total before the discount was applied?" const beforeDiscount = projectOrderStateAt( currentEvents, discountAppliedEvent.occurredAt ); console.log('Total before discount:', beforeDiscount.totalAmount);}Key properties of projections:
One of the superpowers of event sourcing is that you can build multiple different read models from the same event stream. An Order stream might power: (1) the customer-facing order status view, (2) the warehouse's picking list, (3) finance's revenue reports, and (4) analytics dashboards—all from the same events, each optimized for its use case.
The defining characteristic of event sourcing is immutability: events, once written, are never modified or deleted. This immutability is not just a technical constraint—it's a philosophical commitment with profound implications.
Handling 'corrections' without mutating events:
When business reality requires corrections (an invoice amount was wrong, a user's name was misspelled), event sourcing handles this through compensating events, not mutation:
InvoiceIssued { amount: 1000 } — Original event, immutableInvoiceCorrected { originalAmount: 1000, correctedAmount: 950, reason: "Discount not applied" } — CompensationThe history shows exactly what happened: an invoice was issued, then corrected. This is more valuable than a mutable log that only shows 950.
For GDPR 'right to erasure' requirements, event-sourced systems typically use crypto-shredding: personal data is encrypted with a per-user key, and 'deletion' means destroying the key. The events remain, but personal data becomes unrecoverable. This preserves audit integrity while satisfying legal requirements.
Event sourcing and event-driven architecture are related but distinct concepts. They're often confused because both involve 'events', but they serve different purposes.
| Aspect | Event Sourcing | Event-Driven Architecture |
|---|---|---|
| Primary purpose | Persistence pattern: how we store state | Communication pattern: how services interact |
| Events are | Internal to an aggregate/service | Messages between services |
| Events contain | All data needed to reconstruct state | Notifications or data relevant to subscribers |
| Events are used to | Rebuild state via projection | Trigger reactions in other services |
| Can exist without the other | Yes (single service can use ES internally) | Yes (services can be event-driven with CRUD storage) |
| Requires event store | Yes, always | Often yes, but not strictly required |
They work beautifully together:
In practice, these patterns are highly complementary:
This combination gives you both durable local history (event sourcing) and loose coupling between services (event-driven architecture).
We've covered the fundamental paradigm shift of event sourcing. Let's consolidate the key insights:
What's next:
Now that we understand why events should be the source of truth, we'll explore how to design and implement an event store—the specialized database that makes event sourcing possible. We'll cover storage models, partitioning strategies, ordering guarantees, and choosing between building and buying.
You now understand the foundational concept of event sourcing: treating events, not current state, as the source of truth. This paradigm shift enables audit trails, time-travel queries, and reproducible debugging—capabilities impossible with traditional CRUD systems. Next, we'll design the event store that makes this possible.