FeaturesGENERALDOMAIN-SPECIFICCONTESTED

Designing a Feature Before Writing It

Eight questions stand between a ticket and the first line of code. Skipping one does not remove the decision — it relocates it to whichever branch of the code happens to run first.

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 says "let customers pause their subscription". What has to be decided before I open an editor, and in what order?

The requirement

Support keeps asking for a way to stop billing a customer for a couple of months without cancelling them, because customers who cancel to save money mostly do not come back. The ticket is one line: "Add Pause Subscription".

The obvious build

Add a paused boolean to the subscriptions table, check it in the billing job, expose POST /subscriptions/:id/pause that sets it true. Forty lines, one migration, ships Tuesday. And it is genuinely the right amount of code for the requirement as written — the problem is not that it is lazy, it is that the requirement as written is not the requirement.

Why it breaks

The second requirement is "pause for three months, then resume automatically". A boolean has nowhere to put the resume date, so pause_until appears beside it, and now one concept is two columns with a fourth combination — paused = false with a future pause_until — that nothing handles (Boolean Flag Explosion).

How it breaks as requirements change
  • The second requirement is "pause for three months, then resume automatically". A boolean has nowhere to put the resume date, so pause_until appears beside it, and now one concept is two columns with a fourth combination — paused = false with a future pause_until — that nothing handles (Boolean Flag Explosion).
  • The third is "how many customers are paused, and for how long". Unanswerable: a boolean stores a state, never a history, and the transitions that would have answered it were never written down (Explicit State).
  • The fourth is "what happens if they pause mid-cycle" — which the ticket never asked, so the answer is whatever the billing job's ordering happens to produce, and it is different in the admin tool.
  • Meanwhile the renewal job and the entitlement check both read the column directly. The rule "paused means not charged" now lives in two places and is guaranteed in neither (Where Invariants Live).
  • None of these are bugs in the forty lines. They are the forty lines being asked a question they have no vocabulary for, which is what "hard to change" means in this domain (What Makes Software Hard to Change).
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
  • Billing already runs as a nightly job that selects every active subscription with a renewal date of today. It serves four other products and is not being rewritten for this.
  • Subscriptions are in production with three states and a renewal date. No downtime, and no repricing of anyone who already exists.
  • Finance needs pause volume and duration reportable, which means a pause has to be a recorded event rather than the absence of a charge.
  • Two engineers, two weeks, alongside on-call. A design that needs four weeks is not a design for this team (Constraints Are Part of the Design).
Invariants
  • A paused subscription is never charged. That is the whole feature, and it is the only thing a bug here violates in a way a customer sees.
  • A subscription is in exactly one state at any instant. There is no "paused and also renewing", even transiently, even inside a job.
  • Entitlement and billing never disagree by accident: whatever access a paused customer has, it is a decision someone made and not a side effect of which query ran.
  • Every pause and resume is attributable — actor, timestamp, reason — because the first question finance asks is "who did this".

Who owns what, and where the seams fall

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

Responsibilities
  • The subscription owns its lifecycle: which states exist, which transitions are legal, what a pause does to the renewal date. Nothing else is allowed to decide that (Aggregates).
  • The billing job owns "who is due tonight" and answers it by asking the subscription, not by reproducing the rule in a WHERE clause.
  • The application layer owns the things true of every command and not specific to pause: authorization, idempotency, the transaction boundary, the audit record.
  • Entitlement owns what a paused customer can see, and depends on the state rather than on the reason for it (State Ownership).
Boundaries
  • The domain seam falls around the lifecycle, not around the endpoint. pause() is a method on a subscription that can be wrong; a handler that writes a row cannot be wrong, only unfinished.
  • The persistence seam is where states become columns, and it is the only place allowed to know that paused is stored as the string PAUSED (Information Hiding).
  • Between the command and the nightly job the seam is an event or a re-read, never a shared column two processes interpret independently — they run hours apart and must not depend on each other's reading of the same bytes (Temporal Coupling).

Eight questions, and how each one fails when skipped

Read these as prompts, not as a process. The useful moment is the one where you cannot answer — because that is a decision you were about to make by accident, and it will still get made, just by whichever code path runs first.

