RequirementsGENERALLIFETIME-SPECIFICCONTESTED

Requirements Before Design

Design starts from what must be true, not from picking a structure. Seven questions decide almost everything that follows, and only one of them is about the happy path.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

A ticket arrives. What do I need to know before I can say what the code should look like?

The requirement

"Let customers cancel their subscription from the account page." One sentence, in the product manager's words, and everyone in the room believes they understand it.

The obvious build

It is a button and a status field. Add a cancel route, set status = 'cancelled', call the billing provider, show a confirmation. Anything more is over-thinking a one-line requirement.

Why it breaks

The provider call fails after the local status is written, so the database says cancelled and the provider keeps billing. Nothing in the code notices, and the customer finds out on their statement.

How it breaks as requirements change
  • The provider call fails after the local status is written, so the database says cancelled and the provider keeps billing. Nothing in the code notices, and the customer finds out on their statement.
  • The customer double-clicks. Two cancellations reach the provider, the second returns an error, and the error handler rolls back the status the first one set.
  • Finance ask "cancelled as of when?" — the effective date, not the click time — and there is nowhere to put the answer, because the design has one status and no dates.
  • Support ask to cancel on a customer's behalf, and the only cancellation path is a route authenticated as the customer. The requirement did not change; a hidden assumption did (The Requirements Nobody States).
  • Three months later someone asks for "pause" instead of cancel, and it turns out the design encoded a two-value status where the domain has a lifecycle (State Machines).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The team has an existing billing integration that already knows about cancellation, and re-implementing it is not on the table.
  • Finance need every cancellation reflected in the monthly revenue report, which is generated from the database rather than from events.
  • The change is wanted this sprint, so a design that requires a schema migration and a backfill is a different conversation.
Invariants
  • A cancelled subscription must never be billed again — not by the renewal job, not by a retry, not by a replay of a webhook.
  • A customer must never be told the cancellation succeeded when the provider has not accepted it.
  • Every cancellation must be attributable: who cancelled, when, and through which channel.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Someone must own the *decision* — is this subscription allowed to be cancelled right now — and it must not be the HTTP handler, because transport is not policy (Designing by Responsibility).
  • Someone must own reconciling local state with the provider's state, because they are two systems and they will disagree.
  • Someone must own the audit record, and it must be written by the same thing that makes the decision, not by a caller who might forget.
Boundaries
  • The seam falls between "decide whether this cancellation is legal and what it means" and "tell the provider". The first is a domain rule; the second is an integration that will be replaced.
  • The account page, the support tool and the API all sit outside that boundary and all call through it. If any of them can write status directly, the boundary is decorative (Invariant Leaks).
  • The boundary is drawn around the *subscription lifecycle*, not around "cancellation", because pause, resume and downgrade are all going to arrive and they are the same lifecycle.

The seven questions

These are not a process. They are the questions whose answers you will otherwise supply by accident, and the useful moment is the one where you cannot answer — because that is a decision about to be made without anyone making it.

Notice how the weight is distributed. Exactly one question is about what the feature does; six are about the properties it must have. That ratio is not an accident of how the list was written — it reflects where the design cost actually is, because the happy path fits inside almost any structure and the properties do not.

  • What does the user need? Not what the ticket describes. A ticket describes a mechanism; a need survives the mechanism being wrong.
  • What must never happen? The invariants. This question decides where the boundary goes (Invariants).
  • What can fail? Every call that leaves the process, plus the process itself, mid-write (Partial Failure).
  • What can change later? Which parts are volatile and which are stable. This is the question that decides what to hide (Information Hiding).
  • What must be fast? A budget, or the honest answer that there is not one (Cost-Aware Interfaces).
  • What must be secure? Who is allowed to do this, and where is that checked (Trust Boundaries).
  • What must be auditable? Who did what, when, and can we still answer that in a year (Stable Identifiers).
What each question decides
the least of itinvariants pick the seamadds branches the happy path has no room forvolatility picks what to hidecross-cutting, and structuralWhat does the user need?What must never happen?What can fail?What can change later?Fast / secure / auditableWhere the boundary goesThe shape of the code
UserLLMAgentToolDataDecisionHumanGuardrail

Two starting points, same feature

The difference below is not that one version is longer. It is that one of them can answer "what happens if the provider times out" and the other has to be rewritten to answer it.

