Skip to content
Chapter 104Lesson 4

ADR 0007: record the caching decision

Recording a silent caching decision as a Nygard ADR, then self-grading the review and ADR against the reference.

The review is written; now you record the one decision in this surface worth keeping after the diff is merged, and you grade your work against the reference.

Fill the shipped docs/adr/0007-cache-entitlement-reads-with-cacheTag.md scaffold with the four Nygard sections, then append its row to the docs/adr/README.md index. The decision is one the running code already made silently: getPlanEntitlement is cached with cacheTag, and nothing in the repo says why. When you finish, 0007 carries a dated Status, a Context naming the read pattern and the alternative it beat, a single declarative Decision line, and a Consequences list that names every mutation seam owing an updateTag call. The deliverable is this Markdown file; grade it and your review side by side against the reference under solution/. ADRs: one decision per file gave you the template and the three-test inclusion check; here you point both at a real decision.

Not every change earns an ADR. Run the three-test inclusion check from ADRs: one decision per file across the /plan surface’s candidate decisions — affects multiple files, a reasonable alternative exists, reversing costs more than one PR — and reject any candidate that fails one. Adding the planLabel field has no real architectural alternative and reverses in a single PR. Co-locating the src/lib/plan/ module is convention application — Principle #1, the shape every feature follows — not a new decision. Only caching the entitlement read survives: it shapes every future plan-touching surface (the overview, the seat counter, any gating read), it has a genuine alternative (per-request reads, or revalidatePath), and undoing it would cost a sweep of updateTag call sites. Recognizing why the other two don’t qualify is part of the work.

You fill the scaffold that ships in the starter: its H1 title and four empty ## sections are in place, and you write into them, deleting the <!-- TODO(L4) --> marker as you go. Three sections carry the weight. The Decision is one declarative sentence with no hedging: “we will cache…”, never “we’re considering” or “we should” or “maybe”. The hedge has a home — Context, as the rejected alternative, or Consequences, as the reversal cost — but never the Decision line. The Context must name the read’s access pattern and scale, and name the rejected alternative with a reason it lost; an ADR that pretends there was no choice records nothing a future maintainer can use. Consequences is the honesty test. Enumerate every mutation seam that must own an updateTag call rather than writing “every mutation must invalidate” — a rule nobody can grep is a rule nobody enforces. Name the updateTag-versus-revalidateTag cut for the background-job path with its lesson reference. State the staleness window the 'minutes' profile buys and why it’s acceptable. State the reversal cost plainly. Three bullets that are all upsides is a sales pitch, not a record; the future reader needs the trade-off to judge whether the decision still holds.

The filename ships correct, so the discipline here is reading why. The slug cache-entitlement-reads-with-cacheTag is a noun phrase of the decision, not a verb phrase of the change — not add-use-cache-to-getplanentitlement. An ADR names what the team decided, which is durable, not the patch that implemented it, which is ephemeral. Appending the 0007 row to the docs/adr/README.md index — already listing 00010006 — in the same edit is in scope. You reuse the Nygard template and three-test check from ADRs: one decision per file, the cacheTag/updateTag/revalidateTag decision tree and cacheLife profiles from earlier in the course, and the precedent in src/lib/invoices/queries.ts and its mutation seams. Out of scope: choosing a different cache-key strategy, re-litigating the caching decision, or editing the audited source — the only files that grow are the ADR and the index.

The three-test inclusion check is applied to each candidate decision, and only the caching decision is selected — the field-add and the co-location are rejected with a stated reason.
The ADR’s four sections are filled and the <!-- TODO(L4) --> marker is gone.
Status reads “Accepted” with a date.
Context names the entitlement read’s access pattern and names the rejected alternative (per-request reads, or revalidatePath) with the reason it lost.
The Decision is a single declarative sentence naming the cacheTag read and the updateTag invalidation commitment, with no hedging language.
Consequences enumerate every mutation seam that must call updateTag, name the revalidateTag(tag, 'max') background-job path with its lesson reference, state the 'minutes' staleness window, and state the one-PR reversal cost honestly.
The docs/adr/README.md index (already seeded with 00010006) carries an appended one-line 0007 entry.

Fill the ADR scaffold’s four sections and append the index row against the brief, then open the reference. Write your own version first — committing to a Decision line before you see the reference is where the value is.

Reference solution and walkthrough

The deliverable is one Markdown file plus one index line. We’ll read the filled ADR section by section against the brief, then cover the index row and the self-grade.