The order is not decoration. The domain rules decide which states exist; the states decide what the interface must accept; the interface decides what can be tested without a database. Answering out of order means guessing at the input to the next answer, and the guess is invisible once code is written on top of it.

Before the first line: Add Pause Subscription
  1. 1
    User goal

    What is the customer actually trying to do? "Stop paying for a while without losing my history."

    fails by Building the mechanism in the ticket. Cancel-and-resubscribe passes the ticket and loses the customer's history, which was the point.

  2. 2
    Domain rules

    Max length, pauses per year, mid-cycle behaviour, expired card, annual plans.

    fails by The rules get decided by implementation order and differ between the API and the admin tool (The Requirements Nobody States).

  3. 3
    State changes

    Which states, which transitions, which guards, which transitions must be impossible.

    fails by A boolean, then a second boolean, then four combinations nobody has thought about (Boolean Flag Explosion).

  4. 4
    Interfaces

    What a caller supplies, what comes back, what stays hidden.

    fails by The endpoint takes paused: boolean and the lifecycle is now part of the public contract (Exposing Too Much).

  5. 5
    Persistence

    How state is stored, what happens to existing rows, what is readable mid-rollout.

    fails by A migration that requires the deploy to be atomic with the code, which it never is (Expand and Contract).

  6. 6
    Errors

    Already paused, cancelled, mid-charge, unknown subscription — four different kinds of wrong.

    fails by One catch-all 400, so support cannot tell a retry from a real conflict (An Error Taxonomy That Survives Contact).

  7. 7
    Observability

    What someone types in six weeks to answer "why was this charged on the 3rd".

    fails by The answer exists only in the database's current state, which has since changed (Debuggability by Design).

  8. 8
    Tests

    What is asserted, at which boundary, and which test must never be deleted.

    fails by Tests written after the code, describing what it does rather than what it must do (Testing as Design Feedback).

Half a day for a feature of this size. If it takes longer than the implementation, either the feature is trivial and you should stop, or the feature is not the one in the ticket and you have just found that out cheaply.

What the ticket does not say

SIMPLIFIEDA real pause feature has perhaps thirty of these, not six; the table is trimmed to the ones that change the model rather than the copy. The ones omitted — email wording, whether the button is in settings or billing — are real work but do not move a boundary, and that distinction is the one worth practising.

A one-line ticket is not underspecified by accident. The person writing it knows the outcome they want and does not know which decisions their sentence implies — that mapping is the engineering, and it is not delegatable back to them without the list in front of them.

The third column is the one that matters. Every open question gets answered; the choice is only whether it is answered by a person or by an execution order. Nothing on this list can be deferred, because shipping is itself an answer to all of them.

The ticket saysThe decision it hidesWho answers it if you do not
"Let customers pause"Pause for how long, and how many times a year?The first customer who pauses for eleven months, and then finance.
"Pause"Mid-cycle: refund, prorate, or pause at period end?Whichever branch of the billing job runs first, differently in the admin tool.
"Pause"Does a paused customer keep access to their data? To the product?Support, on the phone, inventing a policy that then becomes the policy.
(silent)What happens when the card expires while paused?The dunning job, which does not know the state exists and emails them anyway.
(silent)Can an annual plan pause, and what does that even mean?A sales rep, who promises it on a call before anyone has decided.
(silent)Is pause reversible by the customer or only by support?The endpoint's authorization check, which was copied from the cancel endpoint.

Priced against the boolean

The argument for the lifecycle is not that it is cleaner. It is that a specific, near-certain second requirement — automatic resume — costs one transition under one design and a re-audit of every reader under the other. That is a claim you can check, and it is the only kind worth making here.

Notice what the better design does not fix. Both columns pay the same price for a change to revenue recognition, and the lifecycle costs a vocabulary that every future reader has to learn. A design that made every change cheap would not be a design; it would be a sales pitch.

Second requirement: "pause for three months, then resume automatically"
The change

A pause now carries an end date, and the subscription must return to active on that date without anyone touching it.

