Skip to content

Fix the code, not the data

A post after the series CQRS with effect-cqrs, on its lending library. November 2026.

One librarian frowns at a single card from a card drawer, while another rebuilds a board of loans from a long ribbon of stamped loan cards, a wrench on the desk beside her

Every team has fixed this bug. Some code writes the wrong thing to a table, for weeks, until someone notices. The fix is a few lines and the tests pass. Then someone asks about the rows the bug already wrote, and the few lines turn into a migration, a script to run on production, a dig through the backups, or an apology.

With event sourcing the same bug ends with the code fix. The read model is rebuilt from the log by the fixed code, and nothing in the database has to be edited by hand. This post shows both, on the library of the series. Every snippet runs in examples/blog/fix-the-code.test.ts.

The library wants a history of its loans, so it gets a loans table. Someone writes it with one row per book, the way you’d store where a book is now:

INSERT INTO loans (book_id, loan_id, member_id, returned)
VALUES ('book-1', 'loan-1', 'alice', false)
ON CONFLICT (book_id) DO UPDATE
SET loan_id = excluded.loan_id, member_id = excluded.member_id, returned = false;

Alice borrows Dune and brings it back. Bob borrows it. Bob’s loan lands on Alice’s row, and Alice’s loan is gone. A month later a member of staff asks why Alice has never borrowed anything, and the bug is found.

The fix is to key the table by loan. From the next loan on, every row is right. Alice’s loan is not coming back, though: the UPDATE wrote over the only copy of it, and nothing else in the database remembers it. If you’re lucky, it’s in last night’s backup, or in an application log, and someone pieces it back together by hand.

A gentler bug, one that works out a value wrong without destroying anything, still costs twice. The code fix covers new rows, and a migration has to repair the old ones: the same logic written again in SQL, run once, on production, with no test that the two agree.

Now the same history as a read model, kept by a view model like the catalog of part 3. It lives in memory here, and it would be the same with a table:

examples/blog/fix-the-code.ts
export interface LoanRecord {
readonly loanId: string
readonly bookId: string
readonly memberId: string
readonly returned: boolean
}
/** Every loan the library has made: the `loans` table, as a read model. */
export class LoanHistory extends Context.Service<LoanHistory, Ref.Ref<ReadonlyMap<string, LoanRecord>>>()(
"library/LoanHistory"
) {
static readonly layer = Layer.effect(LoanHistory, Ref.make<ReadonlyMap<string, LoanRecord>>(new Map()))
}
const save = (key: string, record: LoanRecord) =>
LoanHistory.use((history) => Ref.update(history, (loans) => new Map(loans).set(key, record)))
const markReturned = (key: string) =>
LoanHistory.use((history) =>
Ref.update(history, (loans) => {
const record = loans.get(key)
return record === undefined ? loans : new Map(loans).set(key, { ...record, returned: true })
})
)
/** The loans of one member, as the front desk asks for them. */
export const loansOf = (memberId: string) =>
LoanHistory.use((history) =>
Ref.get(history).pipe(Effect.map((loans) => [...loans.values()].filter((loan) => loan.memberId === memberId)))
)

And the same bug, with the record kept under the book’s id:

examples/blog/fix-the-code.ts
export const BuggyLoanHistoryView = ViewModel.make({
name: "loan-history",
aggregates: [Book],
dispose: LoanHistory.use((history) => Ref.set(history, new Map())),
handlers: {
// The bug: the record is kept under the book's id, so the next loan of the same
// book replaces it.
BookLent: ({ loanId, memberId }, { aggregateId }) =>
save(aggregateId, { loanId, bookId: aggregateId, memberId, returned: false }),
BookReturned: (_, { aggregateId }) => markReturned(aggregateId)
}
})

The day goes the same way:

examples/blog/fix-the-code.ts
yield* books.commands.add("book-1", { title: "Dune", author: "Frank Herbert" })
yield* books.commands.lend("book-1", { loanId: "loan-1", memberId: "alice" })
yield* books.commands.returnBook("book-1", {})
const { position } = yield* books.commands.lend("book-1", { loanId: "loan-2", memberId: "bob" })
yield* viewModels.caughtUp(position)
const alice = yield* loansOf("alice")
// []: Bob's loan took the place of hers

