Skip to content

Event sourcing on AWS serverless, part 1: store the facts, compute the state

Why an append-only event log beats a balance column, and how to build a real write model on DynamoDB - the fold, invariants, optimistic concurrency, and snapshots.

AWS Event Sourcing DynamoDB

Part 1 of a three-part series on event sourcing and CQRS with AWS serverless, grounded in appsync-es, a deployable reference implementation you can clone and break. Part 2 covers the read side - CQRS, projections, and eventual consistency. Part 3 covers integration events and the trade-offs.

Here’s a question most databases can’t answer: what was this account’s balance last Tuesday?

If you model a bank account the conventional way - one row, a balance column, an UPDATE per transaction - the answer was destroyed the moment the write committed. The row remembers the present and nothing else. You can bolt on a history table, but now you have two places to write, and the history is only as trustworthy as your discipline about updating it. Sooner or later some code path forgets.

Event sourcing flips the model. Instead of storing current state, you store the sequence of facts that produced it - AccountOpened, FundsDeposited(5000), FundsWithdrawn(1200) - in an append-only log, and you compute current state by replaying them. Three things fall out of that inversion:

  • Audit, for free. The event log is the audit trail. There’s no parallel history table to forget about, because facts are only ever recorded in one place - the same place that produces current state.
  • Time travel. “What was the balance after the fifth transaction?” is a replay of the first five events. No backups, no triggers.
  • Rebuildable views. Found a bug in a denormalized view? Need a new one for a new feature? Replay the events through new logic. Every view is disposable; the log is the ground truth.

The cost is that reading current state now takes work - either a replay on every read, or a materialized view kept in sync asynchronously. That cost is where CQRS comes in, and it gets the whole of part 2. This post is about the write side: how facts get recorded correctly when two writers race, on nothing but DynamoDB.

The demo

The running example is appsync-es, a small bank-account service I built as a reference implementation: AWS AppSync in front, four Lambdas, one DynamoDB table, an EventBridge bus, all deployed by CDK pipelines. It’s deliberately tiny in domain (open an account, deposit, withdraw, close) and deliberately real in mechanism - optimistic concurrency, snapshots, an idempotent projector, a proper integration boundary. Every file that does something non-obvious opens with a comment explaining why. The docs/ folder has a concept primer and an architecture deep dive if you want more than a blog series can hold.

A bank account is the classic teaching domain for a reason: everyone already knows the business rule (no overdrafts), and money makes people take “don’t lose a write” seriously.

Facts, as types

An event is an immutable fact, always named in the past tense: FundsDeposited, never DepositFunds. Past tense matters more than it looks - it’s the difference between a request and a record. Here’s the shape of the domain’s vocabulary, from lib/bank-account/domain/events.ts:

interface BaseEvent {
  id: string; // aggregate (account) id
  version: number; // monotonic per aggregate; also the optimistic-concurrency token
  timestamp: string; // ISO-8601, when the fact was recorded
}

export interface FundsDeposited extends BaseEvent {
  type: "FundsDeposited";
  amount: number; // positive minor units (cents)
}

// AccountOpened, FundsWithdrawn, and AccountClosed follow the same pattern

export type AccountEvent =
  | AccountOpened
  | FundsDeposited
  | FundsWithdrawn
  | AccountClosed;

Two fields deserve attention. The version is a per-account counter: the first event is version 1, the next is 2, and so on. It gives every event a position in its account’s history, and later it becomes the entire concurrency mechanism. The discriminated union (AccountEvent) means TypeScript can check that any code handling events covers every case - which the next function relies on.

One piece of vocabulary before moving on: an aggregate is the consistency boundary, the unit within which state must always be valid. Here one account is one aggregate. The no-overdraft rule is enforced across a single account’s events, never across accounts. That scoping decision is what lets the whole design work on plain DynamoDB writes, no transactions required.

State is a fold

If events are the source of truth, current state is a derived value. The derivation is a fold - the same operation as Array.prototype.reduce: start with nothing, apply events one at a time, end with current state. From lib/bank-account/domain/account.ts:

