FeaturesLANGUAGE-SPECIFICDOMAIN-SPECIFICCONTESTED

Designing the Happy Path Last

Error, repeat and partial-failure behaviour decided first, because a structure built around the success case has no room left for them — and that is where the mess comes from.

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

Why does error handling always end up feeling bolted on, even on teams that genuinely care about it?

The requirement

Every retrospective concludes "we should have thought about failures earlier". Everyone agrees, nothing changes, and the next feature has the same shape.

The obvious build

Write the happy path first, then add error handling. It is the obvious order and it has real logic behind it: you cannot handle the failures of a thing that does not exist, and getting something working is genuinely motivating.

Why it breaks

The happy path fixes the signature. Promise<void> is a fine return type for success and has nowhere to express "already paused" or "conflicted with a charge", so those become exceptions — and exceptions are the control flow you use when the type has no room (Result Types).

How it breaks as requirements change
  • The happy path fixes the signature. Promise<void> is a fine return type for success and has nowhere to express "already paused" or "conflicted with a charge", so those become exceptions — and exceptions are the control flow you use when the type has no room (Result Types).
  • It fixes the sequencing. Once the email send sits before the commit and works, moving it is a change to working code with no visible benefit, which is the change that never gets prioritised (Effect Boundaries).
  • It fixes the state model. Two states are enough for success; the third exists only because two processes can overlap, and by the time you need it there are twelve call sites that assume two (Boolean Flag Explosion).
  • It fixes the tests. Written against the happy path, they pin the happy-path structure, so the restructuring that failure handling needs now breaks forty tests that were never about failure (Testing as Design Feedback).
  • None of that is carelessness. Each step was locally reasonable; the cost is entirely in the order (Temporal Coupling).
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
  • You cannot know every failure in advance, and demanding that is how this advice gets ignored.
  • The team ships weekly, so any practice that delays the first working code by more than a day will be dropped by the second sprint.
  • Most engineers have been taught the opposite order, explicitly, by every tutorial they have ever read.
Invariants
  • The shape of a function — what it returns, what it takes, where it commits — is decided when it is written, and every caller written afterwards depends on that shape.
  • A behaviour that cannot be expressed in the current structure will be expressed as a special case somewhere else, always.

Who owns what, and where the seams fall

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

Responsibilities
  • The type owns the outcome space. If the set of things that can happen is not in the signature, it is in the reader's memory (Error Modeling).
  • The structure owns the sequencing decisions — what commits before what, what is inside the transaction — and those are decided by the first version, not by the last.
  • The happy path owns being the trivial case that falls out once the constraints are in place. That is what it is for, and it is a smaller job than it usually gets.
Boundaries
  • The boundary this affects most is the transaction, because deciding what is inside it is nearly free before the code exists and is a refactor of everything afterwards (Where the Transaction Boundary Goes).
  • Second is the function signature, which is the contract every caller is written against — widening a return type after twenty callers exist is twenty edits and a merge conflict (Designing a Module Interface).
  • Third is the state model, which is the hardest to change because it is in the database as well as the code (Data Migration).

The same feature, two orders

Both of these were written by careful engineers. The difference is not effort or skill — it is that the left one made its structural decisions while thinking about success, and the right one made them while thinking about the full outcome space.

Look at what the left version cannot express without changing its own signature: "this already happened, here is the original answer". That sentence is the retry case, it is the most common failure in any system with clients, and there is nowhere in Promise<void> to put it.

Where the decisions got made
Happy path first, failures added in week three
// week 1
async function pause(id: string): Promise<void>

// week 3: "it needs to handle already-paused"
//   -> throw AlreadyPausedError
// week 4: "retries are double-pausing"
//   -> signature must change; 12 callers, 40 tests
// week 6: "email sent for pauses that rolled back"
//   -> move the send; it is inside a working function now,
//      so the change is risky and has no visible benefit
Outcome space first, happy path last
// hour 1 - before any implementation exists
type PauseOutcome =
  | { ok: true; alreadyPaused: boolean; pausedUntil?: Date }
  | { ok: false; reason: 'cancelled' | 'limit_reached' | 'charging' }

async function pause(cmd: PauseCommand): Promise<PauseOutcome>

// the retry case has a home before it is a bug;
// 'charging' forces the overlap question on day one;
// callers are written against the real shape from the start.

Nothing on the right is cleverer. It is the same information, decided before twelve callers depended on the smaller shape. The left column's week-four change is expensive not because the fix is hard but because the fix is a signature change, and a signature is the one thing that gets more expensive with every hour it exists. This is the concrete mechanism behind "error handling feels bolted on" — it was bolted on, to a shape chosen for a different problem.

