Skip to content

Change the rules, keep the history

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

A librarian on a ladder hangs a new rules board next to the old one, while at the desk below a colleague stamps a new card in a different colour beside a row of cards stamped before

Every summer the library relaxes its rules: five books instead of three, four weeks instead of three. On the first of September the old rules come back. In the CRUD version of the library, the loan period is a setting, and due dates are worked out when they are shown:

SELECT title, lent_on + (SELECT loan_days FROM settings) * interval '1 day' AS due_on
FROM loans WHERE returned = false;

On September 1st someone changes loan_days from 28 to 21. Every loan made in August is now a week shorter, and the reminder job emails a few hundred members that their books are overdue. They were told four weeks, and they were right.

Business rules change: prices, limits, periods, fees, eligibility. This post shows where they belong in an event-sourced system so that a change applies to new decisions and leaves the old ones alone. Every snippet runs in examples/blog/change-the-rules.test.ts, which moves the clock from August to September.

The library’s rules are a list, each with the day it starts:

examples/blog/change-the-rules.ts
export interface Policy {
readonly name: string
/** From when it applies, inclusive. */
readonly from: DateTime.Utc
readonly limit: number
readonly days: number
}
/** The library's lending rules over time, newest last. */
export const policies: ReadonlyArray<Policy> = [
{ name: "standard-2025", from: DateTime.makeUnsafe("2025-01-01T00:00:00Z"), limit: 3, days: 21 },
{ name: "summer-2026", from: DateTime.makeUnsafe("2026-06-01T00:00:00Z"), limit: 5, days: 28 },
{ name: "standard-2026", from: DateTime.makeUnsafe("2026-09-01T00:00:00Z"), limit: 3, days: 21 }
]
/** The policy in force at a given time. */
const policyAt = (at: DateTime.Utc): Policy =>
policies.findLast(({ from }) => DateTime.isLessThanOrEqualTo(from, at)) ?? policies[0]!

A rule change is a new entry, deployed before its date. The old entries stay, because they say what the rules were, and that will be asked.

Decide with the rules, record the decision

Section titled “Decide with the rules, record the decision”

The member aggregate runs the rules in one place, the command handler, on the day it decides:

examples/blog/change-the-rules.ts
export const MemberEvent = Schema.TaggedUnion({
// The decision, with the rule that made it: the due date the member was given, and why.
BookBorrowed: { bookId: Schema.String, dueOn: Schema.DateTimeUtc, policy: Schema.String },
BookReturned: { bookId: Schema.String }
})
export interface MemberState {
/** The books the member has, with the due date each was given. */
readonly books: Readonly<Record<string, DateTime.Utc>>
}
export const Member = Aggregate.make<MemberState>()({
name: "member",
events: MemberEvent,
initial: { books: {} },
// The projection records what was decided. It checks no rule, so a member who kept five
// books under the summer rules still loads in September.
projection: {
BookBorrowed: ({ books }, { bookId, dueOn }) => ({ books: { ...books, [bookId]: dueOn } }),
BookReturned: ({ books }, { bookId }) => {
const { [bookId]: _, ...rest } = books
return { books: rest }
}
}
})
.command("borrow", {
payload: Schema.Struct({ bookId: Schema.String }),
// The rules live here, and run on the day of the decision.
handler: Effect.fnUntraced(function*({ books }, { bookId }, { aggregateId }) {
const now = yield* DateTime.now
const policy = policyAt(now)
if (Object.keys(books).length >= policy.limit) {
return yield* new LoanLimitReached({ memberId: aggregateId, limit: policy.limit })
}
const dueOn = DateTime.add(now, { days: policy.days })
return MemberEvent.cases.BookBorrowed.make({ bookId, dueOn, policy: policy.name })
})
})
.command("giveBack", {
payload: Schema.Struct({ bookId: Schema.String }),
handler: ({ books }, { bookId }) =>
Effect.succeed(books[bookId] === undefined ? [] : MemberEvent.cases.BookReturned.make({ bookId }))
})

Two choices in it carry the whole post:

  • BookBorrowed stores the due date, not the date it was lent plus a period to add later. The due date is what the member was told. It is a decision, made once, under the rules of that day, and it is a fact from then on.
  • It also stores which policy decided it. A member who asks why her book is due on the 17th gets an answer: the summer rules. So does an auditor.
examples/blog/change-the-rules.ts
for (const bookId of ["dune", "emma", "ulysses", "solaris"]) {
yield* members.commands.borrow("alice", { bookId })
}
// Four BookBorrowed, each due in 28 days, under "summer-2026"

Alice borrows four books under the summer rules, each due in four weeks.

The standard rules are back. Alice’s state, and a few commands:

examples/blog/change-the-rules.ts
const alice = yield* members.state("alice")
// Four books, each with the due date she was given in August, four weeks after she took it
const refused = yield* Effect.flip(members.commands.borrow("alice", { bookId: "emma-2" }))
// refused: LoanLimitReached { limit: 3 }
yield* members.commands.giveBack("alice", { bookId: "dune" })
yield* members.commands.giveBack("alice", { bookId: "emma" })
const borrowed = yield* members.commands.borrow("alice", { bookId: "neuromancer" })
// borrowed.events: [BookBorrowed { bookId: "neuromancer", dueOn: in 21 days, policy: "standard-2026" }]
  • Her four books load, with the due dates from August. Nothing moved, because nothing was worked out again.
  • Her fifth request is refused under the new limit of three, although she has four. The rule runs on the decision being made now, with today’s rules.
  • After she brings two back, she borrows again, and the new loan is due in three weeks, under standard-2026.

Look at the projection again: it checks nothing. A member with four books when the limit is three is a state it folds without complaint. That is on purpose.

A projection runs on every load, over every event ever recorded, with today’s code. If it checked today’s limit, Alice’s stream would fail to load the moment the rules changed, and she could neither borrow nor return a book. Worse, the projection would be judging decisions made under rules it doesn’t know about. Events are what happened. A projection that disagrees with them is wrong, not the events.

So rules go in command handlers, where they decide about the future, and projections only record what was decided.

Fix the code, not the data recommended working out conclusions in read models, so that a replay can fix them. This post stores the due date in the event. Both hold, because they are different things:

  • A decision someone was told is a fact. The due date on Alice’s slip, the price on an invoice, the fee in a confirmation email. Store it in the event, with what decided it.
  • A judgment you may revise is a conclusion. Whether a member is a frequent borrower, which books are popular, whether a return was late by a newer definition. Work it out in a read model, from the facts.

If you are unsure which one you have, ask whether a customer would be upset if it changed after the fact. If yes, it is a fact.

  • Rules run in command handlers, on the day of the decision. Never in projections.
  • Store the outcome, not the inputs and a formula. dueOn, not lentOn plus a setting.
  • Record which rule decided. A name or a version in the event answers “why” for years.
  • Keep old rules around with their dates. They explain the history, and a rule that takes effect later is deployed before its date like any other entry.
  • Load whatever the log says. A state that today’s rules wouldn’t allow is a state that older rules did.

The wrong event covers the other kind of change: a decision that was wrong when it was made, and how to correct it.