`paused` boolean checked in the billing job
subscriptions tableBillingJobEntitlementCheckAdminSubscriptionViewDunningJobFinanceExportPauseEndpoint
testsbilling_job_testentitlement_testadmin_testfinance_export_testpause_endpoint_test
7 modules · 5 test files

A second column, a backfill, and then the real work: finding every read of paused and deciding, per site, whether it meant "not billing now" or "not billing indefinitely". Those were the same thing until this ticket and are not any more, and nothing in the code records which meaning each site relied on.

Explicit lifecycle owned by the subscription
SubscriptionLifecycleResumeScheduler
testslifecycle_transition_testresume_scheduler_test
2 modules · 2 test files

One transition with a date guard, one scheduled trigger that fires it. The billing job, entitlement and dunning are untouched because they ask the subscription rather than reading its columns — the whole return on the earlier work is in that sentence.

what it cost The lifecycle is roughly three times the code of the boolean and introduces vocabulary — states, transitions, guards — that every future reader must learn before making a one-line change. It also concentrates change: pricing, dunning and pause now queue behind the same file and the same test suite, where before they edited different places. And it did nothing for the mid-cycle proration question, which is a domain decision that no structure answers.

How to build it

Most important first.

  • User goal. Not "a pause button" — "stop paying for a while without losing my history or my seat". Cancel-and-resubscribe satisfies the ticket and fails the goal, and noticing that is the whole value of asking (Requirements Before Design).
  • Domain rules. Maximum pause length, how many pauses per year, mid-cycle behaviour, what happens to a pause when the card on file expires, whether an annual plan can pause at all. Almost none of this is in the ticket, and every answer changes the model (The Requirements Nobody States).
  • State changes. Name the states, the transitions, the guards, and — the part that gets skipped — the transitions that must not exist (State Machines, Invalid Transitions).
  • Interfaces. What a caller must supply to pause, what it gets back, and what stays hidden. If the endpoint takes a paused: boolean, the lifecycle has already leaked (Designing a Module Interface).
  • Persistence. How the state is stored, what the migration does to rows that exist, and whether the old column stays readable during the rollout (Expand and Contract).
  • Errors. Pause on an already-paused subscription, on a cancelled one, on one being charged right now. Each is a different kind of failure and they must not share a code path (An Error Taxonomy That Survives Contact).
  • Observability. What a support engineer types to answer "why was this customer charged on the 3rd" six weeks from now (Debuggability by Design).
  • Tests. Which of the above is asserted, at which boundary, and which single test must never be deleted. Deciding this now is what stops the tests from merely describing whatever got built (Testing as Design Feedback).

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 boolean, adding auto-resume costs a column, a backfill of every existing row, a new branch in a job that serves four products, and a re-audit of every read of paused — seven call sites, listed nowhere, found by grep.
  • Under an explicit lifecycle, auto-resume is one transition with a guard plus one scheduled trigger. The billing job does not change at all, because it already asks "is this due" instead of reading a flag.
  • The third state — past_due, which is coming whether you plan for it or not — costs one state and its transitions under the lifecycle, and a second boolean plus its four illegal combinations under the flag. That is where the two designs separate for good.
  • What stays expensive under both: changing what a pause means to revenue recognition. That knowledge lives in a finance spreadsheet, and no amount of code structure moves a boundary that runs through another department.
What the recommended approach costs
  • The eight questions cost half a day before any code exists, and on a feature that genuinely is a boolean, that half day is not repaid. The skill is telling the two apart, and the tell is whether the thing has a lifecycle.
  • An explicit lifecycle is more code and more vocabulary. Someone making a one-line change must now learn the states first, and for a reader passing through once that is pure cost (What an Abstraction Costs).
  • Deciding the mid-cycle rule up front means deciding it with less information than you would have after shipping. Some decisions genuinely are better made late; the ones worth making early are the ones that are expensive to reverse (Reversible and Irreversible Decisions).

What can go wrong