Write the outcomes, then the states, then the code

The practical version is short enough to do before lunch. Enumerate outcomes, run the four failure questions, name the states that fall out, write one repeat-request test, then implement. The implementation is faster than usual, because every decision it would otherwise stop to make has already been made.

The step people skip is the third. Failure questions produce states — charging, pause_pending — and if those are not named now they appear later as booleans on rows that already exist in production, which is a migration rather than an edit.

The order, as it actually looks in an editor
1// 1. outcomes - what can happen, before how
2type Outcome =
3 | { ok: true; alreadyPaused: boolean }
4 | { ok: false; reason: 'cancelled' | 'limit_reached' | 'charging' }
5
6// 2. failures - four questions, four answers, in a comment that stays
7// store down -> commit first, effects from the committed fact
8// dep slow -> 2s deadline, then 'charging' is returned honestly
9// repeat -> Idempotency-Key, outcome stored before the work
10// half done -> outbox; email is at-least-once, never at-least-zero
11
12// 3. states the failures revealed, not the requirement
13type State = 'active' | 'charging' | 'pause_pending' | 'paused' | 'cancelled'
14
15// 4. the first test, before the first line of implementation
16test('same key twice -> one pause, same answer', async () => {
17 const a = await pause({ id, key: 'k1' })
18 const b = await pause({ id, key: 'k1' })
19 expect(b).toEqual(a)
20 expect(await transitions(id)).toHaveLength(1)
21})
22
23// 5. now write pause(). It has nowhere left to go wrong structurally.

Steps one to four take about forty minutes and produce no working software, which is exactly why they get skipped. The thing to notice is that step three exists only because of step two — no requirement mentioned charging, and no amount of thinking about the happy path would have produced it.

What the inversion costs

The honest accounting: this order is slower to a demo, produces more code on day one, and is wasted entirely on features that turn out not to ship. Those are real costs and they land on every feature, while the saving lands only on the ones that receive a second failure requirement.

The numbers below are a teaching model, not a measurement. What they are for is the shape — that the middle column wins on everything except the one axis that is most visible to everyone outside the team.

Three orders of work, scored
OptionSimplicityFlexibilityTestabilityOperationalMigration costNote
Happy path first, failures laterFastest to a demo and simplest to read on day one. Every later failure requirement is a signature change or a migration, and the migration score is the one that hurts, because it is paid in production.
Outcome space first, happy path lastA day slower to demo. The retry case, the overlap state and the effect ordering all have homes before they are bugs, so later failure work is additive rather than structural.
Full failure analysis up frontEnumerate everything, including the failures that never occur. Buys little over the middle option and reliably produces states that are never reachable, which are then trusted (Speculative Generality).

caveat These scores assume a command that changes state and will receive more requirements. For a read endpoint the first row is correct on every axis and the others are pure overhead — and for a feature that gets cancelled before launch, the first row wins outright, which is a real outcome and not a rhetorical one. The scores also cannot express the political cost of a slower first demo, which is frequently what actually decides this.

How to build it

Most important first.

  • Write the outcome type first. Enumerate what can happen — succeeded, already done, rejected for a business reason, could not reach a dependency — before writing what happens (An Error Taxonomy That Survives Contact).
  • Write the failure table second, from the four questions in Failure-Aware Feature Design. It takes ten minutes and it is what tells you which states exist.
  • Write the state model third, including the states that exist only because of failures. Those are the ones that never get added later (State Machines).
  • Write one failing test for the repeat request, before the happy-path test. It is a two-line test and it forces the idempotency decision while it is still free.
  • Then write the happy path, which is now a short walk between constraints that already exist rather than a structure that has to be renegotiated.
  • Do this for commands that change state. For a read, the happy path genuinely is the whole thing, and this whole lesson is overhead you should skip (When Design Does Not Pay).

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 this order, adding a fifth failure case costs one variant on the outcome type and one branch. The compiler lists the call sites that now need to handle it, which converts a search into a task list.
  • Under the usual order, the same addition costs a signature change, every caller, every test that mocked the old signature, and a judgement call at each site about whether the new case was already being swallowed by an existing catch.
  • The first version costs perhaps a day longer to demo. That is the honest price, it is paid every time, and it is only repaid on features that receive a second failure requirement — which is most commands and almost no reads.
  • What stays expensive: changing which store owns the state. No ordering of your own code affects that, and it is usually the most expensive change a feature can receive (State Ownership).
