Skip to content

The form that overwrites

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

Two librarians at separate desks each fill in their own copy of the same catalog card, while the large card on the wall between them shows one change and a smudge where the other was erased

Bianca notices that the catalog spells Frank Herbert without its second R, and fixes it. A minute later Carlo, who opened the same record before her fix, moves the book to another shelf and saves. The typo is back. Nobody did anything wrong, nobody got an error, and the fix is gone until someone notices it again.

This is the lost update, and it is as old as forms. It lives in admin panels, CMSs, CRMs and every screen with a Save button. This post shows that event sourcing doesn’t fix it by itself, and what does. Every snippet runs in examples/blog/form-overwrites.test.ts.

A book’s record in the catalog is an aggregate, listing, with a title, an author and a shelf. It has two kinds of commands on purpose: one that saves the whole form, as a CRUD screen does, and some that say what changed:

examples/blog/form-overwrites.ts
const Fields = { title: Schema.NonEmptyString, author: Schema.NonEmptyString, shelf: Schema.NonEmptyString }
export const ListingEvent = Schema.TaggedUnion({
ListingCreated: Fields,
// What a form that saves every field records.
ListingSaved: Fields,
// What commands that say what changed record.
AuthorCorrected: { author: Schema.NonEmptyString },
MovedToShelf: { shelf: Schema.NonEmptyString },
Retitled: { title: Schema.NonEmptyString }
})
export interface ListingState {
readonly title: string
readonly author: string
readonly shelf: string
}
/** A book's record in the catalog: what the librarians edit. */
export const Listing = Aggregate.make<ListingState>()({
name: "listing",
events: ListingEvent,
initial: { title: "", author: "", shelf: "" },
projection: {
ListingCreated: (_, { author, shelf, title }) => ({ title, author, shelf }),
ListingSaved: (_, { author, shelf, title }) => ({ title, author, shelf }),
AuthorCorrected: (state, { author }) => ({ ...state, author }),
MovedToShelf: (state, { shelf }) => ({ ...state, shelf }),
Retitled: (state, { title }) => ({ ...state, title })
}
})
.command("create", {
payload: Schema.Struct(Fields),
handler: (_, fields) => Effect.succeed(ListingEvent.cases.ListingCreated.make(fields))
})
.command("saveForm", {
payload: Schema.Struct(Fields),
handler: (_, fields) => Effect.succeed(ListingEvent.cases.ListingSaved.make(fields))
})
.command("correctAuthor", {
payload: Schema.Struct({ author: Schema.NonEmptyString }),
handler: (state, { author }) =>
Effect.succeed(state.author === author ? [] : ListingEvent.cases.AuthorCorrected.make({ author }))
})
.command("moveToShelf", {
payload: Schema.Struct({ shelf: Schema.NonEmptyString }),
handler: (state, { shelf }) =>
Effect.succeed(state.shelf === shelf ? [] : ListingEvent.cases.MovedToShelf.make({ shelf }))
})
.command("retitle", {
payload: Schema.Struct({ title: Schema.NonEmptyString }),
handler: (state, { title }) =>
Effect.succeed(state.title === title ? [] : ListingEvent.cases.Retitled.make({ title }))
})

The librarians’ screens come from a read model. Next to the fields, it keeps the version of the record it shows, from the metadata of the last event it handled:

examples/blog/form-overwrites.ts
export interface Screen extends ListingState {
/** The version of the record this screen shows, which an edit sends back. */
readonly version: number
}
/** What the librarians' screens show: every record, with the version it was read at. */
export class Screens extends Context.Service<Screens, Ref.Ref<ReadonlyMap<string, Screen>>>()("library/Screens") {
static readonly layer = Layer.effect(Screens, Ref.make<ReadonlyMap<string, Screen>>(new Map()))
}
const show = (listingId: string, version: number, update: (fields: ListingState) => ListingState) =>
Screens.use((screens) =>
Ref.update(screens, (all) => {
const fields = all.get(listingId) ?? { title: "", author: "", shelf: "" }
return new Map(all).set(listingId, { ...update(fields), version })
})
)
export const ScreensView = ViewModel.make({
name: "screens",
aggregates: [Listing],
dispose: Screens.use((screens) => Ref.set(screens, new Map())),
handlers: {
ListingCreated: ({ author, shelf, title }, { aggregateId, version }) =>
show(aggregateId, version, () => ({ title, author, shelf })),
ListingSaved: ({ author, shelf, title }, { aggregateId, version }) =>
show(aggregateId, version, () => ({ title, author, shelf })),
AuthorCorrected: ({ author }, { aggregateId, version }) => show(aggregateId, version, (f) => ({ ...f, author })),
MovedToShelf: ({ shelf }, { aggregateId, version }) => show(aggregateId, version, (f) => ({ ...f, shelf })),
Retitled: ({ title }, { aggregateId, version }) => show(aggregateId, version, (f) => ({ ...f, title }))
}
})