Failure modes
  • The pause commits at 23:59 and the billing job selected its batch at 23:55. The customer is charged, the state says paused, and both are correct in their own frame (Temporal Coupling).
  • The request is retried by an impatient client and the resume date moves twice (Idempotency by Design).
  • The design is done well and recorded nowhere, so the admin tool implements the mid-cycle rule differently three months later (Docs Close to Code).
  • The lifecycle is right and the finance report is built on the paused column that was left behind "for compatibility", so the model and the number diverge silently.
  • The mitigation fails too: an audit table is added, nobody reads it, and it becomes a large unindexed table that is trusted precisely because nobody has checked it.
Dependencies, and their direction
  • The billing job depends on the subscription lifecycle. The lifecycle depends on nothing in billing — that direction is the design, and reversing it is how the rule ends up in a query (Dependency Direction).
  • The lifecycle depends on a clock, because "is the pause over" is a time question. Make that dependency an argument rather than a call to now() inside the rule (Time as a Dependency).
  • Entitlement depends on state, which means adding a state is a change to entitlement whether or not anyone remembers to look.
  • Finance reporting depends on the transition history, so the history is a product of the feature and not a debugging aid you can drop under deadline.
Misreads
  • "This is a design document." It is eight answers, most of them one sentence, most of them written in the ticket comments. A document is what happens when the process outlives the thinking.
  • "So every feature needs a state machine." No. Most features change no lifecycle at all, and the questions still apply — they just get answered in a line each. The machine is what this particular feature needed.
  • "Design first means no iteration." It means the parts that are expensive to reverse are chosen deliberately and the rest is discovered. Iterating on a state model is cheap; iterating on which module owns it is not (The Cost of Change).
  • "The ticket is the requirement." The ticket is a solution somebody already picked. The requirement is behind it, and it is usually one question away (Requirements Before Design).
Smells this explains
  • shotgun-surgery
  • primitive-obsession

Testing it, and how it ages

What to test, and at which boundary
  • Transition tests directly on the lifecycle, with no database and no HTTP — every legal transition, and every forbidden one asserted to be rejected (What a Unit Is).
  • One integration test that the billing job does not charge a paused subscription. That is the invariant; it is the test that must survive every refactor (Where a Test Must Be Real).
  • A test for the mid-cycle rule specifically, because it is the rule most likely to have been decided by accident and the one nobody will remember deciding.
  • A test that pausing twice with the same idempotency key produces one pause, asserted at the API boundary where the retry actually arrives (Contract Tests).
  • Not tested: that the handler calls the repository. That assertion pins the implementation and buys nothing (Mocking).
How this design ages
  • Pause is the feature that turns a flag into a lifecycle. Every later state — trialing, past_due, dunning, grace period — lands in the machine you just built, which is where the up-front cost is repaid, usually around the second one.
  • The first rule to go stale is maximum pause length, because it is a commercial decision and commercial decisions change quarterly. Keep it as configuration read by the guard, not as a constant inside it.
  • It stops being right when pause becomes plan-specific — annual plans pausing differently from monthly. At that point one machine with an injected policy beats one machine per plan, and beats a conditional inside the guard by a wide margin (Strategy).

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.

  • GENERALThe dependency order — goal before rules, rules before state, state before interface — follows from what each decision needs as input, so it survives changes of language, framework and paradigm. What changes is how much of it is enforced by the type system rather than by a test.
  • DOMAIN-SPECIFICThis weighting assumes real business rules with a lifecycle. For a CRUD screen over a table with no rules — an admin editor for a lookup list — the state, invariant and error questions genuinely collapse to nothing, and the naive implementation is the correct one rather than the cheap one.
  • CONTESTEDThe strongest opposing case is that up-front feature design consistently answers the wrong questions, because the rules you invent before shipping are guesses and the real rules only appear from customer behaviour; on that view you should ship the boolean deliberately, learn what pause actually means, and pay the refactor with real information. It is a serious argument and it is right where the domain is genuinely unknown — it is weakest exactly here, where "you cannot charge a paused customer" was known before anyone opened an editor.

Where the depth lives

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

Domains that do not exist yet
  • Testing & Reliability Engineering — deciding what to assert before the code exists is what stops a suite from describing the implementation, and the confidence argument for that belongs there.
  • System Design — the same eight questions at a larger grain, where "persistence" means a storage engine choice and "errors" means what a downstream service does when yours is gone.