What the recommended approach costs
  • The first working version arrives later, and on a team where the first demo is what gets the feature funded, that is a political cost as well as an engineering one.
  • Enumerating outcomes up front means enumerating some that never happen, and every unused variant is code a reader has to consider. The discipline is deleting them once you know.
  • It is genuinely wrong for exploratory work. When you do not yet know what the feature is, designing its failure modes is designing the failures of something that will not exist (The Cost of Change).

What can go wrong

Failure modes
  • It becomes an excuse for analysis paralysis: three days enumerating failure modes for a feature whose entire risk was a duplicate submit.
  • The outcome type is written first and then ignored, with the real failures thrown as exceptions anyway, so you have both mechanisms and neither is complete (Exceptions, Where They Help and Where They Hide the Flow).
  • The failure states are designed and never reachable, because the guard that would produce them was never wired up — and an unreachable state is worse than none, since it is trusted (Speculative Generality).
  • The mitigation fails too: a team that always designs failures first starts treating every feature as high-risk, and the practice gets abandoned wholesale rather than scoped to commands.
Dependencies, and their direction
  • This depends on a language where the outcome space can be expressed cheaply. With sum types it is four lines; without, it is a discriminated object and a discipline, and the discipline decays (Optional Values and Absence).
  • It depends on the team accepting a slower first demo. The visible cost is front-loaded and the saving is invisible, which is a hard trade to sell and worth being honest about.
Misreads
  • "So never write the happy path first." Write it first as a spike, in a branch you delete. The problem is not writing it — it is keeping it and building on its shape (What Refactoring Actually Is).
  • "This means designing for every failure." It means designing for the four that change structure. The rest are branches, and branches can be added cheaply forever (Failure-Aware Feature Design).
  • "Errors as return values, always." In a language with checked exceptions or with a strong exception culture, exceptions carry the same information at the type level and the argument is about idiom rather than substance. The claim here is about deciding the outcome space early, not about the mechanism (Exceptions, Where They Help and Where They Hide the Flow).
  • "This is just TDD." TDD constrains implementation from tests. This constrains structure from the outcome space, and you can do either without the other — though writing the repeat-request test first is where they meet.

Testing it, and how it ages

What to test, and at which boundary
  • The first test written is the repeat request. Writing it before the happy-path test is the single practice that most reliably produces an idempotent command (Idempotency by Design).
  • Assert the outcome type exhaustively — a test per variant — so that adding a variant fails a test rather than silently falling into a default branch.
  • Test that a rejected command left nothing behind: no partial write, no event, no email. That is the assertion that catches sequencing mistakes, and it is the one nobody writes (Where a Test Must Be Real).
  • Do not test the happy path first. It will pass, it will feel like progress, and it will pin the structure you have not finished choosing yet.
How this design ages
  • The outcome type grows as the business grows, and that growth is the design working: each new variant is a business decision made visible at every call site instead of a new exception class nobody catches.
  • Once the type has eight or nine variants, some are variations of one thing and it is time to group them — but not before, because premature grouping hides the distinction that made them separate (An Error Taxonomy That Survives Contact).
  • The practice stops applying when a feature genuinely has one outcome. Recognising that is the skill; applying it uniformly is the failure mode (Over-Design and Under-Design).

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.

  • LANGUAGE-SPECIFICIn Rust or a typed functional language the outcome space is a sum type the compiler forces every caller to handle, so this ordering is nearly free and the argument is close to unanswerable. In Python or JavaScript the same design is a convention that decays under deadline, so it needs a linter or a review habit to hold, and the honest cost is higher.
  • DOMAIN-SPECIFICThis is advice about commands that change state. For a read endpoint, a report, or a pure transformation, the happy path really is the whole feature and the inversion adds nothing but ceremony — applying it there is what makes teams reject it everywhere.
  • CONTESTEDThe strongest opposing case is that structure should be discovered rather than chosen: you learn the real failure modes from production, and a structure designed around imagined failures is as speculative as any other premature abstraction — better to write the simple version, watch it break, and restructure with evidence. That is right when the failures are genuinely unknown; it is weak for the four here, which are known in advance for every command ever written, and it also assumes the restructuring actually gets funded, which it usually does not.

Where the depth lives

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

Distributed Systemsretry-ambiguity
Domains that do not exist yet
  • Testing & Reliability Engineering — "write the repeat-request test first" is a testing practice with a design consequence, and the confidence half of the argument lives there.
  • Programming Languages & Runtime Internals — how cheaply a language can express an outcome space, and what exceptions cost at runtime, decides how much of this is free.