What it looked like last Tuesday
A post after the series CQRS with effect-cqrs, on its lending library. November 2026.
Monday morning, Bob is at the desk. Last Tuesday he asked for Dune on the website, and the library refused. Today Dune is on the shelf. Was the website broken? Is he being treated differently? He would like to know, and so would the librarian.
Support tickets look like this everywhere: why did the system do that, back then? This
post answers Bob’s from the library’s log. Every snippet runs in
examples/blog/last-tuesday.test.ts,
which lets the days pass in between.
Tuesday
Section titled “Tuesday”This is what happened, with the library and the borrowing saga of the series:
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.requestLoan("alice", { loanId: "loan-1", bookId: "book-1" })yield* settled("alice", "loan-1")yield* members.commands.requestLoan("bob", { loanId: "loan-2", bookId: "book-1" })yield* settled("bob", "loan-2")Alice asked first and got the book. Bob asked a moment later, and the saga cancelled his loan. On Friday Alice brought it back.
What a table can say
Section titled “What a table can say”The books table says Dune is available, lent_to empty. That is true, and it is all the
table knows. Was it available last Tuesday at 10:04? It doesn’t say. Why was Bob refused? A
request log might have a line, if it is still kept, if it was logged at all, and if someone
can find it among a million others. Most support tickets like Bob’s end with “we can’t tell,
sorry”, or with a developer reading code and guessing.
The log can say more, because it never threw anything away.
const today = yield* books.state("book-1")// { status: "available", loan: null, … }: on the shelfStart from Bob
Section titled “Start from Bob”Bob’s own history says what happened to his request, with the reason the saga recorded:
const bob = yield* members.history("bob", { payload: true })const refusal = bob.find(({ type }) => type === "LoanCancelled")!// { type: "LoanCancelled", payload: { loanId: "loan-2", reason: "BookAlreadyLent" },// timestamp: Tuesday, causationId: "member/bob@2", correlationId: "…" }LoanCancelled, reason BookAlreadyLent, on Tuesday. Two more fields come with every event:
- the causation id names what directly caused it:
member/bob@2, the second event in Bob’s stream, his ownLoanRequested. The saga that cancelled the loan was started by his request, and said so. - the correlation id is shared by everything that followed from the same original request, however many steps and aggregates away.
The book, back then
Section titled “The book, back then”Was the book really lent? Ask the book, as it was at the moment of the refusal:
// The book as it was when Bob was refused. `finishTime` leaves out events from that// instant on, so take the next millisecond.const backThen = yield* books.state("book-1", { finishTime: DateTime.add(refusal.timestamp, { milliseconds: 1 }) })// { status: "lent", loan: { loanId: "loan-1", memberId: "alice", dueOn: … }, … }state with a finishTime folds the book’s events up to that instant, the same fold that
gives today’s state, cut short. At the moment Bob was refused, the book was lent to Alice,
with its due date. The refusal was right, and the librarian can show why, with the
evidence.
This isn’t a feature added for support. It falls out of keeping events: a state is a fold of events, and a fold can stop anywhere. Every aggregate can be seen as it was at any instant, for free, from the day the first event was written.
One request, end to end
Section titled “One request, end to end”The correlation id goes further. Gather every event that shares it, from every aggregate:
// Everything one request caused, across aggregates, by its correlation id.const causedBy = (correlationId: string | null) => store.read().pipe( Stream.filter((event) => event.correlationId === correlationId), Stream.map(({ aggregateId, aggregateName, type, version }) => `${aggregateName}/${aggregateId}@${version} ${type}` ), Stream.runCollect )
const bobsRequest = yield* causedBy(refusal.correlationId)// ["member/bob@2 LoanRequested", "member/bob@3 LoanCancelled"]
const alicesRequest = yield* causedBy( (yield* members.history("alice")).find(({ type }) => type === "LoanRequested")!.correlationId)// ["member/alice@2 LoanRequested", "book/book-1@2 BookLent", "member/alice@3 LoanConfirmed"]Bob’s request is two events: asked, cancelled. The book has none, because it refused and a refusal writes nothing. Alice’s request, read the same way, is three events in two aggregates: she asked, the book was lent, her loan was confirmed. The whole saga, in order, from a single id.
What else it answers
Section titled “What else it answers”The same two tools, a state at an instant and events by correlation, answer questions that are hard or impossible with tables:
- Disputes. “I returned it on time.” The
BookReturnedhas a timestamp and thelatedecision, and the state just before it has the due date. - Audits. Who changed what, when, as a result of which request. With metadata, also by which user or script.
- Bug reports. “It said available, then refused me.” The events of that request, and the state each aggregate was in, are the reproduction.
- Reports as of a date. Fold the events before a date into a report, and it says what the numbers were then, not what they are now with today’s corrections mixed in.
Rules of thumb
Section titled “Rules of thumb”- Record the reason in the event.
LoanCancelled { reason }answers Bob in one line. A bareLoanCancelledsends someone to read code. - Keep correlation and causation flowing. effect-cqrs carries them through commands, view models and sagas. Bring the correlation id in from the edge, the HTTP request or the message, and the trail starts where the user did.
- Answer support from the log, not from logs. Application logs are for operators, rotate away, and say what the code thought. The event log says what the system decided, and it is kept.
Sagas in the series builds the borrowing process this post investigates, and the aggregates guide covers state, history, correlation and causation.