# The saga you skipped

> Borrowing a book without a saga, in the two ways that look right: one aggregate that records another's events, and one that calls another's commands. What breaks, and what the saga does instead.

*A post after the series [CQRS with effect-cqrs](https://effect-cqrs.dambox.ch/blog/), 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](https://effect-cqrs.dambox.ch/_astro/the-saga-you-skipped.2dF_e-d7_Z1kxY5r.webp)

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](https://effect-cqrs.dambox.ch/blog/sagas/) does differently. Every snippet runs in [`examples/blog/without-saga.test.ts`](https://github.com/davidnussio/effect-cqrs/blob/main/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

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

```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

```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.

Note

An aggregate is a consistency boundary. It can guarantee its rules because every change to its data goes through its own commands, in turn, checked against its own version. **A rule can only be enforced by the aggregate that owns the data the rule is about.**

## Mistake 2: one aggregate calls the other

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

```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

```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

```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

```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 same loan, with a saga

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](https://effect-cqrs.dambox.ch/blog/sagas/):

examples/blog/library.ts

```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

```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 |

## Rules of thumb

* **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.

## Next

The series shows the whole saga, its trigger and its wiring in [part 4](https://effect-cqrs.dambox.ch/blog/sagas/), and the [sagas guide](https://effect-cqrs.dambox.ch/guides/sagas/) covers compensations, timeouts and durable engines.
