FoundationsLIFETIME-SPECIFICCONTESTEDSCALE-SPECIFIC

When Design Does Not Pay

Structure is an investment against future change. Where there is no future, it is pure cost — and knowing which code that is, is part of the skill.

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

When is the right amount of design close to none?

The requirement

A script to migrate 40,000 rows once, this weekend, and then never run again. A colleague asks where the tests are.

The obvious build

Every piece of code deserves the same standards. Tests, boundaries, error types, dependency injection — professionalism means consistency.

Why it breaks

Standards are a means. Applied to code with no future, they cost real hours and return nothing, and the hours come out of work that does have a future.

How it breaks as requirements change
  • Standards are a means. Applied to code with no future, they cost real hours and return nothing, and the hours come out of work that does have a future.
  • It also trains a team to see the practices as ritual rather than as tools with conditions — which is precisely how they get abandoned wholesale later, when someone notices they are not helping.
  • And it is the wrong risk model. The risk in a one-shot migration is data loss on the day, not maintenance in a year; effort spent on structure is effort not spent on a dry run against a production snapshot.
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
  • It runs once. There is no second change, because there is no second run.
  • It must be correct on the day — but correctness here is verified by checking the output, not by a suite.
Invariants
  • It must be safe to re-run if it fails halfway, because it will fail halfway (Idempotency by Design).
  • It must not corrupt data it was not asked to touch.

Who owns what, and where the seams fall

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

Responsibilities
  • Whoever writes throwaway code owns making sure it is genuinely throwaway — which mostly means it cannot quietly become load-bearing.
  • Whoever reviews it owns checking the risk that is actually present, which for a migration is the data, not the structure.
Boundaries
  • The boundary is the code's expected lifetime and blast radius. Those two, not its size or its language, decide how much structure it earns.

What actually decides it

Two variables, and neither is size or language. How long the code lives, and how much damage it can do. Everything else — tests, boundaries, error modelling, injection — follows from where a piece of code sits on those two axes.

  • The high-blast-radius rows earn verification effort even when they earn no structure, and those are different budgets.
  • The prototype row is where most of this domain's real-world problems begin — not from ignorance, from a prediction that turned out wrong.
  • Nothing on this list earns effort because of professionalism. Every row is a risk judgement.
CodeLifetimeBlast radiusWhat it actually earns
One-shot data migrationOne weekendHigh — production dataDry run, transaction, resume point, row-count assertion, backup. Almost no structure.
Spike to answer a questionDaysNone — never mergedNothing. Answer the question and delete it. Structure here is pure waste.
Internal tool, two usersYears, changed rarelyLowReadable code and a couple of tests on the tricky part. Boundaries would not repay.
Core domain moduleYears, changed weeklyHighThe full apparatus. This is the code the rest of this domain is about.
Prototype for a demoOfficially daysOfficially noneThe dangerous cell. Officially disposable, frequently permanent — decide *before* the demo what happens if it is well received (Deliberate Debt).
Generated codeRegeneratedVariesNothing by hand. Structure belongs in the generator, and edits to the output are lost anyway.

Making the assumption survivable

The exemption rests on a prediction — this code will not live long — and predictions fail. The mature version does not avoid the prediction; it makes the failure cheap to detect and cheap to correct.

That means writing the assumption down where the code is, and making the structural cost of being wrong small: contained in one directory, importing production code but never imported by it, and impossible to schedule by accident.

When the prediction turns out wrong
  1. 1
    Notice

    The code is run a second time, or something imports it.

    fails by Nobody notices, because nothing was watching for it.

  2. 2
    Decide, once

    Delete it, or promote it deliberately.

    fails by Neither — it stays, unlabelled, and is now load-bearing.

  3. 3
    Promote properly

    Move it out of one-off/, add tests for the behaviour it actually has, name its errors.

    fails by Moving it without tests, so it looks like production code and is not (What "Legacy" Actually Means).

  4. 4
    Record the debt

    If promotion is deferred, write down what is missing and the trigger to fix it.

    fails by An intention instead of a record, which is how accidental debt is created (The Debt Register).