export const apply = (
  state: AccountState | null,
  event: AccountEvent,
): AccountState => {
  switch (event.type) {
    case "AccountOpened":
      return {
        id: event.id,
        owner: event.owner,
        balance: 0,
        version: event.version,
        status: "OPEN",
      };
    case "FundsDeposited": {
      const prior = requireState(state, event);
      return {
        ...prior,
        balance: prior.balance + event.amount,
        version: event.version,
      };
    }
    // FundsWithdrawn subtracts; AccountClosed flips status to 'CLOSED'
  }
};

export const rehydrate = (
  snapshot: AccountState | null,
  events: AccountEvent[],
): AccountState | null => events.reduce((s, e) => apply(s, e), snapshot);

rehydrate is reduce almost verbatim. That’s the whole trick.

These functions are pure - no AWS SDK, no I/O, no clock. Given the same events they always produce the same state, which makes the core of the system trivially unit-testable: the domain tests in this repo run against arrays of plain objects and never mock anything. When people say event sourcing is testable, this is what they mean. The part of your system where the money math lives is a reducer.

Commands decide

A command is the other half of the vocabulary: a request to change something, which the system may refuse. withdrawFunds (imperative) is a command; FundsWithdrawn (past tense) is the event it produces if the rules allow it. Business rules - invariants - live exactly at this boundary, in pure functions that take the current folded state plus the request and return either one new event or an error. From lib/bank-account/domain/commands.ts:

export const withdrawFunds = (
  state: AccountState,
  p: { amount: number; now: string },
): FundsWithdrawn => {
  assertOpen(state);
  assertPositive(p.amount);
  if (p.amount > state.balance) {
    throw new InvariantViolation(
      `Insufficient funds: cannot withdraw ${p.amount} from balance ${state.balance}`,
    );
  }
  return {
    type: "FundsWithdrawn",
    id: state.id,
    amount: p.amount,
    version: state.version + 1,
    timestamp: p.now,
  };
};

Notice that nothing here writes to the database. The function’s whole job is the decision: given this state and this request, either produce the event or refuse. The returned event claims version: state.version + 1 - the next slot in the account’s history - and that claim is about to matter a great deal.

An event store on one DynamoDB table

DynamoDB has no “event store” primitive, but it has the two properties you need: items are cheap to append, and conditional writes are atomic. This repo keeps everything - events, snapshots, and the read-model views we’ll meet in part 2 - in a single table, with the item’s role encoded in its sort key:

itempkskrole
eventACCOUNT#<id>EVENT#<zero-padded version>append-only truth
snapshotACCOUNT#<id>SNAPSHOTcached fold, overwritten
viewACCOUNT#<id>VIEWmaterialized read model (pt2)

The key formulas all live in one file, lib/bank-account/adapters/dynamo-client.ts:

export const pk = (id: string): string => `ACCOUNT#${id}`;

export const eventSk = (version: number): string =>
  `EVENT#${String(version).padStart(VERSION_PAD_WIDTH, "0")}`; // VERSION_PAD_WIDTH = 12

export const EVENT_SK_MAX = `EVENT#${"9".repeat(VERSION_PAD_WIDTH)}`;

The zero-padding looks fussy and isn’t. DynamoDB sorts keys lexicographically, and as plain strings "EVENT#2" sorts after "EVENT#10". Pad both to twelve digits and string order matches version order, so a single range query returns an account’s events in exactly the order the fold needs them.

The EVENT# prefix does a second job: it carves out a contiguous key-space within the account’s partition. Loading an account is a GetItem for the snapshot followed by one query for only the events after it:

KeyConditionExpression: 'pk = :pk AND sk BETWEEN :lo AND :hi',
ExpressionAttributeValues: { ':pk': pk(id), ':lo': eventSk(fromVersion + 1), ':hi': EVENT_SK_MAX },

The BETWEEN bounds keep the query inside the EVENT# range, so the SNAPSHOT and VIEW items sharing the same partition never show up in a fold. The events come back in ascending version order, rehydrate folds them onto the snapshot, and you have current state.

Two writers, one version

Now the interesting failure mode. Suppose an account holds a balance of 10000 - $100, in the integer minor units the schema uses - and two clients concurrently withdraw 6000 each. Both requests load the account, both see version 5 and balance 10000, both pass the invariant check, and both produce a FundsWithdrawn event claiming version 6. If both writes land, the balance hits -2000 and the invariant you carefully wrote was decoration.

The fix costs one line. appendEvent, in lib/bank-account/adapters/event-store.ts:

await ddb.send(
  new PutCommand({
    TableName: TABLE_NAME,
    Item: { pk: pk(event.id), sk: eventSk(event.version), ...event },
    ConditionExpression: "attribute_not_exists(pk)", // nothing at this exact pk+sk yet
  }),
);

Because the event’s sort key encodes its version, “put this item only if nothing exists at this key” can succeed exactly once per version. There is no separate version counter to read and increment, no lock table, no transaction. The event’s own key is the lock. One of our two racing writers claims EVENT#000000000006; the other gets ConditionalCheckFailedException, which the event store converts into a typed ConcurrencyError.

What should the loser do? Retry - but retry the decision, not the write. The command resolver (lib/bank-account/handlers/command-resolver.ts) wraps the whole cycle in a loop:

for (let attempt = 1; attempt <= MAX_APPEND_RETRIES; attempt++) {
  const { state } = await loadAccount(id);
  if (!state) throw new InvariantViolation(`Account ${id} not found`);

  const event = decide(state, new Date().toISOString());
  try {
    await appendEvent(event);
  } catch (error) {
    if (!(error instanceof ConcurrencyError)) throw error; // real errors fail fast
    continue; // another writer won this version - reload and re-decide
  }
  return apply(state, event); // the new state, computed in memory
}
throw new ConcurrencyError(`Exhausted ${MAX_APPEND_RETRIES} retries for ${id}`);

Follow the losing withdrawal through the retry. It reloads and now sees version 6, balance 4000. It re-runs withdrawFunds against the fresh state - and the invariant throws InvariantViolation: Insufficient funds. The caller gets a clean business error instead of the system quietly minting money. This is why the loop re-decides instead of just re-appending the old event with a bumped version: the second withdrawal was only valid in a world that no longer exists.

This pattern is called optimistic concurrency: no locks are held between load and append, so the common case (no contention) pays zero coordination cost, and the rare conflict is detected and retried. Five attempts (MAX_APPEND_RETRIES) is plenty for a single hot aggregate; if you exhaust them, something upstream is hammering one account and deserves the error it gets.

Snapshots, so the fold stays short

A fold over ten events is free. A fold over ten thousand is a problem you’d meet on every single command, since each one starts with loadAccount. Snapshots bound it: every SNAPSHOT_FREQUENCY versions (10 by default, 50 in prod - it’s an environment-overridable constant in constants.ts), the command resolver writes the freshly computed state to the account’s single SNAPSHOT item. The next loadAccount reads the snapshot and folds only the events after it.

Three properties keep snapshots honest:

  • A snapshot is not an event. It never gets an EVENT# key, never consumes a version number, and never participates in the concurrency scheme. It’s a cache with a version label.
  • It’s disposable. Delete every snapshot and the system computes identical answers, just slower. The moment a snapshot becomes required for correctness, you’ve grown a second source of truth, and the two will eventually disagree.
  • Writing it is best-effort. The event is durably appended before the snapshot write is attempted, so a failed snapshot is logged and swallowed - it must never fail the command. Worst case is a slightly longer replay next load.

The write path, end to end

Putting the pieces together, here’s what one depositFunds mutation does:

ConditionalCheckFailed

success

AppSync mutation · depositFunds(id, amount)

validate input (zod)

load · GetItem SNAPSHOT + Query EVENT# after it, fold

decide · pure function checks invariants, returns event at version+1

append · conditional Put on EVENT#version

ConcurrencyError · reload + re-decide (max 5)

snapshot every Nth version (best-effort)

return new state, computed in memory

That last box hides a decision that pays off in part 2: the mutation’s response is apply(state, event) - the new state computed in memory from the fold plus the event just appended. It is never read back from storage. The caller who just deposited always sees the post-deposit balance in the mutation response, and holding onto that guarantee is about to matter.

What we can’t do yet

We can record facts correctly under concurrency, and we can rebuild any account’s state on demand. What we can’t do is read cheaply. getAccount by fold means every read pays for a GetItem plus a query plus a replay, and listAccounts is worse - there is no fold-on-read version of “list every account” that doesn’t rehydrate all of them just to print their ids.

The fix is to maintain a separate, denormalized “current state” record per account, kept up to date asynchronously by replaying events off a DynamoDB stream - a read model. Splitting the system into a write path shaped for correctness and a read path shaped for cheap queries has a name, CQRS, and a price, eventual consistency. Both are part 2.