Here is the reference docs/adr/0007-cache-entitlement-reads-with-cacheTag.md, filled:

docs/adr/0007-cache-entitlement-reads-with-cacheTag.md
# ADR 0007 — Cache entitlement reads with cacheTag
## Status
Accepted — 2026-06-15
## Context
`getPlanEntitlement(orgId)` is the entitlement read behind `/plan` and every gate that asks "what plan is this org on, and how many seats does it have." It is a hot, org-scoped read: it runs on most app navigations, the value changes rarely (only when a mutation touches plan or entitlement state), and the surfaces that consume it — the `/plan` overview, seat counters, feature gates — tolerate a short staleness window but must reflect a write the moment the user who made it lands back on the surface (read-your-writes). This mirrors the existing `'use cache'` reads in `lib/invoices/queries.ts`, so a cached read is the established pattern, not a new invention.
Two alternatives were considered and rejected. **Per-request reads with no cache** keep the value trivially fresh but re-run the entitlement lookup on every navigation for a value that almost never changes — needless work on the hot path, and it forfeits the tag-based invalidation the rest of the stack uses. **`revalidatePath`** ties invalidation to routes, not to data: it would force every plan-touching route to enumerate itself at each mutation seam, and any new surface reading the entitlement would silently miss invalidation — the coupling points the wrong way. A data-keyed cache tag is the shape that matches the access pattern.
## Decision
We will cache `getPlanEntitlement(orgId)` with `cacheTag(orgPlanEntitlementTag(orgId))` (the tag string `org:{orgId}:plan-entitlement`) and `cacheLife('minutes')`, and invalidate it via `updateTag(orgPlanEntitlementTag(orgId))` from every mutation seam that touches plan or entitlement state.
## Consequences
- **Every plan/entitlement mutation seam now owns an `updateTag` call.** Today that is the `updatePlanLabel` action in `src/app/(app)/plan/actions.ts`; every future seam that writes plan or entitlement state (a Stripe-driven plan change, a seat-count adjustment) must call `updateTag(orgPlanEntitlementTag(orgId))` after the commit, before the redirect, through the `tags.ts` helper — never a raw string.
- **Background jobs and webhooks invalidate from the non-action path.** A job or webhook that mutates entitlement state outside a Server Action — no user sitting on a redirect — invalidates with `revalidateTag(orgPlanEntitlementTag(orgId), 'max')` (the eventual primitive, chapter 032), exactly as `src/server/jobs/summary-recompute.ts` does for the summary tag. The second `cacheLife` profile argument is mandatory in Next.js 16; the single-argument form is a type error.
- **Reads tolerate a bounded staleness window.** Between invalidations, the value can be up to one `'minutes'` profile stale. That is acceptable for the consuming surfaces; a write the user just made is fresh because `updateTag` runs synchronously in their action before the redirect.
- **The failure mode is a forgotten invalidation.** A mutation that touches plan or entitlement state without calling `updateTag` leaves the entitlement stale for the staleness window — silent, no error. Routing every write through the `tags.ts` helper and reviewing new mutation seams for the `updateTag` call is the guard.
- **Reversal is cheap.** Backing this out is one PR: delete the `'use cache'`/`cacheTag`/`cacheLife` annotation on the read and the `updateTag`/`revalidateTag` calls at the mutation seams. No data migration, no schema change.

The Decision line is one sentence, present tense, declarative: we will cache, and invalidate via updateTag. No “we think”, no “probably”, no “for now”. Every hedge has been routed elsewhere — the no-cache option lives in Context as a rejected alternative, the reversal cost lives in Consequences as its own bullet. The line sounds confident not because nothing will change, but because the doubt has a home elsewhere in the document.

The Context earns its length by naming the access pattern concretely — a hot, org-scoped read that runs on most navigations for a value that changes rarely — and then naming the precedent: the existing 'use cache' reads in src/lib/invoices/queries.ts, which turn this from a novel invention into a pattern the codebase already trusts. It also rejects two alternatives with reasons: per-request reads (needless work on a near-static value) and revalidatePath (couples invalidation to routes instead of data). A Context that says “we cached it” and stops has recorded a fact, not a decision; the rejected alternatives are the decision.

The Consequences list shows its honesty in not saying “every mutation must invalidate the tag.” It says: today the only such seam is updatePlanLabel in src/app/(app)/plan/actions.ts, and here is the rule the next seam inherits.