Alice’s loan is gone from the read model, exactly as it was gone from the table. The difference is in what the bug could reach. The view model only writes its own read model. The log, where BookLent for loan-1 was appended by the book’s command, is written by the commands alone, and the bug never touched it.

The fix is the same few lines:

examples/blog/fix-the-code.ts
export const LoanHistoryView = ViewModel.make({
name: "loan-history",
aggregates: [Book],
dispose: LoanHistory.use((history) => Ref.set(history, new Map())),
handlers: {
// The fix: one record per loan.
BookLent: ({ loanId, memberId }, { aggregateId }) =>
save(loanId, { loanId, bookId: aggregateId, memberId, returned: false }),
BookReturned: ({ loanId }) => markReturned(loanId)
}
})

To see what a deploy does, the example keeps the storage apart from the code that runs on it. The log, the checkpoints and the read model live through the deploy. The view model is replaced:

examples/blog/fix-the-code.ts
// What outlives a deploy: the log, the checkpoints and the loans table itself.
export const Storage = Layer.mergeAll(EventStore.layerMemory, Checkpoints.layerMemory, LoanHistory.layer)
// What a deploy starts: the books and one version of the view model.
const deploy = (view: typeof LoanHistoryView) => ViewModel.layer([view]).pipe(Layer.provideMerge(Books.layer))

Then the new version starts:

examples/blog/fix-the-code.ts
yield* viewModels.caughtUp()
const beforeReplay = yield* loansOf("alice")
// []: the view model starts from its checkpoint, after every event the bug handled
const { eventCount } = yield* viewModels.replay({ viewModels: ["loan-history"] })
const afterReplay = yield* loansOf("alice")
// [{ loanId: "loan-1", bookId: "book-1", memberId: "alice", returned: true }], from the 3 loan events

Deploying alone changes nothing for the past. The view model picks up from its checkpoint, after every event the bug handled, so new loans are right and Alice’s is still missing. replay is the step that repairs it: it resets the checkpoint, clears the read model with dispose, and runs the fixed handlers over the whole log. Alice’s loan is back, worked out from the same BookLent the bug read wrong.

There was no migration, no script and no UPDATE. The log didn’t change, through the bug or through the fix. The handler that will record tomorrow’s loans is the one that rebuilt yesterday’s, so there is one piece of code to test, not a fix and a migration that have to agree.

  • The truth and the copy are apart. Commands write the log, and view models only read it. A bug in a view model can damage the copy it keeps, never the facts it was made from.
  • The past and the future go through the same code. A replay is the view model running as it always does, from the first event instead of from its checkpoint.
  • Nothing is lost while it’s wrong. Every event the bug mishandled is still in the log, waiting for a handler that reads it right.

This is the same reason part 1 could count loans with a timesLent written after them. A wrong read model and a missing one are fixed the same way.

A replay rebuilds read models. It doesn’t change what the commands decided.

  • A bug on the write side stays in the log. If lend had stored the wrong member, the event says so for good, and replays reproduce it faithfully. That is fixed forward: a command that records the correction as a new event, which read models handle like any other, as The wrong event shows. An event whose shape is wrong is read through an upcaster, as in part 5.
  • What an event concludes is fixed with it. The library’s BookReturned carries late, worked out by returnBook when the book came back. If the rule for lateness had a bug, every return it judged keeps the wrong answer. A read model that worked out lateness from dueOn and the time of the return would be fixed with a replay. Deciding what an event states and what read models work out is the main choice in designing one.
  • A replay takes time. It reads the whole log, and the read model is cleared while it runs. For a large one, deploy the fix under a new name, say loan-history-2, with its own storage: it starts from the first event next to the old one, and queries move over once it has caught up.
  • Handlers should only write their read model. A handler that sends an email sends it again on every replay. Work that must happen once belongs in a saga, which records what it has done.
  • Keep facts in events, and conclusions in read models. What happened can’t be wrong later. What it means can, and a read model can work it out again.
  • When a read model is wrong, fix the handler and replay. Don’t patch its data by hand: the next replay rebuilds it from the handlers and throws the patch away.
  • When an event is wrong, add one. The log records the mistake and the correction, which is what an auditor wants to see anyway.

The view models guide covers replays, checkpoints and failing handlers in full, and part 3 builds the library’s read models from the start.