Event Sourcing Explained: Storing State as a Sequence of Events
A normal database row only tells you what's true right now. Event sourcing keeps every change that ever happened, so "what's true now" becomes something you compute, not something you overwrite.
Published July 6, 2026In a conventional schema, updating a bank account balance means running UPDATE accounts SET balance = 150 WHERE id = 7, and the previous balance is simply gone, overwritten. Event sourcing stores the individual facts instead: AccountOpened, Deposited(100), Deposited(75), Withdrew(25). The current balance, 150, isn't stored anywhere directly — it's derived by replaying every event for that account, in order, starting from a balance of zero.
events = [
{ type: "AccountOpened" },
{ type: "Deposited", amount: 100 },
{ type: "Deposited", amount: 75 },
{ type: "Withdrew", amount: 25 },
]
function currentBalance(events) {
return events.reduce((balance, e) => {
if (e.type === "Deposited") return balance + e.amount;
if (e.type === "Withdrew") return balance - e.amount;
return balance;
}, 0);
}
The events are the source of truth; the balance is a projection computed from them. That inversion — store the history, derive the present — is the entire idea, and everything else about event sourcing follows from it.
What you get that a normal schema doesn't give you
A complete, permanent audit trail falls out for free: you can answer "what sequence of actions led to this account being overdrawn" by literally reading the events in order, not by piecing it together from application logs that may or may not have captured the relevant detail. You can also build new projections retroactively — if next year you decide you want a "largest single withdrawal per month" report that nobody thought to track when the system was built, you replay the existing event history through a new projection function and get accurate historical numbers, something that's simply impossible if the old detail was already overwritten by later updates in a conventional schema.
Replaying from the beginning doesn't scale forever
An account with ten events replays in microseconds. An account, or an aggregate, with two million events does not — recomputing current state by replaying the entire history on every read becomes the bottleneck. The standard fix is snapshotting: periodically persist the computed state itself (say, every thousand events, or once a day), so a later read only needs to load the most recent snapshot and replay whatever events happened after it, rather than the full history from event one.
Events are immutable, which changes how you handle mistakes
You cannot edit or delete a past event without breaking the audit trail that's the whole point of the pattern — and in many domains, doing so is a compliance problem, not just a design preference. If Deposited(75) should never have happened, the fix is a new, compensating event: DepositReversed(75), appended after it, not a rewrite of history. This means the application logic for every projection has to be written knowing that corrections arrive as new events layered on top, not as edits to old ones, which takes real discipline compared to just fixing a row.
Where it earns its complexity, and where it doesn't
Domains where the history itself has business value — financial ledgers, inventory movements, anything with regulatory audit requirements, or systems where "how did we get here" is a question users actually ask — are where event sourcing pays for itself. A simple settings table or a user's display name preference gains nothing from being event-sourced; nobody needs the full history of every time someone changed their avatar, and treating it as an event stream just adds replay and projection machinery around a fact that a single mutable field would represent perfectly well. Reach for event sourcing when the history is the product, not by default.
Versioning events once they're already in production
A schema you can freely alter is one of the quiet luxuries of a normal mutable row, and event sourcing gives it up. Once an event type has been written and stored, you generally can't go back and change its shape, because old, already-persisted events of that type still need to be readable by whatever code replays them later. Adding an optional field is usually safe, since older events simply lack it and replay code can supply a default. Renaming a field or changing its meaning is not safe without a migration step, typically an "upcaster" that reads an old event shape off disk and translates it into the current shape in memory before handing it to the projection logic, so the projection code only ever has to understand the latest shape rather than every historical variant it might encounter.
Testing an event-sourced system looks different too
Because behavior is defined by "given this sequence of past events, and this new command, what event should be produced," tests naturally take the shape of given-when-then: given a list of prior events reconstructing some state, when a particular command arrives, then a specific new event (or a specific rejection) should result. This tends to produce very direct, readable tests of business rules, since there's no need to first set up a database row in some intermediate state — the prior events themselves describe exactly how that state came to exist, which is often clearer for reasoning about edge cases like "what should happen if someone tries to withdraw from an account that was already closed three events ago."