Skip to content

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

Two members hold loan cards for the same single copy of a book, their lines tangled, while a courier waits by a dotted path in the background

Sagas are the part of CQRS that teams skip most often. Aggregates and read models make sense on day one, while a saga looks like ceremony: a workflow, a trigger, an engine, just to lend a book? It is so much shorter to let the member’s command do everything at once.

This post takes that shortcut, twice, on the library of the series. Both versions compile, pass a happy-path test, and look fine in a demo. Then we look at what they do to the data, and at what the saga of part 4 does differently. Every snippet runs in examples/blog/without-saga.test.ts.

The rules are the ones from the series: a member may have at most three books at a time, and a copy can only be with one member. The first rule is about the member, the second about the book.

Mistake 1: one aggregate records everything

Section titled “Mistake 1: one aggregate records everything”

Borrowing changes the member and the book, so the member’s command records both changes. The member’s events get a BookLent, and borrow returns two events in one go:

examples/blog/without-saga.ts
const GreedyMemberEvent = Schema.TaggedUnion({
MemberJoined: { name: Schema.NonEmptyString },
LoanStarted: { loanId: Schema.String, bookId: Schema.String },
// The book's event, recorded in the member's stream.
BookLent: { loanId: Schema.String, bookId: Schema.String, dueOn: Schema.DateTimeUtc }
})
export const GreedyMember = Aggregate.make<LoansState>()({
name: "member",
events: GreedyMemberEvent,
initial: { joined: false, loans: [] },
projection: {
MemberJoined: (state) => ({ ...state, joined: true }),
LoanStarted: (state, { loanId }) => ({ ...state, loans: [...state.loans, loanId] }),
BookLent: (state) => state
}
})
.command("join", {
payload: Schema.Struct({ name: Schema.NonEmptyString }),
handler: Effect.fnUntraced(function*(state, { name }, { aggregateId }) {
if (state.joined) return yield* new MemberAlreadyJoined({ memberId: aggregateId })
return GreedyMemberEvent.cases.MemberJoined.make({ name })
})
})
.command("borrow", {
payload: Schema.Struct({ loanId: Schema.String, bookId: Schema.String }),
handler: Effect.fnUntraced(function*(state, { bookId, loanId }, { aggregateId }) {
if (!state.joined) return yield* new MemberNotFound({ memberId: aggregateId })
if (state.loans.length >= loanLimit) {
return yield* new LoanLimitReached({ memberId: aggregateId, limit: loanLimit })
}
const dueOn = DateTime.add(yield* DateTime.now, loanPeriod)
// Two events in one go: the member's, and the one the book should have decided on.
return [
GreedyMemberEvent.cases.LoanStarted.make({ loanId, bookId }),
GreedyMemberEvent.cases.BookLent.make({ loanId, bookId, dueOn })
]
})
})

It reads well. One command, one atomic append, both facts recorded together. Here is a morning at the library:

examples/blog/without-saga.ts
const members = yield* AggregateService.make(GreedyMember)
yield* books.commands.add("book-1", { title: "Dune", author: "Frank Herbert" })
yield* members.commands.join("alice", { name: "Alice Rossi" })
yield* members.commands.join("bob", { name: "Bob Bianchi" })
yield* members.commands.borrow("alice", { loanId: "loan-1", bookId: "book-1" })
yield* members.commands.borrow("bob", { loanId: "loan-2", bookId: "book-1" })
// Both succeed: two loans of one copy.
const book = yield* books.state("book-1")
// { status: "available", loan: null, … }: the book never heard of either

Alice and Bob both have the only copy of Dune. Nothing failed, so nothing told us.

Look at where the rule “one member per copy” is checked in borrow: nowhere, and it can’t be. The handler decides from the member’s state, which holds the member’s loans and nothing about the book. The member wrote a fact about the book, BookLent, without asking the book.

And the event is in the wrong place. BookLent sits in Alice’s stream and in Bob’s, not in book-1’s, so the book’s own state says it is on the shelf. Every read model that follows the book’s events agrees with it. The library now has two versions of the truth, and the one in the book’s stream is wrong.

The usual patch is to read the book first, with books.state(bookId), and refuse if it is lent. That only moves the problem: the book’s stream still never gets the BookLent, so the read always says available. Even if it did, reading and then writing elsewhere is check-then-act: between the read and the member’s append, another member can borrow the same copy, and no version check stops it, because the append goes to a different stream.

Fine, the book must decide for itself. So the member’s command calls the book’s command, and records its own event once the book has said yes:

examples/blog/without-saga.ts
const CallingMemberEvent = Schema.TaggedUnion({
MemberJoined: { name: Schema.NonEmptyString },
LoanStarted: { loanId: Schema.String, bookId: Schema.String }
})
export const CallingMember = Aggregate.make<LoansState>()({
name: "member",
events: CallingMemberEvent,
initial: { joined: false, loans: [] },
projection: {
MemberJoined: (state) => ({ ...state, joined: true }),
LoanStarted: (state, { loanId }) => ({ ...state, loans: [...state.loans, loanId] })
}
})
.command("join", {
payload: Schema.Struct({ name: Schema.NonEmptyString }),
handler: Effect.fnUntraced(function*(state, { name }, { aggregateId }) {
if (state.joined) return yield* new MemberAlreadyJoined({ memberId: aggregateId })
return CallingMemberEvent.cases.MemberJoined.make({ name })
})
})
.command("borrow", {
payload: Schema.Struct({ loanId: Schema.String, bookId: Schema.String }),
handler: Effect.fnUntraced(function*(state, { bookId, loanId }, { aggregateId }) {
if (!state.joined) return yield* new MemberNotFound({ memberId: aggregateId })
if (state.loans.length >= loanLimit) {
return yield* new LoanLimitReached({ memberId: aggregateId, limit: loanLimit })
}
// Now the book decides, through its own command, from inside the member's decision.
const books = yield* Books
yield* books.commands.lend(bookId, { loanId, memberId: aggregateId })
return CallingMemberEvent.cases.LoanStarted.make({ loanId, bookId })
})
})

The book’s events are back in the book’s stream, and its rule runs:

examples/blog/without-saga.ts
const members = yield* AggregateService.make(CallingMember)
yield* books.commands.add("book-1", { title: "Dune", author: "Frank Herbert" })
yield* members.commands.join("alice", { name: "Alice Rossi" })
yield* members.commands.join("bob", { name: "Bob Bianchi" })
yield* members.commands.borrow("alice", { loanId: "loan-1", bookId: "book-1" })
const refused = yield* Effect.flip(members.commands.borrow("bob", { loanId: "loan-2", bookId: "book-1" }))
// refused._tag: "BookAlreadyLent". So far, so good.

This version passes every test you’d think of writing first. The problem is in a test you wouldn’t: one where something goes wrong between the two writes.

borrow now writes to two streams: the book’s, inside lend, then the member’s, when the handler returns. Those are two appends. No transaction spans them, and in general none can: the two streams may live in different tables, databases or services. So let’s lose the connection to the database at the worst moment, with a store that fails one append:

examples/blog/without-saga.ts
/**
* The store in memory, except that the first append with an event of this type fails,
* as it would if the connection to the database dropped at that moment.
*/
export const failingOnce = (eventType: string) =>
Layer.effect(
EventStore.EventStore,
Effect.gen(function*() {
const store = yield* EventStore.EventStore
const failed = yield* Ref.make(false)
return EventStore.EventStore.of({
...store,
append: (input) =>
Effect.gen(function*() {
if (input.events.some(({ type }) => type === eventType) && !(yield* Ref.getAndSet(failed, true))) {
return yield* new EventStore.EventStoreError({ operation: "append", cause: new Error("Connection lost") })
}
return yield* store.append(input)
})
})
})
).pipe(Layer.provide(EventStore.layerMemory))

Same code, same request, on that store:

examples/blog/without-saga.ts
yield* books.commands.add("book-2", { title: "Neuromancer", author: "William Gibson" })
yield* members.commands.join("carol", { name: "Carol Verdi" })
// The member's append fails, after the book's went through.
const failure = yield* Effect.flip(members.commands.borrow("carol", { loanId: "loan-3", bookId: "book-2" }))
// failure._tag: "EventStoreError"
const book = yield* books.state("book-2")
// { status: "lent", loan: { loanId: "loan-3", memberId: "carol", … }, … }
const carol = yield* members.state("carol")
// { joined: true, loans: [] }

The book is lent to Carol, for loan loan-3. Carol has no loan loan-3. The command failed, so the client tells Carol to try again later, and if she doesn’t, the book stays lent to a loan that doesn’t exist. Nothing in the system knows that a loan was half done, so nothing will come back to finish it or undo it.

A lost connection is only one way to get there. Any of these does the same:

  • The process stops between the two appends: a crash, a deploy, a scaled-down pod.
  • The member’s append loses a race. Another request for Carol got there first, so the service reloads her state and runs the handler again, which calls lend again. Here lend happens to be idempotent, so the second call records nothing. If the handler had instead picked “the first free copy”, the retry could lend a second one.
  • The member’s rules change their mind on the retry. After the reload Carol has three books, so the retry fails with LoanLimitReached, after the book was lent.

There are quieter costs too. A handler is meant to only decide, because it can run more than once. The member module now depends on the books service, and an aggregate whose handler calls another aggregate’s command can deadlock if that one ever calls back, since commands on one aggregate take turns. And when someone asks why a book is lent, the member’s stream has no answer.

The saga starts from a different question. Not “how do I write both changes at once?”, which has no good answer, but “how do I make sure the process finishes?”

