Build an Event-Sourced Account Ledger
A complete event-sourced ledger: immutable events, aggregate replay, optimistic concurrency, projections, and a verified balance invariant.
Project goal
Model an account as a stream of immutable events, rebuild the balance by replay, and reject writes based on a stale version so concurrent transfers stay consistent.
Final features
- Event store
- Aggregate replay
- Optimistic append
- Projection
- Invariant check
Architecture
Commands load the aggregate by replaying its events, validate the business rule, and append new events with an expected version. A projection consumes events to maintain a queryable balance table. The version check makes concurrent appends fail instead of interleaving.
Prerequisites
- Node.js 20 LTS or later
- PostgreSQL 15+
- Familiarity with event sourcing basics
Dependencies
- pg ^8.12.0 — Event store and projection tables
- express ^4.21.0 — Command endpoints
Directory structure
event-ledger/
├── src/aggregate.ts
├── src/append.ts
├── src/projection.ts
├── src/commands.ts
├── tests/ledger.test.ts
└── package.json
Phases
1. Event store
Append events with a stream id and a monotonic version.
CREATE TABLE events (
stream_id text, version int, type text, payload jsonb,
PRIMARY KEY (stream_id, version));
Expected behavior: A unique constraint on stream plus version prevents history rewrites.
2. Aggregate replay
Fold events into the current state.
let balance = 0;
for (const e of events) balance += e.type === 'deposited' ? e.amount : -e.amount;
Expected behavior: Replay makes the state a pure function of history.
3. Optimistic append
Insert with the expected version and let the constraint reject races.
INSERT INTO events(stream_id, version, type, payload)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING RETURNING version;
Expected behavior: Concurrency control lives in the database, not in a read-then-write gap.
4. Projection
Consume events to update a read-model balance keyed by account.
await db.upsertBalance(accountId, balance);
Expected behavior: Reads should not replay the whole stream every time.
5. Invariant check
Assert the projected balance equals the replayed balance for a sampled account.
assert.equal(projected.balance, replayBalance(events));
Expected behavior: A projection bug silently corrupts reported balances.
Testing
Apply a sequence of commands and assert the replayed balance. Fire two concurrent withdrawals that together exceed the balance and assert exactly one succeeds.
Troubleshooting
Balance drifts from the projection
An event was appended outside the command path and the projection skipped it; funnel all writes through commands.
Concurrent transfers both succeed
The append used an auto-incremented version instead of the expected one, so races did not conflict.
Deployment
Treat the event store as the source of truth and the projection as disposable; rebuild it by replay. Back up the event store with point-in-time recovery.
Known limitations
- No snapshots; replay cost grows with stream length.
- A single projection runner, not a consumer group.
- Schema versioning of events is not implemented.
Project overview
An account balance is easy to store and hard to explain. This build models the account as a stream of immutable events, so the balance is derived rather than authoritative, and any past state can be reconstructed by replay.
You will append events that describe what happened, rebuild the aggregate by replaying its stream, guard each append with an expected version so concurrent writes fail instead of interleaving, and maintain a projection for queries.
The version check is what makes concurrency safe without a lock: two withdrawals that both read the same balance cannot both append, because the second presents a stale expected version and is rejected.
The projection is disposable by design. If it is lost or wrong, it is rebuilt from the event store, which keeps a query-optimized view from ever becoming a second source of truth.
Each phase builds one guarantee: the store, replay, optimistic append, the projection, and the invariant check, so the ledger is correct at every stage.
The build suits developers who have debugged a balance by comparing backups and want a model where the history itself is the authority. It is more code than a mutable row on purpose, because the extra code is the explanation.
Because the store is append-only, a mistake is corrected by writing another event rather than by fixing history, and that constraint is what makes the ledger auditable years later.
What you will build
The ledger provides five capabilities, each with a test that drives commands and inspects state:
- Event store
- Aggregate replay
- Optimistic append
- Projection
- Invariant check
An append-only event store preserves history; aggregate replay derives the current state; an expected-version append keeps concurrent writes consistent; a projection serves fast queries; and an invariant check catches impossible states before they are committed.
The design accepts more storage and more code in exchange for a defensible answer to why the balance is what it is. That trade is the entire reason to choose event sourcing over a mutable row.
Architecture and data model
Commands load the aggregate by replaying its events, validate the business rule, and append new events with an expected version. A projection consumes events to maintain a queryable balance table. The version check makes concurrent appends fail instead of interleaving.
Events are appended to a per-aggregate stream with a version. Loading the aggregate replays its events in order and folds them into state, and every command is validated against that state before producing new events.
Appends carry the version the command observed; if the stored version has moved, the append is rejected and the command retries against fresh state. This makes lost updates impossible without a database lock.
A projection subscribes to new events and maintains a balance table for queries, checked against a replay in tests. Treating the projection as derived means a bug in it is a rebuild rather than a data-loss incident.
Because events are immutable, corrections are expressed as compensating events rather than edits. That property is what makes the history trustworthy, and it is why the store is append-only with no update path.
The aggregate boundary is chosen small, one account at a time, so replay stays cheap and the optimistic version check contends only on the account under change rather than on the whole ledger. A pure fold turns a list of events into state, so the same list always yields the same state and any historical balance is reproducible in a test.
Prerequisites and dependencies
You need a database with transactional appends and a way to run a projection consumer alongside the write path.
- Node.js 20 LTS or later
- PostgreSQL 15+
- Familiarity with event sourcing basics
The dependencies cover the event store, the projection, and the tests:
pg(^8.12.0) — Event store and projection tablesexpress(^4.21.0) — Command endpoints
pnpm init
pnpm add pg express
pnpm add -D typescript tsx vitest
Pin the event schema and include a version field with every event, so a future shape change can be handled explicitly rather than breaking replay of old streams. Keep the store append-only at the grant level.
Directory structure
Separate the event store, aggregate replay, optimistic append, the projection, and invariant checks so each is tested against a disposable stream:
event-ledger/
├── src/aggregate.ts
├── src/append.ts
├── src/projection.ts
├── src/commands.ts
├── tests/ledger.test.ts
└── package.json
Phase 1: Event store
Append events with a stream id and a monotonic version.
CREATE TABLE events (
stream_id text, version int, type text, payload jsonb,
PRIMARY KEY (stream_id, version));
A unique constraint on stream plus version prevents history rewrites.
Store events append-only, keyed by aggregate id and version, with a payload and a type tag. An update route would defeat the model, so the store exposes append and read and nothing else.
Phase 2: Aggregate replay
Fold events into the current state.
let balance = 0;
for (const e of events) balance += e.type === 'deposited' ? e.amount : -e.amount;
Replay makes the state a pure function of history.
Rebuild state by replaying events in version order through a fold function. Keeping the fold pure makes replay deterministic and lets tests assert a state from a fixed event list.
Phase 3: Optimistic append
Insert with the expected version and let the constraint reject races.
INSERT INTO events(stream_id, version, type, payload)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING RETURNING version;
Concurrency control lives in the database, not in a read-then-write gap.
Append with the expected version and fail on mismatch, so two concurrent commands cannot both succeed against stale state. This single check replaces a lock and is far easier to reason about.
Phase 4: Projection
Consume events to update a read-model balance keyed by account.
await db.upsertBalance(accountId, balance);
Reads should not replay the whole stream every time.
Maintain a projection from the event stream for queries, and verify it against a replay in tests. A projection that cannot be rebuilt is a second source of truth wearing a disguise.
Phase 5: Invariant check
Assert the projected balance equals the replayed balance for a sampled account.
assert.equal(projected.balance, replayBalance(events));
A projection bug silently corrupts reported balances.
Check domain invariants such as a balance never going negative before producing events, and reject the command rather than writing an invalid one. Validating on the way in is cheaper than repairing history afterward.
Testing
Apply a sequence of commands and assert the replayed balance. Fire two concurrent withdrawals that together exceed the balance and assert exactly one succeeds.
Apply a sequence of commands and assert the replayed balance, since the balance is derived and only the replay proves the fold is correct.
Fire two concurrent withdrawals that together exceed the balance and assert exactly one succeeds, which is the optimistic-concurrency guarantee expressed as a test.
Rebuild the projection from scratch and assert it matches the state reached by incremental updates, because a projection that disagrees with replay is a bug waiting to be discovered in production.
Assert that a command violating an invariant is rejected and produces no events, so an invalid state can never enter the stream.
Assert that replaying the same stream twice yields the same state, because a fold with an accidental external dependency is the kind of bug that only surfaces under concurrency, and drive the same command twice with the same expected version to confirm the second is rejected.
pnpm vitest run
Deployment and operations
Treat the event store as the source of truth and the projection as disposable; rebuild it by replay. Back up the event store with point-in-time recovery.
Treat the event store as the source of truth and the projection as disposable; if they disagree, the projection is wrong. Back the store up with point-in-time recovery and rehearse restoring and replaying.
Run the projection consumer with idempotent handling keyed by event position, so a redelivery does not double-apply an event.
Publish the event schema as a versioned contract, because other systems will eventually consume the stream and an unversioned change breaks them silently.
Monitor projection lag as a first-class metric, since a projection that falls behind a healthy store makes every user-facing query stale without an error.
Snapshot the aggregate periodically once streams grow long, and treat the snapshot as a cache with a version, so load time stays bounded without the snapshot ever becoming authoritative.
Security considerations
Authorize commands by account ownership, never trust a balance sent by the client, and make event payloads immutable through database privileges.
Keep the event store append-only at the database grant level, because the immutability guarantee is only real if no code path can update or delete an event.
Redact or encrypt sensitive fields inside event payloads, since events are retained indefinitely and a schema mistake becomes a permanent record of a secret.
Authorize commands against the aggregate and not merely against the endpoint, so a caller cannot act on an account it does not own.
Log the command, the actor, and the resulting versions, because an event-sourced audit trail is only useful if the commands that produced it are recorded too.
Never allow a direct update to the balance table, because a writable projection is a way to change money without an event explaining it.
Authorize the projection read path with the same rules as the write path, because a projection that leaks balances across accounts is a data-exposure bug even though it holds no authority.
Observability
Track append failures due to version conflicts, projection lag, and replay time. Alert on growing projection lag, which means the consumer is behind.
Track append rate, the rejection rate for version conflicts, and aggregate load time, because a rising conflict rate means contention and a rising load time means streams are growing too long to replay quickly.
Chart projection lag and rebuild duration, since lag directly bounds how stale a query can be and rebuild duration bounds recovery time.
Alert on invariant rejections, because a spike usually indicates a client retry bug rather than genuine invalid business activity.
Measure event count per aggregate, because an unbounded stream eventually makes replay expensive and signals that snapshots are needed.
Track the ratio of appends to command attempts, because a low ratio means most commands are losing the race and retrying, which points at contention on a hot aggregate. Alert on replay failures during a projection rebuild, since a stream that cannot be replayed is a history that cannot be verified.
Chart projection lag alongside the store append rate, because together they show whether the read model is keeping up with the write model.
Troubleshooting
Balance drifts from the projection. An event was appended outside the command path and the projection skipped it; funnel all writes through commands.
Concurrent transfers both succeed. The append used an auto-incremented version instead of the expected one, so races did not conflict.
If a command keeps failing with a version conflict, the caller is not refreshing its state between attempts; retry with a fresh load rather than the same expected version.
If a balance looks wrong, replay the stream before touching the projection, since the projection can be rebuilt and the store cannot.
If replay after a restore produces a different balance, check the event ordering key; a version assigned from a non-monotonic source will replay in a different order and change the result.
Limitations and next steps
- No snapshots; replay cost grows with stream length.
- A single projection runner, not a consumer group.
- Schema versioning of events is not implemented.
There are no snapshots, so replay cost grows with stream length; a long-lived account will eventually need periodic snapshots to keep load time bounded.
The projection is single-process and rebuildable but not sharded, so query throughput is bounded by one consumer until it is partitioned by aggregate id.
Cross-aggregate transactions are not supported; a transfer writes two streams and relies on a compensating event if the second fails, which the example demonstrates but does not generalize.
Event schema evolution is handled by versioning rather than upcasting, so old streams replay with their original shape and consumers must tolerate every historical version.
There is no built-in event deletion or archival, so a stream that must be pruned for privacy would need a tombstone event and a compaction pass the example does not implement. The model also covers a single aggregate type, and a system with many would need a registry of event types and folds.
Because events are immutable and replay is pure, testing is thorough by construction: the same stream always yields the same state, so a regression is reproducible from stored data alone.