The header that makes throwaway code honest
1// scripts/one-off/2026-08-backfill-tax-codes.ts
2//
3// ONE-OFF. Runs once, 2026-08-30, then delete this file.
4//
5// Not tested, not structured, deliberately. Correctness is
6// verified by the dry run below against a prod snapshot, not
7// by a suite. Blast radius is orders.tax_code only.
8//
9// If you are reading this after 2026-09-30, the assumption
10// was wrong: either delete it, or promote it properly —
11// tests, error types, and out of one-off/.
12
13const DRY_RUN = process.env.APPLY !== 'true'
14
15// resume point: it will fail halfway, and re-running must be safe
16const from = Number(process.env.FROM_ID ?? 0)

Four things carry the weight: the deletion date, the named blast radius, the stated verification method, and the instruction to whoever finds it later. None of it is structure, and all of it is design.

How to build it

Most important first.

  • Ask how long it lives and what it can damage. Short life and small blast radius means write it directly and spend the effort on verification instead.
  • Spend on the risk that exists. For a one-shot migration: a dry run, a count assertion, a transaction, a resume point, a backup. None of those are "design" and all of them matter more here.
  • Make throwaway code obviously throwaway — a scripts/one-off/ directory with a date, so nobody mistakes it for a component (Repository Structure).
  • Notice when the assumption breaks. The second time a "one-off" is run, it is not one, and it should be either deleted or promoted deliberately.

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
  • For genuinely throwaway code, the cost of the next change is irrelevant, because there is no next change. That is the entire argument.
  • The risk is that the premise is wrong. The expected cost is the probability it survives times the cost of it surviving unstructured — which for a prototype that ships is very high, and is why the honest move is to name the assumption rather than rely on it.
What the recommended approach costs
  • Two standards mean judgement calls, and judgement calls can be abused. "This is throwaway" is an easy thing to say about code you do not want to test.
  • Uniform standards are simpler to enforce and easier to defend, and there is a real argument that the simplicity is worth the waste (The Complexity Budget).

What can go wrong

Failure modes
  • The prototype ships. This is the dominant failure and it is organisational, not technical — the demo works, the deadline moves, and structure is never added (Deliberate Debt).
  • The one-off script becomes a monthly ritual, still with no tests, now load-bearing.
  • The reverse failure: applying the exemption to code that is genuinely long-lived because it *feels* small, which is how core modules end up untested.
Dependencies, and their direction
  • Throwaway code should depend on production code and never the reverse. The moment something imports the script, it is not throwaway any more.
Misreads
  • "So prototypes do not need care." They need a different kind: correctness on the day, a bounded blast radius, and a clear label. That is care, aimed elsewhere.
  • "Anything I call a spike is exempt." The exemption comes from the lifetime being genuinely short, and that is a prediction which can be wrong. The honest version states it out loud.
  • "This contradicts the rest of the domain." It is the rest of the domain applied consistently: structure buys change locality, and where there is no change there is nothing to buy (YAGNI, With Its Bill Attached).

Testing it, and how it ages

What to test, and at which boundary
  • Not none, but different. A one-shot migration wants a dry run against real data and an assertion on row counts, not unit tests of its helper functions.
  • The test that matters for throwaway code is usually "did the output match what we expected", run once, by a human (Characterization Tests).
How this design ages
  • The only evolution question that matters is whether the code survives its intended lifetime. Everything else follows from that one fact being right or wrong.
  • Teams that are good at this have an explicit way for throwaway code to be promoted — reviewed, tested and moved — rather than drifting into permanence unnoticed.

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.

  • LIFETIME-SPECIFICThis is the entire lesson — the argument holds for code with a genuinely short life and inverts completely for code that must keep absorbing requirements. The failure mode in both directions is misjudging which one you are writing.
  • CONTESTEDA strong opposing position: prototypes ship far more often than anyone predicts, so the expected cost of the exemption is much higher than it looks, and a uniform standard is the cheaper policy even though it wastes effort on genuinely disposable code. Teams that have been burned by a shipped prototype hold this view for good reason, and the disagreement is empirical rather than philosophical.
  • SCALE-SPECIFICOn a solo project the author knows what is disposable; at fifty engineers nobody does, so the label has to be structural — a directory, a lint rule, a deletion date — rather than something someone remembers.

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 — verification and structure are separate budgets, and one-shot high-risk work needs the first without the second.