Skip to content

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

A member at the front desk presses a desk bell twice while the librarian holds up a single receipt, a second identical slip set aside on a spike

Alice owes the library two fines and pays one on the website. The spinner turns, the page times out, and she presses the button again. Behind the page, the first request had gone through: only its answer was lost. She has now paid twice.

Everyone has seen this bug, on one side of it or the other. It isn’t caused by impatient users. It is what every retry looks like from the server, and retries are everywhere: the browser, the mobile app that reconnects, the load balancer, the HTTP client library, the queue that redelivers. Every snippet runs in examples/blog/clicked-twice.test.ts.

The library keeps what each member owes as an aggregate: fines charged, payments received, and a balance in cents. One rule: there must be something to pay.

examples/blog/clicked-twice.ts
export const AccountEvent = Schema.TaggedUnion({
FineCharged: { amount: Schema.Int, reason: Schema.String },
PaymentReceived: { amount: Schema.Int }
})
export class NothingToPay extends Schema.TaggedError<NothingToPay>()("NothingToPay", {
memberId: Schema.String
}) {}
/** What a member owes the library, in cents. Negative is credit. */
export const Account = Aggregate.make<{ readonly balance: number }>()({
name: "account",
events: AccountEvent,
initial: { balance: 0 },
projection: {
FineCharged: ({ balance }, { amount }) => ({ balance: balance + amount }),
PaymentReceived: ({ balance }, { amount }) => ({ balance: balance - amount })
}
})
.command("chargeFine", {
payload: Schema.Struct({ amount: Schema.Int, reason: Schema.String }),
handler: (_, payload) => Effect.succeed(AccountEvent.cases.FineCharged.make(payload))
})
.command("pay", {
payload: Schema.Struct({ amount: Schema.Int }),
handler: Effect.fnUntraced(function*({ balance }, { amount }, { aggregateId }) {
if (balance <= 0) return yield* new NothingToPay({ memberId: aggregateId })
return AccountEvent.cases.PaymentReceived.make({ amount })
})
})
export class Accounts extends Context.Service<Accounts, AggregateService.AggregateService<typeof Account>>()(
"library/Accounts"
) {
static readonly layer = Layer.effect(Accounts, AggregateService.make(Account))
}
examples/blog/clicked-twice.ts
yield* accounts.commands.chargeFine("alice", { amount: 300, reason: "Dune, 10 days late" })
yield* accounts.commands.chargeFine("alice", { amount: 300, reason: "Neuromancer, 10 days late" })
// The payment page sends 3 euros. The response is lost, and the page sends it again.
yield* accounts.commands.pay("alice", { amount: 300 })
yield* accounts.commands.pay("alice", { amount: 300 })
const alice = yield* accounts.state("alice")
// { balance: 0 }: it looks paid, and Alice was charged 6 euros for one click

Two PaymentReceived events for one click. The balance even looks right, which is the worst part: Alice owed 6 euros and paid 6, and only she knows she meant to pay 3. Nobody notices until she reads her bank statement.

Notice the rule didn’t help. With one fine, the second payment would have been refused, because there was nothing left to pay. That makes the bug rarer, not gone: it only shows when a member owes more than one fine, which is when it costs the most to explain. Business rules catch duplicates by luck.

Disabling the button stops the double click and nothing else. The retry that matters is the one the user never sees: the HTTP client that tries again after a timeout, the phone that resends when it gets its signal back, the message that a queue delivers twice.

A unique constraint is the right instinct. Add a request_id column to the payments table, unique, and the second insert fails. But then:

  • the second request gets an error, while the first succeeded, so the client shows “payment failed” for a payment that went through. To answer it properly, the server has to find the first payment and return what it returned then, which means keeping the response too.
  • every table that can be written by a retried request needs the column, the index and the code that looks it up. A fine, a loan, a reservation, a renewal.
  • someone has to decide how long ids are kept, and clean them up.

The client makes an id once, when Alice clicks, and sends it with every attempt. effect-cqrs calls it a commandId:

examples/blog/clicked-twice.ts
yield* accounts.commands.chargeFine("alice", { amount: 300, reason: "Dune, 10 days late" })
yield* accounts.commands.chargeFine("alice", { amount: 300, reason: "Neuromancer, 10 days late" })
// The page makes one id when Alice clicks, and sends it with every attempt.
const requestId = "7b6f0e1c-pay-dune"
const first = yield* accounts.commands.pay("alice", { amount: 300 }, { commandId: requestId })
const again = yield* accounts.commands.pay("alice", { amount: 300 }, { commandId: requestId })
// again equals first: the same version, position and events, and nothing new is stored
const alice = yield* accounts.state("alice")
// { balance: 300 }: one payment, 3 euros still owed

The second attempt didn’t run the handler. The service recognized the id, read back the events the first attempt wrote, and returned the same result: same version, same position, same events. The client can’t tell a retry from the first try, which is the point. It gets the answer it lost.

Where are the ids kept? In the log. The id is recorded in the metadata of the events the command wrote, so there is no extra table, and nothing to keep in step: the payment and the proof that it was paid by this request are written in the same append. Each aggregate remembers the ids of its last 100 commands, rebuilt from its events when its state is loaded.

An id belongs to one request. Sent again with another command, it is refused rather than answered with a result that isn’t the caller’s:

examples/blog/clicked-twice.ts
const reused = yield* Effect.flip(
accounts.commands.chargeFine("alice", { amount: 100, reason: "Lost card" }, { commandId: requestId })
)
// reused._tag: "CommandIdReused", with previous: "pay" and command: "chargeFine"

Sometimes the domain already has an id that makes a repeat recognizable. A loan in this library has one, chosen by the client, and the book’s lend command records nothing if the book is already lent for that loan:

examples/blog/clicked-twice.ts
yield* books.commands.add("book-1", { title: "Dune", author: "Frank Herbert" })
const first = yield* books.commands.lend("book-1", { loanId: "loan-1", memberId: "alice" })
const again = yield* books.commands.lend("book-1", { loanId: "loan-1", memberId: "alice" })
// first.events: [BookLent], again.events: []: the book already has that loan

That is the best kind of idempotency, because it doesn’t depend on remembering anything: a retry next month is still harmless. It is why the saga in the series can repeat any of its steps. Not every command has such an id. “Pay 3 euros” twice is two payments, unless something says they are the same request.

  • Every command that can be retried needs a way to be recognized. Prefer an id in the domain, like loanId. Where there isn’t one, send a commandId.
  • The client makes the id, once per intention. A UUID made when the user clicks, kept across retries. An id the server makes can’t help, because the retry doesn’t know it.
  • A retry gets the first answer, not an error. The caller needs the result it lost.
  • Don’t count on business rules to catch duplicates. They catch some, by luck.
  • The window is bounded. An aggregate remembers its last 100 command ids by default, commandIdsPerStream. Retries come within seconds or minutes, so that’s plenty for a member’s account, and an aggregate with heavy traffic can keep more.

The commands guide covers command ids in full, including two processes running the same command at once.