The second version is also not the finished design — it is the point at which choosing a structure becomes a small decision rather than a large one, because the requirement has already ruled most structures out.

Starting from the structure vs starting from the requirement
Started by choosing a shape
// "It is a CRUD update, so: controller -> service -> repository."

async function cancel(req, res) {
  const sub = await repo.find(req.params.id)
  sub.status = 'cancelled'
  await repo.save(sub)
  await billing.cancel(sub.providerId)
  res.json({ ok: true })
}

// Every question the requirement raises is unanswered here,
// and none of them can be added without moving code:
//   - provider fails after save -> billed forever
//   - called twice -> second call errors, first already saved
//   - "cancelled as of when?" -> no field, no answer
//   - support cancels on behalf -> no actor, no audit row
Started from what must be true
// Invariants first: never bill after cancel; never confirm
// what the provider did not accept; always attributable.

type Actor = { kind: 'customer' | 'support' | 'system'; id: string }

// Pure decision. No I/O, no clock of its own.
function decideCancellation(
  sub: Subscription, actor: Actor, now: Instant,
): Result<CancellationDecided, CancelRefused> { /* ... */ }

// The effect, ordered so the provider is the source of truth
// for "is it really cancelled".
//   1. decide (pure)
//   2. record intent + audit row, one transaction
//   3. call provider
//   4. reconcile: intent -> confirmed, or leave for the job

The second version can answer the failure question without moving code, because the requirement forced the intent and the confirmation apart before anything was written. The first cannot: the provider call sits after a commit, so every failure branch has to be retrofitted into a structure that assumed there were none. The cost is real — the second is four moving parts where the first is one, and a reconciliation job that has to exist, be deployed and be monitored. That cost buys exactly one thing, which is that the invariant holds when the provider is down.

When the answers are all trivial

The questions are not a gate and they are not proportional to ticket size. Their whole value is that they are cheap enough to ask every time, which means most of the time they finish in under a minute and change nothing.

What follows is the honest version of "when do I actually stop and design". It is not about how big the feature is; it is about which of the seven questions has a non-trivial answer.

You have asked the seven questions. What now?

Which questions came back with something you cannot answer in a word?

All seven trivial

when A read-only endpoint, an added column on an internal admin screen, a copy change.

cost Write the obvious code. The questions cost you sixty seconds and saved nothing this time, which is the expected outcome most days and is not an argument against asking.

Only "what can change later" is non-trivial

when The rule is simple now, but three product lines are coming and each will want its own version.

cost Name the volatile part and give it one address. Do not build the abstraction yet — you do not know its shape (The Rule of Three).

"What must never happen" is non-trivial

when Money, entitlements, tenancy, anything a customer can see and dispute.

cost Stop and decide where the invariant is enforced before writing anything. This is the question with the worst retrofit cost in the list (Where Invariants Live).

"What can fail" is non-trivial

when The feature crosses a network, a queue or a process boundary at any point.

cost Design the failure branches first; the happy path will fit into whatever shape they require, and it will not fit the other way round (Failure-Aware Feature Design).

Auditable or secure came back "nobody has said"

when The common case, and the dangerous one.

cost Go and ask, and treat the answer as unknown rather than absent until you have it. Retrofitting an audit trail means backfilling data that was never recorded, which is not a code change (Data Migration).

How to build it

Most important first.

  • Ask the seven questions before choosing anything: what does the user need, what must never happen, what can fail, what can change later, what must be fast, what must be secure, what must be auditable. Most of them have short answers; the value is in the ones that do not.
  • Write the invariants down first, because they decide where the boundary goes and nothing else does (Where Invariants Live).
  • Model the lifecycle, not the flag. Once you have asked "what can change later", "cancelled" stops being a boolean and becomes one state among several (Explicit State).
  • Decide what happens when the provider call fails *before* writing the happy path, because that decision changes the shape of the code and the happy path does not (Designing the Happy Path Last).
  • Only then choose a structure. By this point the structure is mostly implied, which is the point — a design chosen before the questions is a guess that has to be defended.

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Under the flag design, "pause" costs: a second flag or a status string nobody validates, an edit to every query that filters on status, a new branch in the renewal job, and a re-reading of the provider integration to find out what it does with a paused subscription. Roughly a week, most of it discovery, and the risk is silent — the failure mode is a subscription that quietly keeps billing.
  • Under the lifecycle design, "pause" costs: one state, two transitions with guards, and one case added to the renewal job's exhaustive match. The compiler names every place that has to change if the language has sum types; a test names them if it does not. Roughly a day, and the risk is loud.
  • What did not get cheaper: a change to *how* cancellation is stored — a move to an event log, say — still crosses the boundary, because the boundary was drawn around the rule and not around persistence. And the seven questions cost real time on every feature, including the ones where the answers are all trivial.
