Skip to content

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

A librarian pins a new, clearly marked correction card next to an older loan card on a ribbon of cards, without removing the old one

Fix the code, not the data ended on a warning: a replay rebuilds read models, and it can’t change what the commands decided. A bug in a view model is fixed by its own handlers. A bug in a command writes wrong facts into the log, and the log is never edited.

This post is about that bug: what you do when the log itself says something false. Every snippet runs in examples/blog/wrong-event.test.ts.

A release changes how the loan period is written, and the new line says hours where it meant days. The library’s lend command is the one from the series, with the period made a parameter so the example can run both versions:

examples/blog/wrong-event.ts
/** The book of the series, with the loan period as a parameter, so two deploys can differ. */
const makeBook = (loanPeriod: Partial<DateTime.DateTime.PartsForMath>) =>
Aggregate.make<BookState>()({
name: "book",
events: BookEvent,
initial: { status: "unknown", loan: null },
projection: {
BookAdded: (state) => ({ ...state, status: "available" }),
BookLent: (_, { dueOn, loanId, memberId }) => ({ status: "lent", loan: { loanId, memberId, dueOn } }),
BookReturned: () => ({ status: "available", loan: null }),
DueDateCorrected: (state, { dueOn }) => state.loan === null ? state : { ...state, loan: { ...state.loan, dueOn } }
}
})
.command("add", {
payload: Schema.Struct({ title: Schema.NonEmptyString, author: Schema.NonEmptyString }),
handler: Effect.fnUntraced(function*(state, payload, { aggregateId }) {
if (state.status !== "unknown") return yield* new BookAlreadyAdded({ bookId: aggregateId })
return BookEvent.cases.BookAdded.make(payload)
})
})
.command("lend", {
payload: Schema.Struct({ loanId: Schema.String, memberId: Schema.String }),
handler: Effect.fnUntraced(function*(state, { loanId, memberId }, { aggregateId }) {
if (state.status === "unknown") return yield* new BookNotFound({ bookId: aggregateId })
if (state.loan !== null) return yield* new BookAlreadyLent({ bookId: aggregateId })
const dueOn = DateTime.add(yield* DateTime.now, loanPeriod)
return BookEvent.cases.BookLent.make({ loanId, memberId, dueOn })
})
})
.command("returnBook", {
payload: Schema.Struct({}),
handler: Effect.fnUntraced(function*(state, _, { aggregateId }) {
if (state.loan === null) return yield* new BookNotLent({ bookId: aggregateId })
const { dueOn, loanId, memberId } = state.loan
const late = DateTime.isGreaterThan(yield* DateTime.now, dueOn)
return BookEvent.cases.BookReturned.make({ loanId, memberId, late })
})
})
.command("correctDueDate", {
payload: Schema.Struct({ loanId: Schema.String, dueOn: Schema.DateTimeUtc, reason: Schema.String }),
handler: Effect.fnUntraced(function*(state, { dueOn, loanId, reason }, { aggregateId }) {
if (state.loan?.loanId !== loanId) return yield* new LoanNotFound({ bookId: aggregateId, loanId })
// Correcting it again to the same date records nothing, so the script can run twice.
if (DateTime.Equivalence(state.loan.dueOn, dueOn)) return []
return BookEvent.cases.DueDateCorrected.make({ loanId, dueOn, reason })
})
})
// The bug: hours where days were meant.
export const BookWithTheBug = makeBook({ hours: 21 })
// The fix.
export const Book = makeBook({ days: 21 })

For a week, every BookLent says the book is due 21 hours after it went out. That is not a read model getting it wrong. The command decided it, the event recorded it, and the reminder job reads it from a read model that does exactly what it should:

examples/blog/wrong-event.ts
export interface OpenLoan {
readonly bookId: string
readonly memberId: string
readonly lentAt: DateTime.Utc
readonly dueOn: DateTime.Utc
}
/** The loans still open, by loan id, with when each began and when it is due. */
export class OpenLoans extends Context.Service<OpenLoans, Ref.Ref<ReadonlyMap<string, OpenLoan>>>()(
"library/OpenLoans"
) {
static readonly layer = Layer.effect(OpenLoans, Ref.make<ReadonlyMap<string, OpenLoan>>(new Map()))
}
const change = (update: (loans: Map<string, OpenLoan>) => void) =>
OpenLoans.use((open) =>
Ref.update(open, (loans) => {
const next = new Map(loans)
update(next)
return next
})
)
export const OpenLoansView = ViewModel.make({
name: "open-loans",
aggregates: [Book],
dispose: OpenLoans.use((open) => Ref.set(open, new Map())),
handlers: {
BookLent: ({ dueOn, loanId, memberId }, { aggregateId, timestamp }) =>
change((loans) => loans.set(loanId, { bookId: aggregateId, memberId, lentAt: timestamp, dueOn })),
BookReturned: ({ loanId }) => change((loans) => loans.delete(loanId)),
DueDateCorrected: ({ dueOn, loanId }) =>
change((loans) => {
const loan = loans.get(loanId)
if (loan !== undefined) loans.set(loanId, { ...loan, dueOn })
})
}
})
/** The loans that are overdue at a given time, as the reminder job asks for them. */
export const overdueAt = (at: DateTime.Utc) =>
OpenLoans.use((open) =>
Ref.get(open).pipe(
Effect.map((loans) =>
[...loans].filter(([, { dueOn }]) => DateTime.isGreaterThan(at, dueOn)).map(([loanId]) => loanId)
)
)
)

Alice borrows Dune during the bad week. The fix is deployed, and Bob borrows Neuromancer after it:

examples/blog/wrong-event.ts
const withTheBug = yield* AggregateService.make(BookWithTheBug)
yield* withTheBug.commands.add("book-1", { title: "Dune", author: "Frank Herbert" })
yield* withTheBug.commands.lend("book-1", { loanId: "loan-1", memberId: "alice" })
examples/blog/wrong-event.ts
const books = yield* AggregateService.make(Book)
yield* books.commands.add("book-2", { title: "Neuromancer", author: "William Gibson" })
const { position } = yield* books.commands.lend("book-2", { loanId: "loan-2", memberId: "bob" })
yield* viewModels.caughtUp(position)
const inTwoDays = DateTime.add(yield* DateTime.now, { days: 2 })
const beforeCorrection = yield* overdueAt(inTwoDays)
// ["loan-1"]: Alice's book is due 21 hours after she took it

Bob’s loan is right. Alice’s is still due the day after she took it, and two days later the reminder job tells her she is late. Fixing the code stopped new damage. It didn’t touch the event that says Alice’s book was due on Tuesday, and no replay will, because a replay reads that event as faithfully as the first time.

Don’t edit the event. It is tempting: one UPDATE on the events table and the date is right. But the log is what everything else trusts to never change. Read models have already handled the old value and won’t see the new one, since their checkpoints are past it. An outbox may have published it to another service. A test or an audit that compares the log with what happened now finds the past rewritten, with no trace of who did it or why.

Don’t hide it in an upcaster. An upcaster reads events of an old schema version as the current shape, as in part 5. The week’s events have the same version as every other BookLent, so an upcaster can’t even tell them apart. And if you found a way, the fix would live in the read path forever, invisible in the log, changing what the aggregate decided after the fact. An upcaster is for a change of shape, true of every old event. This is a change of value, true of some.

Don’t patch the read models. Correcting the due date in the reminder job’s table fixes the reminders until the next replay brings the 21 hours back. And the book itself still believes it: its own state, folded from the same event, has the wrong dueOn, so when Alice returns it on day ten, returnBook marks her late.

The log is a record of what happened, and something did happen: the library found a mistake and fixed it. That is a fact like any other, so it gets an event:

examples/blog/wrong-event.ts
export const BookEvent = Schema.TaggedUnion({
BookAdded: { title: Schema.NonEmptyString, author: Schema.NonEmptyString },
BookLent: { loanId: Schema.String, memberId: Schema.String, dueOn: Schema.DateTimeUtc },
BookReturned: { loanId: Schema.String, memberId: Schema.String, late: Schema.Boolean },
// The correction is a fact of its own: what the due date is now, and why it changed.
DueDateCorrected: { loanId: Schema.String, dueOn: Schema.DateTimeUtc, reason: Schema.String }
})

The book handles it like any other event. Its projection replaces the due date, and the correctDueDate command in the aggregate above checks that the loan is the one the book is on, and records nothing if the date is already right, so the script that sends it can run twice.

The script finds the loans to correct from the read model: every open loan due sooner than 21 days after it began came from the bug. Then it sends one command per loan, with the reason:

examples/blog/wrong-event.ts
// Every open loan that is due sooner than 21 days after it began came from the bug.
const affected = [...(yield* OpenLoans.use(Ref.get))].filter(([, { dueOn, lentAt }]) =>
DateTime.isLessThan(dueOn, DateTime.add(lentAt, { days: 21 }))
)
for (const [loanId, { bookId, lentAt }] of affected) {
yield* books.commands.correctDueDate(bookId, {
loanId,
dueOn: DateTime.add(lentAt, { days: 21 }),
reason: "The loan period was 21 hours instead of 21 days"
}).pipe(Metadata.withMetadata({ ranBy: "scripts/fix-due-dates.ts" }))
}
yield* viewModels.caughtUp()
const afterCorrection = yield* overdueAt(inTwoDays)
// []
const history = yield* books.history("book-1", { payload: true })
// BookAdded, BookLent, DueDateCorrected: the mistake and the correction, whose
// metadata says { ranBy: "scripts/fix-due-dates.ts" }

Everything that follows the log picks the correction up in order. The reminder job’s read model handles DueDateCorrected and moves the date. The book’s own state has the new date, so when Alice brings Dune back on day ten, the test shows returnBook deciding she is on time. And history tells the whole story: lent with the wrong date, then corrected, by which command, with which reason.

With a loans table, the fix is an UPDATE:

UPDATE loans SET due_on = lent_on + interval '21 days'
WHERE lent_on BETWEEN '2026-11-02' AND '2026-11-09' AND returned = false;

The rows are right afterwards. Everything else is gone: that Alice’s row ever said Tuesday, that she got a reminder because of it, that anyone changed it. If she disputes a fine next month, the table says she was never late and has no idea why she was told so. Unless someone built an audit table beforehand, the database now holds a cleaner past than the one that happened.

  • A wrong fact is corrected by a new fact. Name it after what happened, DueDateCorrected rather than BookLentAgain, and give it a reason.
  • Make the correction a command on the aggregate. It goes through the same rules and the same version check as everything else, and the aggregate’s state learns from it.
  • Find what to correct with a read model, fix it with commands. The query can be thrown away after the script runs. The corrections stay.
  • Design for the corrections you will need. A domain where people make mistakes, which is every domain, needs a way to say “that was wrong”. Events like DueDateCorrected, PaymentReversed or AddressAmended are part of the model, not an embarrassment.
  • Some consequences are already out. Alice got the reminder. A view model or a saga that handles DueDateCorrected can send her an apology, which a silent UPDATE never could.

Fix the code, not the data covers the other half: bugs in read models, which a replay repairs. The aggregates guide covers commands, projections and history.