Loading content...
Every software application manages state. When a user updates their profile, places an order, or transfers money, the application must persist these changes. For decades, the dominant approach has been straightforward: store the current state of entities in a database. When something changes, overwrite the old value with the new one.
But what if this approach—so intuitive and widespread—is fundamentally limiting? What if there's a way to persist data that gives you complete historical visibility, natural audit trails, temporal queries, and the ability to reconstruct any past state of your system?
This is the promise of Event Sourcing—an architectural pattern that inverts our traditional thinking about persistence. Instead of storing what data looks like now, event sourcing stores what happened to the data. The current state becomes a derived value, computed from an immutable sequence of events.
By the end of this page, you will understand: (1) what event sourcing is and how it differs from traditional CRUD persistence, (2) the core mental model of events as the system of record, (3) the fundamental concepts and terminology of event sourcing, and (4) real-world scenarios where event sourcing provides significant advantages over state-based persistence.
To understand event sourcing, we must first crystallize our understanding of the conventional approach it aims to replace. In traditional state-based persistence (often called CRUD—Create, Read, Update, Delete), we store the current state of entities directly in our database.
The mental model is simple:
Consider a simple bank account. In traditional persistence, we have a database row that might look like:
12345678910111213141516171819202122232425
-- Traditional account representationCREATE TABLE accounts ( id UUID PRIMARY KEY, account_holder VARCHAR(255) NOT NULL, balance DECIMAL(19, 4) NOT NULL, currency VARCHAR(3) NOT NULL, status VARCHAR(20) NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL); -- Current state of account #12345-- | id | account_holder | balance | currency | status | updated_at |-- |--------|----------------|----------|----------|--------|---------------------|-- | 12345 | Alice Johnson | 5000.00 | USD | ACTIVE | 2024-01-15 14:30:00 | -- When a transfer occurs (withdraw $500):UPDATE accounts SET balance = 4500.00, updated_at = NOW() WHERE id = '12345'; -- The previous balance of $5000 is now GONE-- We have no idea how Alice got to this balance-- We cannot answer: "What was the balance on January 10th?"-- We cannot audit: "Who withdrew money and when?"This approach has served us well. It's intuitive, performant for simple queries, and well-supported by virtually all databases and ORMs. However, it has fundamental limitations:
Information Loss: Every update destroys information. The previous value, the reason for change, the context—all gone.
No Natural History: Answering "What was the state at time T?" requires either expensive audit tables or temporal database features.
Debugging Difficulty: When something goes wrong, reconstructing what happened requires correlating logs, database snapshots, and guesswork.
Audit Compliance Challenges: Regulatory requirements often demand complete audit trails—adding these after the fact is expensive and error-prone.
The most subtle but profound problem with state-based persistence is that UPDATE and DELETE operations are destructive. Every time you overwrite data, you're making an irreversible decision about what's important (recent state) versus what's not (previous state). Event sourcing challenges this assumption entirely.
Event sourcing takes a fundamentally different approach. Instead of storing the current state, we store an immutable, append-only sequence of events that represent everything that has happened to an entity. The current state is derived—computed by replaying these events from the beginning.
The mental model shifts:
For the same bank account, event sourcing looks dramatically different:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
// Event definitions - immutable records of what happenedinterface AccountEvent { eventId: string; // Unique identifier for the event aggregateId: string; // The account ID this event belongs to eventType: string; // The type of event timestamp: Date; // When this event occurred version: number; // Sequence number within the aggregate payload: unknown; // Event-specific data metadata: { // Contextual information causationId: string; // What caused this event correlationId: string; // Request/transaction correlation userId: string; // Who triggered this event };} // Concrete event types for a bank accounttype AccountOpened = { eventType: 'AccountOpened'; payload: { accountHolder: string; initialDeposit: number; currency: string; };}; type MoneyDeposited = { eventType: 'MoneyDeposited'; payload: { amount: number; source: string; reference: string; };}; type MoneyWithdrawn = { eventType: 'MoneyWithdrawn'; payload: { amount: number; destination: string; reference: string; };}; // The event stream for account #12345:const accountEventStream = [ { eventId: "evt-001", aggregateId: "12345", eventType: "AccountOpened", timestamp: new Date("2024-01-01T09:00:00Z"), version: 1, payload: { accountHolder: "Alice Johnson", initialDeposit: 1000.00, currency: "USD" }, metadata: { causationId: "cmd-001", correlationId: "req-001", userId: "alice" } }, { eventId: "evt-002", aggregateId: "12345", eventType: "MoneyDeposited", timestamp: new Date("2024-01-05T14:30:00Z"), version: 2, payload: { amount: 2500.00, source: "Salary", reference: "JAN-SALARY" }, metadata: { causationId: "cmd-002", correlationId: "req-002", userId: "payroll-system" } }, { eventId: "evt-003", aggregateId: "12345", eventType: "MoneyDeposited", timestamp: new Date("2024-01-10T11:00:00Z"), version: 3, payload: { amount: 2000.00, source: "Transfer", reference: "TRF-98765" }, metadata: { causationId: "cmd-003", correlationId: "req-003", userId: "bob" } }, { eventId: "evt-004", aggregateId: "12345", eventType: "MoneyWithdrawn", timestamp: new Date("2024-01-15T14:30:00Z"), version: 4, payload: { amount: 500.00, destination: "ATM-Withdrawal", reference: "ATM-54321" }, metadata: { causationId: "cmd-004", correlationId: "req-004", userId: "alice" } }]; // Current balance can now be COMPUTED:// $1,000 + $2,500 + $2,000 - $500 = $5,000// But we also know EVERYTHING about how we got here!Notice what we've gained:
Complete History: We know every deposit, every withdrawal, every detail of every transaction. Nothing is lost.
Temporal Queries: "What was the balance on January 8th?" Just replay events up to that date: $1,000 + $2,500 = $3,500.
Natural Audit Trail: Every event includes who did it, when, and why. This isn't an afterthought—it's the primary storage.
Debugging Power: When something's wrong, we can replay events, step by step, to understand exactly what happened.
Business Intelligence: The event stream is a goldmine. We can answer questions we never anticipated: "What's our average deposit amount?" "How many deposits come from salary vs. transfers?" "What time of day do most withdrawals happen?"
Before diving deeper, let's establish precise definitions for the key concepts in event sourcing. Clear terminology is essential because terms like "event" are overloaded in software engineering.
| Term | Definition | Example |
|---|---|---|
| Event | An immutable record of something that happened in the past. Always named in past tense. Cannot be changed or deleted. | OrderPlaced, PaymentReceived, ItemShipped |
| Event Store | The database or storage system that persists events. Optimized for append-only writes and sequential reads. | EventStoreDB, Kafka (for streaming), Postgres with event tables |
| Aggregate | A cluster of domain objects treated as a single unit for data changes. Events belong to aggregates. | An Order with its OrderItems, or an Account with its transactions |
| Event Stream | The ordered sequence of all events for a single aggregate instance. Each aggregate has its own stream. | All events for Order #12345 in chronological order |
| Projection | A read-optimized view derived from events. Rebuilt by replaying events. | A dashboard showing total sales by product category |
| Snapshot | A performance optimization that captures aggregate state at a point in time, reducing replay overhead. | Account state after event #1000, avoiding replay of 1000 events |
| Command | A request to perform an action. Commands are validated against current state and may result in events. | PlaceOrder, DepositMoney, CancelSubscription |
| Rehydration | The process of reconstructing an aggregate's current state by replaying its event stream. | Loading Account #12345 by applying all its events from the store |
Commands express intent and can be rejected ("I want to withdraw $500"). Events express facts and cannot be undone ("$500 was withdrawn"). Commands use imperative mood: PlaceOrder. Events use past tense: OrderPlaced. This distinction is fundamental—events are irrefutable history, commands are requests that may or may not succeed.
The relationship flow:
User Intent → Command → Validation → Event(s) → State Change
TransferMoney { from: alice, to: bob, amount: 100 }MoneyTransferred { from: alice, to: bob, amount: 100 }Understanding event sourcing requires grasping how fundamentally it differs from traditional approaches. Let's compare the two paradigms across several dimensions:
The storage inversion:
In traditional systems, the database stores the current state (primary) and the history is derived (audit logs, change data capture, temporal tables).
In event sourcing, the events are primary (immutable history) and the current state is derived (computed from events).
This inversion has profound implications. Making history primary means it's always available, always accurate, and never an afterthought. Making current state derived means we can create multiple views optimized for different query patterns.
Event sourcing mirrors how accountants have worked for centuries. A ledger records individual transactions (debits and credits)—never overwritten, only appended. The current balance is always a derived value: the sum of all entries. If the balance seems wrong, you don't guess—you audit the ledger. This isn't a new idea; it's applying time-tested financial wisdom to software.
Let's work through a complete example to solidify our understanding. We'll implement a shopping cart using event sourcing, showing the full lifecycle from commands to events to state reconstruction.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
// ============================================================// EVENT DEFINITIONS// Events are immutable records of what happened// ============================================================ interface BaseEvent { eventId: string; aggregateId: string; // Cart ID aggregateType: 'ShoppingCart'; timestamp: Date; version: number;} interface CartCreated extends BaseEvent { type: 'CartCreated'; payload: { customerId: string; createdAt: Date; };} interface ItemAdded extends BaseEvent { type: 'ItemAdded'; payload: { productId: string; productName: string; quantity: number; unitPrice: number; };} interface ItemRemoved extends BaseEvent { type: 'ItemRemoved'; payload: { productId: string; quantity: number; };} interface ItemQuantityChanged extends BaseEvent { type: 'ItemQuantityChanged'; payload: { productId: string; oldQuantity: number; newQuantity: number; };} interface CartCheckedOut extends BaseEvent { type: 'CartCheckedOut'; payload: { orderId: string; totalAmount: number; itemCount: number; };} interface CartAbandoned extends BaseEvent { type: 'CartAbandoned'; payload: { reason: 'timeout' | 'user_cleared' | 'session_expired'; lastActiveAt: Date; };} type CartEvent = | CartCreated | ItemAdded | ItemRemoved | ItemQuantityChanged | CartCheckedOut | CartAbandoned; // ============================================================// AGGREGATE STATE// Derived from replaying events// ============================================================ interface CartItem { productId: string; productName: string; quantity: number; unitPrice: number;} interface CartState { cartId: string; customerId: string; status: 'active' | 'checked_out' | 'abandoned'; items: Map<string, CartItem>; createdAt: Date; version: number;} // ============================================================// STATE RECONSTRUCTION// The "apply" function that turns events into state// ============================================================ function createInitialState(): CartState { return { cartId: '', customerId: '', status: 'active', items: new Map(), createdAt: new Date(0), version: 0, };} function applyEvent(state: CartState, event: CartEvent): CartState { switch (event.type) { case 'CartCreated': return { ...state, cartId: event.aggregateId, customerId: event.payload.customerId, createdAt: event.payload.createdAt, status: 'active', version: event.version, }; case 'ItemAdded': { const newItems = new Map(state.items); const existing = newItems.get(event.payload.productId); if (existing) { newItems.set(event.payload.productId, { ...existing, quantity: existing.quantity + event.payload.quantity, }); } else { newItems.set(event.payload.productId, { productId: event.payload.productId, productName: event.payload.productName, quantity: event.payload.quantity, unitPrice: event.payload.unitPrice, }); } return { ...state, items: newItems, version: event.version, }; } case 'ItemRemoved': { const newItems = new Map(state.items); const existing = newItems.get(event.payload.productId); if (existing && existing.quantity <= event.payload.quantity) { newItems.delete(event.payload.productId); } else if (existing) { newItems.set(event.payload.productId, { ...existing, quantity: existing.quantity - event.payload.quantity, }); } return { ...state, items: newItems, version: event.version, }; } case 'ItemQuantityChanged': { const newItems = new Map(state.items); const existing = newItems.get(event.payload.productId); if (existing) { newItems.set(event.payload.productId, { ...existing, quantity: event.payload.newQuantity, }); } return { ...state, items: newItems, version: event.version, }; } case 'CartCheckedOut': return { ...state, status: 'checked_out', version: event.version, }; case 'CartAbandoned': return { ...state, status: 'abandoned', version: event.version, }; default: return state; }} // ============================================================// REHYDRATION// Reconstruct current state from event stream// ============================================================ function rehydrate(events: CartEvent[]): CartState { return events.reduce(applyEvent, createInitialState());} // ============================================================// TEMPORAL QUERIES// Get state at any point in time// ============================================================ function getStateAt(events: CartEvent[], targetTime: Date): CartState { const eventsUpToTime = events.filter(e => e.timestamp <= targetTime); return rehydrate(eventsUpToTime);} // ============================================================// EXAMPLE USAGE// ============================================================ const cartEvents: CartEvent[] = [ { eventId: 'e1', aggregateId: 'cart-001', aggregateType: 'ShoppingCart', timestamp: new Date('2024-01-15T10:00:00Z'), version: 1, type: 'CartCreated', payload: { customerId: 'cust-123', createdAt: new Date('2024-01-15T10:00:00Z') } }, { eventId: 'e2', aggregateId: 'cart-001', aggregateType: 'ShoppingCart', timestamp: new Date('2024-01-15T10:05:00Z'), version: 2, type: 'ItemAdded', payload: { productId: 'prod-A', productName: 'Widget', quantity: 2, unitPrice: 29.99 } }, { eventId: 'e3', aggregateId: 'cart-001', aggregateType: 'ShoppingCart', timestamp: new Date('2024-01-15T10:07:00Z'), version: 3, type: 'ItemAdded', payload: { productId: 'prod-B', productName: 'Gadget', quantity: 1, unitPrice: 49.99 } }, { eventId: 'e4', aggregateId: 'cart-001', aggregateType: 'ShoppingCart', timestamp: new Date('2024-01-15T10:10:00Z'), version: 4, type: 'ItemRemoved', payload: { productId: 'prod-A', quantity: 1 } },]; // Current state: 1 Widget ($29.99) + 1 Gadget ($49.99) = $79.98const currentState = rehydrate(cartEvents);console.log('Current items:', [...currentState.items.values()]); // State at 10:06 AM: 2 Widgets ($59.98), no Gadgets yetconst pastState = getStateAt(cartEvents, new Date('2024-01-15T10:06:00Z'));console.log('State at 10:06:', [...pastState.items.values()]);This example demonstrates several key characteristics of event sourcing:
Events are specific and meaningful: Each event type captures exactly what happened with all relevant context.
State is derived: The rehydrate function computes current state by applying each event in sequence.
Temporal queries are natural: Getting historical state is just replaying fewer events—no special infrastructure needed.
Events are the foundation: The CartState is a convenience; the event stream is the truth.
Event sourcing isn't universally superior to state-based persistence—it's a specialized tool that excels in specific contexts. Understanding when it provides significant value is crucial for making sound architectural decisions.
| Domain | Event Sourcing Fit | Key Drivers |
|---|---|---|
| Banking & Finance | Excellent | Regulatory audit trails, transaction history, fraud detection |
| E-Commerce Orders | Good | Order lifecycle tracking, support for status inquiries, analytics |
| Healthcare Records | Excellent | Complete patient history, legal requirements, temporal queries |
| Gaming (MMO) | Good | Player action replay, anti-cheat forensics, event-driven mechanics |
| Simple CRUD Apps | Poor | Overhead not justified, no audit needs, simple query patterns |
| Content Management | Moderate | Version history useful but often simpler solutions suffice |
| Real-time Dashboards | Good | Projections for read optimization, event streaming for updates |
| IoT / Telemetry | Excellent | Naturally append-only, time-series analysis, device state reconstruction |
Event sourcing adds significant complexity compared to traditional CRUD. It requires new mental models, specialized infrastructure, careful event schema design, and thoughtful projection strategies. A simple contact form doesn't need event sourcing. Choose it when the benefits clearly outweigh the costs.
We've covered the foundational concepts of event sourcing. Let's consolidate the key insights:
What's next:
Now that we understand what event sourcing is, we'll explore its philosophical foundation: events as the source of truth. This deeper examination will clarify why treating events as primary—and current state as derived—leads to powerful architectural properties.
You now understand the core concept of event sourcing: storing an immutable sequence of events rather than mutable current state. Next, we'll explore why events serve as the authoritative source of truth and how this inversion enables powerful capabilities.