The CRUD version of a save is UPDATE listings SET title = ?, author = ?, shelf = ? WHERE id = ?, every field, from what the screen showed. The same thing as a command:

examples/blog/form-overwrites.ts
// Bianca and Carlo open the same record, author misspelled, on shelf A3.
const bianca = yield* openDune
const carlo = bianca
// Bianca fixes the author. Carlo, a minute later, moves the book.
yield* listings.commands.saveForm("dune", { title: bianca.title, author: "Frank Herbert", shelf: bianca.shelf })
yield* listings.commands.saveForm("dune", { title: carlo.title, author: carlo.author, shelf: "B1" })
const dune = yield* listings.state("dune")
// { title: "Dune", author: "Frank Herbet", shelf: "B1" }: Carlo's form put the typo back

Event sourcing didn’t save us. The log has both ListingSaved events, so at least someone looking at the history can see what happened, which a table can’t offer. But the state is wrong, because Carlo’s command said “the author is Frank Herbet”: his form sent back every field it showed, including the one he never touched.

The problem isn’t storage. It’s that the command says more than the user meant.

Bianca meant “correct the author”. Carlo meant “move it to B1”. Those are the commands:

examples/blog/form-overwrites.ts
yield* openDune
yield* listings.commands.correctAuthor("dune", { author: "Frank Herbert" })
yield* listings.commands.moveToShelf("dune", { shelf: "B1" })
const dune = yield* listings.state("dune")
// { title: "Dune", author: "Frank Herbert", shelf: "B1" }: both edits kept

Both edits survive, in either order, because neither command carries a field it doesn’t change. Most lost updates disappear this way, without locks, versions or error messages: two people working on the same record rarely change the same thing.

This is what people mean by task-based UI: screens built around what users do, “correct author”, “move to shelf”, “withdraw”, rather than around a table’s columns. It costs more than one generic form. It also gives you events that say what happened, AuthorCorrected rather than ListingSaved, which every read model, report and audit after it will appreciate.

Sometimes two people do change the same thing. Bianca retitles the book “Dune (Deluxe Edition)”, and Carlo, from the screen he opened before, retitles it “Dune: 60th Anniversary”. The last one would win, and Carlo would never know he overwrote a decision he hadn’t seen.

His screen knows which version it showed. Sending it back as the expectedVersion turns the silent overwrite into a question:

examples/blog/form-overwrites.ts
const bianca = yield* openDune
const carlo = bianca
// Both screens show version 1.
yield* listings.commands.retitle("dune", { title: "Dune (Deluxe Edition)" }, { expectedVersion: bianca.version })
const refused = yield* Effect.flip(
listings.commands.retitle("dune", { title: "Dune: 60th Anniversary" }, { expectedVersion: carlo.version })
)
// refused: WrongExpectedVersion { expectedVersion: 1, actualVersion: 2 }
// Carlo's screen reloads, shows Bianca's title, and he decides what to do.

Bianca’s command matched: the record was at version 1, as her screen said. Carlo’s didn’t: the record had moved to version 2. The command failed before its handler ran, with both versions in the error, and the screen can reload and show Carlo what changed. Then he decides, knowing.

WrongExpectedVersion is never retried. That is the difference from the other version check in effect-cqrs: when two processes append to the same aggregate at once, the loser reloads the state and runs the handler again, and the rules decide on the new state. That’s safe because the handler sees what changed. A user doesn’t, so deciding again for them would be the same silent overwrite.

Not always. An expected version refuses any change in between, even one that didn’t touch what the user is editing. With it on every command, Carlo’s shelf move would have been refused because Bianca corrected the author, and he would have had to reload for nothing.

  • Leave it out for commands whose result doesn’t depend on what the user saw: “move to shelf B1” means the same whatever the title is. The aggregate’s rules still run on the current state.
  • Send it when the user’s decision is based on what they read: a new title chosen while looking at the old one, a price set while looking at the cost, an approval of a document someone may have edited since.
  • A command carries what the user changed, not what the screen showed. Most lost updates are a form that sends back fields nobody touched.
  • Read models keep the version they show. The version in an event’s metadata is free, and the screen will need it.
  • Send expectedVersion when the decision depends on what was read. It turns an overwrite into a conflict the user can see.
  • Never retry a conflict for the user. Reload, show the change, and let them decide.

The commands guide covers expected versions and how they combine with command ids, and I clicked twice covers the other kind of repeat: the same request arriving twice.