What the recommended approach costs
  • Asking seven questions costs time on every feature, and for a genuinely trivial change most of the answers are "nothing" — that time is not recovered.
  • Modelling a lifecycle before the second state exists is speculative, and a critic would call it exactly that. The defence is that "cancelled" was never one state; the naive design was already modelling a lifecycle badly.
  • Answering "what can fail" early biases toward structures with room for failure handling, which are more indirect than the happy path needs. That indirection is paid by every reader.

What can go wrong

Failure modes
  • The seven questions get asked and the answers are never written down, so the next engineer re-derives them wrongly and the design drifts back to a status flag.
  • The questions become a form. Filled in after the implementation, they document what was built rather than deciding it, which is worse than not asking because it looks like diligence.
  • A question with no answer is treated as "no requirement" rather than "unknown". "Nobody said it has to be auditable" and "we asked and nobody knows" are very different states and only one of them is safe to build on.
Dependencies, and their direction
  • The lifecycle owner depends on nothing volatile: no HTTP, no provider SDK, no clock it did not receive (Time as a Dependency).
  • The provider integration depends on the lifecycle owner, never the reverse — the direction is the design decision, and it is what makes the provider replaceable (Dependency Direction).
  • The audit trail depends on the decision, which is why it lives with the decision rather than in the handler.
Misreads
  • "This is requirements gathering, which is the product manager's job." The seven questions are not about scope, they are about the properties the code has to have, and product managers reliably do not know whether cancellation has to be idempotent.
  • "So we need a design document for every ticket." No. Most of the seven answers are one word. The document is the code plus a short note; the deliverable is the decision, not the artefact.
  • "If we get the requirements right the design follows mechanically." It does not. Requirements constrain the design space heavily and leave several defensible options inside it, which is the rest of this domain (The Trade-off Matrix).
  • "Nobody stated an audit requirement, so there is not one." Audit, retention and tenancy are the three requirements that are almost never stated and are almost always real (The Requirements Nobody States).

Testing it, and how it ages

What to test, and at which boundary
  • Test the lifecycle decision with no HTTP, no database and no provider: given a subscription in state X, is this transition legal? If that test needs a container, the boundary is in the wrong place (Testing as Design Feedback).
  • Test the failure branch explicitly — provider rejects, provider times out, provider succeeds but the response is lost — because those are the branches the naive design does not have.
  • One integration test asserting the invariant the business actually cares about: a cancelled subscription is not billed by the renewal job (Where a Test Must Be Real).
How this design ages
  • The first three follow-up requirements — pause, cancel-at-period-end, support-initiated cancellation — all land inside the lifecycle boundary. That is when the up-front questions are repaid, and it is usually within a quarter.
  • The boundary stops being right when subscriptions genuinely diverge by product line and one lifecycle becomes a lifecycle with a dozen conditionals (Replace Conditional With Polymorphism).
  • The questions themselves do not age. Their answers do, which is why they belong next to the code rather than in a ticket (Docs Close to Code).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat structure should follow from stated properties rather than from a chosen pattern holds across languages and paradigms; what changes is which properties the language can enforce for you rather than which questions are worth asking.
  • LIFETIME-SPECIFICFor a spike or an internal tool with three users and a known deletion date, the honest answer to "what must never happen" is often "nothing expensive", and the flag design is correct. The seven questions still get asked; they just get answered quickly. Where this differs is code that must keep absorbing requirements for years.
  • CONTESTEDThe strongest opposing view: requirements elicited up front are frequently wrong, and building the simplest thing that works produces better information about what is actually needed than any amount of questioning does — so a status flag shipped this week and reshaped next month beats a lifecycle designed against imagined futures. That argument is genuinely strong for the functional shape of a feature. It is much weaker for the properties in the invariant and failure questions, because those are the ones that are expensive to retrofit and whose absence is silent in production.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — the same questions at a larger grain, where "what can fail" means a whole node or region rather than a single call, and the answers reshape topology rather than modules.