Why enumerate seams instead of stating a rule

Section titled “Why enumerate seams instead of stating a rule”

“Remember to invalidate after every plan mutation” feels complete and enforces nothing. Six months on, someone adds a Stripe-webhook handler that flips an org’s plan, ships it green, and never touches the entitlement tag — nothing pointed them at the obligation. The cache goes stale and a feature gate reads the old plan, silently.

Naming the seam converts that vague rule into a list to grep against and a contract the next write inherits explicitly. The list is one seam today, and that’s the point: you write the ADR while the surface area is still small enough to enumerate. The TSDoc on getPlanEntitlement is the second half of the guard — it puts the invalidation obligation on the read’s signature, where the next caller will see it.

The background-job cut, and where it’s owned

Section titled “The background-job cut, and where it’s owned”

A mutation inside a Server Action invalidates with updateTag; a background job or webhook — no user waiting on a redirect — uses revalidateTag(orgPlanEntitlementTag(orgId), 'max'). The ADR doesn’t re-explain that decision tree: it points at the precedent (src/server/jobs/summary-recompute.ts already does this for the summary tag) and names the caching chapters where the rule was taught. Recording it again here would be drift, so the ADR cites and moves on.

The 'minutes' profile is a recorded trade-off

Section titled “The 'minutes' profile is a recorded trade-off”

The staleness bullet names the window — up to one 'minutes' profile between invalidations — and says why it’s acceptable: entitlements change rarely, and a write the user just made is fresh anyway because updateTag fires synchronously before the redirect. A bare cacheLife('minutes') in the source is a value with no explanation; the ADR is where it becomes a trade-off someone chose on purpose, with the reasoning attached for whoever later asks “why minutes and not seconds?”

Append one line to docs/adr/README.md, beneath the existing 0006 row:

docs/adr/README.md
0007 — Cache entitlement reads with cacheTag — Accepted — 2026-06-15

The row mirrors the file’s title and Status, nothing more: the index is the at-a-glance map, the file is the detail. The slug cache-entitlement-reads-with-cacheTag is a noun phrase naming the decision, not a verb phrase like add-use-cache-to-getplanentitlement naming the change. The decision is what’s durable and what a future reader searches for; the patch that implemented it is forgotten the day after it merges.

Open the two reference deliverables under solution/ next to your own — only now, after your review and your ADR are written:

  • solution/reviews/chapter 104.md
  • solution/docs/adr/0007-cache-entitlement-reads-with-cacheTag.md

Read them side by side and score honestly. A real PR has no rubric, so the reflex you train here is grading your own pass against what a thorough one would have caught.

There is no automated checker for this chapter. lesson-verification/ ships empty and there is no pnpm test:lesson 4 — nothing machine-asserts on a written .md. You self-grade by hand against solution/, which is why every item below is untested.

Open solution/reviews/chapter 104.md and solution/docs/adr/0007-cache-entitlement-reads-with-cacheTag.md and grade side-by-side, ticking each off as you go:

Only the caching decision was selected by the three-test check; the field-add and the co-location were rejected with a stated reason.
untested
Status is “Accepted” with a date; Context names the alternative and rejects it with a reason; the <!-- TODO(L4) --> marker is gone.
untested
The Decision is one declarative sentence with no hedging tokens (“we should”, “we’re considering”, “maybe”).
untested
Consequences list the mutation seam(s) explicitly, name the revalidateTag(tag, 'max') background path with its lesson reference, name the 'minutes' window, and state the reversal cost.
untested
The five review comments score against the reference on coverage and severity match — five blocking: is the expectation; a 3/5 review that goes deep on cacheTag while silencing the Date math is a fail.
untested
Misses and wrong severities are read back into a personal review checklist for the next PR — the self-grade is the rehearsal, since a real review has no rubric.
untested

That last item is the one that compounds. The payoff isn’t the score on this surface — it’s the sharper checklist you carry into the next PR, where every miss here is a named blind spot you’ll catch next time.

ADR 0007 is not the last word on this decision, by design. When the cache moves to Redis or the entitlement model changes shape, you don’t edit 0007 or delete it: you write a new ADR that references it and flip this one’s Status to “superseded by ADR XXXX” — the supersession discipline from ADRs: one decision per file. The record of why the team once decided to cache is worth keeping even after the decision is reversed, because the next person reasoning about the cache needs the history, not just the present.