What you'll learn
Quick Answer
Event sourcing stores every state change as an immutable event in an append-only log, instead of storing just the current state. Current state is derived by replaying all events in order through a pure function. Appending a new event never rewrites history, so you can always recompute state as of any point in time by replaying a prefix of the log. The demo below appends bank-account events, derives a balance from all of them, then appends one more and recomputes without touching anything already stored.
Storing State vs. Storing What Happened
The conventional approach stores current state directly: an accounts table with a balance column, updated in place every time money moves. Event sourcing stores something different, every deposit and withdrawal as its own immutable record, appended to a log that's never edited or deleted. Current balance isn't stored anywhere; it's calculated.
That sounds like more work for a simpler question, and for a single balance lookup, it is. What it buys back: the full history of how you got there is never thrown away, because it was never overwritten in the first place. Every past state is reconstructible, not just the current one.
The demo below appends real events to an in-memory array and derives the balance by replaying them, actual executed code with actual printed output, not pseudocode.
The same idea applies well beyond bank balances — shopping cart contents, a document's edit history, or a game character's stats can all be modeled the same way, as a sequence of changes rather than a single mutable snapshot that overwrites what came before it.
Deriving State by Replay
function deriveState(eventLog) {
let state = { balance: 0, history: [] };
for (const event of eventLog) {
switch (event.type) {
case 'AccountOpened':
state = { balance: event.payload.openingBalance, history: [...state.history, 'opened'] };
break;
case 'FundsDeposited':
state = { balance: state.balance + event.payload.amount, history: [...state.history, 'deposit'] };
break;
case 'FundsWithdrawn':
state = { balance: state.balance - event.payload.amount, history: [...state.history, 'withdraw'] };
break;
}
}
return state;
}
After appending AccountOpened(1000), FundsDeposited(500), and FundsWithdrawn(200), calling deriveState(events) on the real, executed script gives:
{ balance: 1300, history: [ 'opened', 'deposit 500', 'withdraw 200' ] }
1000 + 500 − 200 = 1300, computed by replaying the log, not by reading a stored total anywhere. Each case in the switch returns a brand-new state object rather than mutating the previous one in place, which matters: since deriveState is a pure function of the event log, calling it twice on the same events always produces the same result, with no hidden state carried between calls.
Adding a New Event Without Touching History
Append one more event, FundsDeposited(300), and call deriveState again on the same array:
[APPEND] FundsDeposited {"amount":300}
{ balance: 1600,
history: [ 'opened', 'deposit 500', 'withdraw 200', 'deposit 300' ] }
1300 + 300 = 1600, and critically, the first three events were never touched to get there, the log grew from 3 entries to 4, and deriveState simply replayed all of them again from the start. Checking it directly after the run confirms it: events.length is 4, and the first event still matches { type: 'AccountOpened', payload: { openingBalance: 1000 } } exactly.
This is the core discipline event sourcing enforces: you never go back and edit a past record to reflect new information. You only ever append. If a deposit was recorded in error, you fix it by appending a compensating event, not by rewriting history. That discipline is what makes the log trustworthy as the single source of truth — nothing downstream ever has to wonder whether a past record was quietly changed after the fact.
The Free Feature: Time Travel
Because state is derived, not stored, asking "what was the balance after just the first two events" is a matter of replaying a shorter slice of the same log, no separate history table, no snapshot taken in advance:
const pastState = deriveState(events.slice(0, 2));
// { balance: 1500, history: [ 'opened', 'deposit 500' ] }
1000 + 500 = 1500, the balance exactly as it stood before the withdrawal and the second deposit ever happened, computed from data that was already there. A system storing only current balance has no way to answer that question after the fact unless it separately logged every change, which is precisely what event sourcing makes the primary record instead of an afterthought.
This is genuinely useful outside of curiosity: reconstructing what a customer's account looked like at a specific moment is a common support and audit request, and with event sourcing it costs nothing extra to answer, because the data needed to reconstruct it was never thrown away in the first place.
The Real Costs
Replaying every event from the beginning gets slow as the log grows, an account with ten years of transactions shouldn't replay ten years of events on every balance check. Production systems solve this with snapshots: periodically save the derived state, and on the next read, replay only the events appended after that snapshot.
There's also a schema problem: events already in the log can never be edited, so if FundsDeposited's shape needs to change five years from now, old events still have the old shape. Systems handle this with "upcasting", a small function that transforms an old event's shape into the new one during replay, so the reducer only ever has to understand the current format.
And a query problem: an append-only log is excellent for writes and terrible for "show me all accounts over ₹50,000", which is why event sourcing is so often paired with CQRS, projecting the log into a queryable read model instead of scanning it directly.