The member only ever records its own decision. requestLoan checks the member’s rule and appends LoanRequested to the member’s stream, one atomic write to one aggregate. From that moment, the intention to borrow is a fact in the log, and the rest of the process can always be finished from it.

Then the saga carries the data from one aggregate to the next. This is the workflow of part 4:

examples/blog/library.ts
// One run per loan: lend the book and confirm the loan, or cancel it if the book is out.
export const Borrow = Saga.workflow("Borrow", {
payload: { loanId: Schema.String, memberId: Schema.String, bookId: Schema.String },
idempotencyKey: ({ loanId }) => loanId
})
export const BorrowWorkflow = Saga.toLayer(Borrow, ({ bookId, loanId, memberId }) =>
Effect.gen(function*() {
const books = yield* Books
const members = yield* Members
const lent = yield* Saga.command("lend", {
error: Schema.Union([BookAlreadyLent, BookNotFound]),
execute: books.commands.lend(bookId, { loanId, memberId })
}).pipe(
Effect.as(true),
Effect.catchTag(["BookAlreadyLent", "BookNotFound"], (error) =>
Saga.command("cancel", {
execute: members.commands.cancelLoan(memberId, { loanId, reason: error._tag })
}).pipe(Effect.as(false)))
)
if (lent) {
yield* Saga.command("confirm", { execute: members.commands.confirmLoan(memberId, { loanId }) })
}
}))

Look at what it does with the data. It reads the member id, the book id and the loan id from the member’s event, and hands them to the book’s command. The book decides with its own state and its own rule. The answer goes back to the member, as one of the member’s own commands, confirmLoan or cancelLoan. Every change is still made by a command on the aggregate that owns the data. The saga owns no data, and changes none directly: it puts together what several aggregates know, and asks each one to do its part.

Now the same failure, the same store, on the library of the series. This time the append that fails is the member’s LoanConfirmed, the saga’s last step:

examples/blog/without-saga.ts
yield* books.commands.add("book-2", { title: "Neuromancer", author: "William Gibson" })
yield* members.commands.join("carol", { name: "Carol Verdi" })
// One write, to one aggregate: the member records what she asked for.
yield* members.commands.requestLoan("carol", { loanId: "loan-3", bookId: "book-2" })
// The saga lends the book, fails to confirm the loan, tries again, and confirms it.
const outcome = yield* settled("carol", "loan-3")
// Option.some("LoanConfirmed")
const book = yield* books.state("book-2")
// { status: "lent", loan: { loanId: "loan-3", memberId: "carol", … }, … }
const carol = yield* members.state("carol")
// { …, loans: { "loan-3": { bookId: "book-2", status: "active" } } }

The loan settles: the book is lent to Carol, and Carol has the loan. Here is why:

  • Each step is one command on one aggregate, with one atomic append. A step either happened or it didn’t. There is no half of a step.
  • A step that fails is tried again. Saga.command retries a lost race or a store error a few times, with a backoff that starts at 50 milliseconds, so a dropped connection costs a short wait.
  • Finished steps are not done again. The workflow engine records the outcome of every step. A run that restarts after a crash skips the book’s lend, which already happened, and goes on with the confirmation. With a durable engine, that holds across restarts of the whole process.
  • The commands are idempotent, so a step that ran but whose outcome wasn’t recorded can run again harmlessly.
  • The state in between is explicit. Until the saga confirms or cancels it, Carol’s loan is requested. It holds one of her three places, so she can’t over-borrow while the book decides, and it shows that something is in progress. If a failure outlasts the retries, the run stops there, and the loan stays visibly requested instead of silently missing.

Here are the three versions side by side:

One aggregate records both One aggregate calls the other Saga
The book’s rule is checked No Yes Yes
The book’s events are in its stream No Yes Yes
A failure between two writes — Leaves them disagreeing Retried, then resumed
Where the process is recorded Nowhere Nowhere The member’s events and the engine
  • One command, one aggregate, one append. When a command needs to change a second aggregate, the first one records what it decided, and something else, a saga, asks the second.
  • An event names its own aggregate’s facts. A member’s event that says “the book was lent” is a fact the member had no right to decide.
  • A decision that needs another aggregate’s state is a process, not a command. Model the steps in between, such as requested, in the state, because they are real: the book really hasn’t answered yet.
  • If you can’t live with the step in between, check the boundary. A rule that must hold instantly across two aggregates may mean they are one aggregate. That is a design decision, and sometimes the right one. But “every loan in the library” is too big to be one aggregate, so here the saga wins.

What about a view model whose handler runs the book’s command? It gets closer: the member records its intention, and the handler sees every event at least once. But it keeps no record of which steps ran or which branch it took, so it can’t resume a half-done process or replay one the same way. The saga’s trigger is a view model, plus the workflow engine that keeps that record.

The series shows the whole saga, its trigger and its wiring in part 4, and the sagas guide covers compensations, timeouts and durable engines.