Loading content...
Consider a bank account with a balance of $1,000. In traditional systems, that $1,000 is simply a number stored in a database column. But this single number hides an entire history: the initial deposit, salary payments, ATM withdrawals, online purchases, interest accruals, and fee deductions that together produced that balance.
When we store only current state, we lose the story of how we arrived there.
This loss has profound consequences. When a customer disputes a transaction, we scramble through fragmented logs. When auditors ask "what did this account look like on March 15th?", we cannot answer with certainty. When a bug corrupts balances, we have no definitive record to restore from. When regulations require us to prove why a system made a decision, we find ourselves unable to explain.
Event sourcing inverts this model entirely. Instead of storing the answer (current state), we store the complete derivation (all events). The answer becomes a function of that derivation—reproducible, auditable, and temporally queryable.
By the end of this page, you will understand the fundamental paradigm shift event sourcing represents, why events constitute a more powerful primary record than state, the immutability guarantees that make event sourcing trustworthy, and how this pattern changes the way we design distributed systems. You'll see why industry leaders like LinkedIn, Netflix, and financial institutions have adopted event sourcing for their most critical systems.
For decades, the dominant model for data persistence has been CRUD (Create, Read, Update, Delete). In this paradigm, we model our domain as entities with attributes, store the current values of those attributes in database rows, and modify those values as the system evolves. The database serves as the authoritative record of "what is true right now."
This approach has obvious appeal:
However, CRUD carries fundamental limitations that become critical in complex, distributed, or regulated systems.
| Limitation | Description | Business Impact |
|---|---|---|
| Information Destruction | Updates overwrite previous values; delete removes records entirely | Historical analysis impossible; compliance failures; dispute resolution blocked |
| Temporal Blindness | Cannot query 'what was the state on date X' without additional infrastructure | Audits require custom solutions; debugging race conditions extremely difficult |
| Intent Opacity | State captures 'what' but not 'why' or 'how' | System behavior cannot be explained; root cause analysis complicated |
| Integration Brittleness | Other systems must poll for changes or rely on fragile triggers | Tight coupling; missed events; synchronization failures |
| Recovery Complexity | Corruption requires either backup restoration or manual reconstruction | Data loss risk; extended downtime; inconsistent recovery states |
The Update Problem in Detail
Consider a user profile system. When a user changes their email address:
UPDATE users SET email = 'new@example.com' WHERE id = 42;
This single statement destroys information. The previous email? Gone. When was it changed? Unknown (unless we added separate audit logging). Who changed it? The application, but which code path? If this update was part of a larger operation that partially failed, can we identify which changes completed? In a CRUD system, answering these questions requires additional infrastructure layered on top—audit tables, change data capture, application-level logging. But these are band-aids over a fundamental architectural limitation.
State is a lossy compression of history. We've traded information for storage efficiency—a tradeoff that was reasonable when storage was expensive and analytical requirements were simpler, but increasingly problematic in modern systems.
CRUD appears simpler only when requirements are limited to 'show me the current state.' The moment you need history, audit trails, debugging capabilities, or event-driven integrations, CRUD becomes complex—requiring separate logging systems, change tracking tables, triggers, and reconciliation processes that often become more complex than event sourcing itself.
Event sourcing inverts the traditional model. Instead of storing current state and deriving events (through logging, CDC, or triggers), we store events and derive current state. Events become the source of truth; state becomes a read optimization.
An event is an immutable record of something that happened—a fact about the past that cannot be changed. Events are named in the past tense because they represent completed actions:
AccountOpened (not OpenAccount)FundsDeposited (not DepositFunds)OrderShipped (not ShipOrder)EmailAddressChanged (not ChangeEmailAddress)This distinction matters. Commands represent intentions that might fail; events represent facts that have already occurred. Events are never rejected, updated, or deleted—they simply are.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// Domain events are immutable facts about what happenedinterface DomainEvent { // Unique identifier for this specific event instance readonly eventId: string; // Type of event (discriminator for deserialization) readonly eventType: string; // Aggregate this event belongs to readonly aggregateId: string; readonly aggregateType: string; // Position in the aggregate's event stream readonly sequenceNumber: number; // When the event occurred readonly timestamp: Date; // The actual event data (varies by event type) readonly payload: Record<string, unknown>; // Optional metadata (causation, correlation, user, etc.) readonly metadata?: EventMetadata;} // Concrete event examplesinterface AccountOpened extends DomainEvent { readonly eventType: 'AccountOpened'; readonly payload: { accountNumber: string; customerId: string; accountType: 'checking' | 'savings'; initialDeposit: number; currency: string; };} interface FundsDeposited extends DomainEvent { readonly eventType: 'FundsDeposited'; readonly payload: { amount: number; currency: string; depositMethod: 'cash' | 'wire' | 'check' | 'transfer'; reference: string; };} interface FundsWithdrawn extends DomainEvent { readonly eventType: 'FundsWithdrawn'; readonly payload: { amount: number; currency: string; withdrawalMethod: 'atm' | 'wire' | 'check'; atmId?: string; reference: string; };} // Event metadata for traceabilityinterface EventMetadata { causationId: string; // Event that caused this event correlationId: string; // Business transaction ID userId?: string; // Who initiated the action userAgent?: string; // Client information ipAddress?: string; // Request origin}Event Characteristics
Well-designed events share several critical properties:
Immutability: Once recorded, events never change. This is fundamental—if events could be modified, we'd lose the trustworthy audit trail that makes event sourcing valuable.
Self-Describing: Events contain all information needed to understand what happened without external context. An event retrieved years later should be interpretable.
Ordered: Events within an aggregate (a consistency boundary) have a strict total order given by sequence numbers. This ordering is essential for correct state reconstruction.
Complete: The event stream contains every fact needed to reconstruct current state. Nothing relevant happens "off the books."
Business-Meaningful: Events represent domain concepts, not technical operations. CustomerUpgradedToGold is superior to CustomerTableRowUpdated.
Event sourcing events are rich, containing all relevant data. Event notifications (used in pub/sub messaging) can be thin, containing just identifiers and type. Don't conflate these patterns. In event sourcing, the event IS the data. In event notification, the event announces that data exists elsewhere.
The paradigm shift from state-first to event-first thinking is profound. Let's visualize this inversion to understand what changes and what remains constant.
The Mathematical Analogy
Consider the relationship between a function and its integral:
Event sourcing applies this principle to data:
Just as integration can be computed from any starting point with known initial conditions, current state can be rebuilt from events starting from any snapshot.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
// Current state is derived by applying all events in sequenceinterface BankAccountState { accountId: string; customerId: string; balance: number; accountType: 'checking' | 'savings'; status: 'active' | 'frozen' | 'closed'; lastActivity: Date;} // The fold/reduce operation that derives state from eventsfunction deriveAccountState(events: BankAccountEvent[]): BankAccountState | null { return events.reduce<BankAccountState | null>((state, event) => { switch (event.eventType) { case 'AccountOpened': // Handle initial event—creates the aggregate return { accountId: event.aggregateId, customerId: event.payload.customerId, balance: event.payload.initialDeposit, accountType: event.payload.accountType, status: 'active', lastActivity: event.timestamp, }; case 'FundsDeposited': if (!state) throw new Error('Cannot deposit to non-existent account'); return { ...state, balance: state.balance + event.payload.amount, lastActivity: event.timestamp, }; case 'FundsWithdrawn': if (!state) throw new Error('Cannot withdraw from non-existent account'); return { ...state, balance: state.balance - event.payload.amount, lastActivity: event.timestamp, }; case 'AccountFrozen': if (!state) throw new Error('Cannot freeze non-existent account'); return { ...state, status: 'frozen', lastActivity: event.timestamp, }; case 'AccountClosed': if (!state) throw new Error('Cannot close non-existent account'); return { ...state, status: 'closed', balance: 0, // Assuming closure requires zero balance lastActivity: event.timestamp, }; default: // Unknown event type—log warning but don't fail console.warn(`Unknown event type: ${(event as any).eventType}`); return state; } }, null);} // Temporal query: "What was the balance on March 15, 2024?"function getStateAtTime( events: BankAccountEvent[], asOf: Date): BankAccountState | null { const eventsUpToDate = events.filter(e => e.timestamp <= asOf); return deriveAccountState(eventsUpToDate);} // Example usageconst events: BankAccountEvent[] = [ { eventType: 'AccountOpened', timestamp: new Date('2024-01-01'), /* ... */ }, { eventType: 'FundsDeposited', timestamp: new Date('2024-01-15'), /* ... */ }, { eventType: 'FundsWithdrawn', timestamp: new Date('2024-02-01'), /* ... */ }, { eventType: 'FundsDeposited', timestamp: new Date('2024-03-01'), /* ... */ },]; // Current state: replay all eventsconst currentState = deriveAccountState(events); // Historical state: what was the balance on Feb 15?const feb15State = getStateAtTime(events, new Date('2024-02-15'));The immutability of events is not merely a technical constraint—it is the foundational property that enables every benefit event sourcing provides. Understanding why immutability matters, and how to maintain it, is essential.
Why Events Must Be Immutable
Trustworthy Audit Trail: Auditors, regulators, and dispute resolution processes require confidence that records weren't altered after the fact. Immutable events provide cryptographic-level assurance that what you see is what happened.
Reproducibility: If events could change, replaying the event stream could yield different states depending on when you replayed. Immutability guarantees that state reconstruction is deterministic.
Safe Distribution: Events can be safely replicated to other systems, cached, and archived because they won't change. Downstream systems don't need to poll for corrections.
Debugging Time Travel: When investigating production issues, you can replay events to reproduce exact historical states. Mutability would make this unreliable.
PaymentRefunded instead of deleting PaymentReceived). The original event remains as historical fact; the correction is a new fact that affects derived state.EventCorrection or domain-specific correction event (e.g., IncorrectChargeReversed). Include reference to the original event and explanation.Under pressure to 'fix' bad data quickly, teams sometimes corrupt their event store with direct modifications. Resist this urge absolutely. Once you modify events, you've lost the core value proposition. Every system that depends on event immutability—auditing, replay, debugging, projections—becomes unreliable. Use compensating events instead; they're more work upfront but preserve system integrity.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Scenario: Customer was charged incorrectly// Wrong approach: Update or delete the original event (NEVER DO THIS)// Correct approach: Add compensating events // Original event that contained an errorconst incorrectCharge: PaymentEvent = { eventId: 'evt-001', eventType: 'PaymentCharged', aggregateId: 'cust-123', sequenceNumber: 15, timestamp: new Date('2024-03-10T14:30:00Z'), payload: { amount: 999.99, // Error: should have been 99.99 currency: 'USD', description: 'Monthly subscription', },}; // Compensating event that logically reverses the mistakeconst refund: PaymentEvent = { eventId: 'evt-002', eventType: 'PaymentRefunded', aggregateId: 'cust-123', sequenceNumber: 16, timestamp: new Date('2024-03-11T09:00:00Z'), payload: { originalEventId: 'evt-001', refundAmount: 999.99, reason: 'Incorrect charge amount - should have been 99.99', initiatedBy: 'support-agent-456', }, metadata: { causationId: 'evt-001', correlationId: 'support-ticket-789', },}; // Corrected charge eventconst correctCharge: PaymentEvent = { eventId: 'evt-003', eventType: 'PaymentCharged', aggregateId: 'cust-123', sequenceNumber: 17, timestamp: new Date('2024-03-11T09:01:00Z'), payload: { amount: 99.99, // Correct amount currency: 'USD', description: 'Monthly subscription (corrected)', relatedRefund: 'evt-002', }, metadata: { correlationId: 'support-ticket-789', },}; // The event stream now tells the COMPLETE story:// 1. We charged $999.99 (mistakenly)// 2. We refunded $999.99 (acknowledging the error)// 3. We charged $99.99 (correctly)//// An auditor can see exactly what happened.// Replaying produces correct current state.// No information was destroyed.One of event sourcing's most powerful capabilities is temporal querying—the ability to answer questions about the past or to reconstruct the system's state at any historical point. This capability emerges naturally from the append-only event model.
Use Cases for Temporal Queries
| Query Pattern | Implementation | Performance Consideration |
|---|---|---|
| Point-in-Time State | Replay events up to target timestamp | Keep periodic snapshots to avoid full replay |
| State Difference | Reply to T1, then to T2; diff the states | Useful for understanding what changed |
| Event Slice | Filter events by time range and/or type | Direct query against event store |
| Trend Analysis | Replay at regular intervals, aggregate states | Materialized views can optimize this |
| Counterfactual | Replay with modified events or rules | Requires isolated replay environment |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
interface TemporalQueryService { // Get state at a specific point in time getStateAt<T>( aggregateId: string, timestamp: Date, reducer: (events: DomainEvent[]) => T ): Promise<T>; // Get all states at regular intervals (for trend analysis) getStateOverTime<T>( aggregateId: string, startDate: Date, endDate: Date, interval: Duration, reducer: (events: DomainEvent[]) => T ): Promise<Array<{ timestamp: Date; state: T }>>; // Compare state between two points getStateDiff<T>( aggregateId: string, t1: Date, t2: Date, reducer: (events: DomainEvent[]) => T, differ: (s1: T, s2: T) => StateDiff ): Promise<StateDiff>;} class EventStoreTemporalQueries implements TemporalQueryService { constructor(private eventStore: EventStore) {} async getStateAt<T>( aggregateId: string, timestamp: Date, reducer: (events: DomainEvent[]) => T ): Promise<T> { // Find the most recent snapshot before the target time const snapshot = await this.eventStore.getLatestSnapshotBefore( aggregateId, timestamp ); // Get events from snapshot (or beginning) to target time const events = await this.eventStore.getEvents({ aggregateId, fromSequence: snapshot?.sequenceNumber ?? 0, toTimestamp: timestamp, }); // If we have a snapshot, start from that state if (snapshot) { const newEvents = events.filter( e => e.sequenceNumber > snapshot.sequenceNumber ); return this.applyEventsToState(snapshot.state, newEvents, reducer); } // Otherwise replay from beginning return reducer(events); } async getStateOverTime<T>( aggregateId: string, startDate: Date, endDate: Date, interval: Duration, reducer: (events: DomainEvent[]) => T ): Promise<Array<{ timestamp: Date; state: T }>> { const results: Array<{ timestamp: Date; state: T }> = []; // Get all events in the time range once const allEvents = await this.eventStore.getEvents({ aggregateId, fromTimestamp: new Date(0), // From beginning toTimestamp: endDate, }); // Replay incrementally at each interval let currentTime = startDate; while (currentTime <= endDate) { const eventsUpToNow = allEvents.filter(e => e.timestamp <= currentTime); const state = reducer(eventsUpToNow); results.push({ timestamp: new Date(currentTime), state }); currentTime = addDuration(currentTime, interval); } return results; } // Example: Analyze how account balance changed over time async analyzeBalanceHistory(accountId: string): Promise<BalanceHistory> { const history = await this.getStateOverTime( accountId, new Date('2024-01-01'), new Date('2024-12-31'), { days: 1 }, // Daily snapshots events => deriveAccountState(events)?.balance ?? 0 ); return { dataPoints: history, min: Math.min(...history.map(h => h.state)), max: Math.max(...history.map(h => h.state)), average: history.reduce((sum, h) => sum + h.state, 0) / history.length, }; }}When production bugs occur, event sourcing lets you replay the exact sequence of events that led to the error. This is dramatically more powerful than log analysis because you can literally reproduce the system state, step through the event application, and observe exactly where things went wrong. Combined with event replay in a development environment, this capability can reduce debugging time from days to hours.
Event sourcing provides unique advantages in distributed system architectures. The append-only nature of events, combined with their immutability, solves several classic distributed systems problems.
Natural Integration Pattern
In traditional systems, integrating with downstream systems requires either:
With event sourcing, the event stream itself becomes the integration point. Downstream systems subscribe to events, consuming the same immutable record that the originating system uses. This is not a secondary log or a derived feed—it's the actual source of truth.
Event Sourcing vs. Event-Driven Architecture
These terms are often conflated but represent distinct concepts:
Event-Driven Architecture (EDA) is about communication patterns—systems notifying each other through events rather than direct calls. The events might be transient, stored temporarily in message brokers.
Event Sourcing is about data persistence—using events as the authoritative record of state. Events must be stored permanently and are the source of truth.
You can have EDA without event sourcing (using traditional databases but communicating via messages). You can have event sourcing without full EDA (using events for persistence but still making synchronous calls between services). The combination of both creates particularly powerful architectures, but understanding the distinction is important for design decisions.
Event sourcing is not academic theory—it's production reality at massive scale. LinkedIn's Kafka platform is fundamentally an event sourcing infrastructure. Netflix uses event sourcing for many critical services. Financial institutions use it for trade capture and regulatory reporting. The pattern has been battle-tested at scales measured in millions of events per second.
Some domains are naturally event-oriented—the real-world processes they model are sequences of things that happen, not static states that get updated. In these domains, event sourcing isn't an architectural choice; it's a recognition of how the domain actually works.
Naturally Event-Oriented Domains
| Domain | Natural Events | Why State-First Feels Wrong |
|---|---|---|
| Financial Services | Transactions, transfers, trades, payments | A ledger is literally a sequence of entries. Summarizing to a 'balance' loses critical information. |
| Healthcare | Prescriptions, diagnoses, treatments, vitals | Medical history matters as much as current state. Changes must be explained. |
| Supply Chain | Orders, shipments, customs, deliveries | Tracking requires knowing what happened at each step, not just current location. |
| Legal/Contracts | Agreements, amendments, signatures, disputes | The sequence of legal actions is the record. You can't 'update' a signed contract. |
| Gaming | Actions, achievements, purchases, level-ups | Player history enables analytics, fairness checks, and replay features. |
| IoT/Sensors | Readings, alerts, calibrations, maintenance | Sensor data is inherently time-series. Aggregation loses signal. |
Signs That Event Sourcing Is a Good Fit
Domain experts think in events: When stakeholders naturally say "when X happens, then Y" rather than "X is set to value V", the domain is event-oriented.
Audit requirements: Any domain where "who did what, when, and why" must be answerable is a candidate.
Temporal questions matter: If the business needs to answer historical questions, event sourcing provides this intrinsically.
Integration-heavy: Systems that must notify many downstream consumers benefit from events as the integration mechanism.
Complex state transitions: When entities go through complex lifecycles with many valid transitions, events capture the actual path taken.
Undo/replay requirements: Applications needing undo functionality, replay capabilities, or "what-if" analysis fit naturally.
Event sourcing often improves domain modeling even beyond the persistence benefits. Thinking about events forces you to capture domain behavior explicitly. Instead of vague 'status changed to active', you have 'AccountActivatedAfterKYCVerification'. This precision reveals business rules and edge cases that state-focused models often miss.
We've established the foundational understanding of event sourcing—why it exists, what paradigm shift it represents, and the principles that make it work. Let's consolidate these key concepts:
What's Next:
Understanding that events are the source of truth is the conceptual foundation. The next page dives into the practical engineering: Event Store Design. We'll explore how to persist events durably, how to structure event streams, how to handle concurrency and consistency, and the architectural patterns that make event stores performant at scale. The theory becomes implementation.
You now understand the paradigm shift event sourcing represents—from storing mutable state to storing immutable events. You've seen why this inversion provides audit trails, temporal queries, debugging capabilities, and integration patterns that traditional persistence cannot match. Next, we'll engineer the event store that makes this possible.