>_devkit
maintainability
skills/maintainability/

references/deletion.md

Deletion: volume as liability

The cheapest code to maintain is code that does not exist. This file is about keeping it that way, and about removing what already got in.

Why this got urgent

When writing code was slow, volume was self-limiting. Nobody added a speculative abstraction layer at 5pm because it was an hour of typing. That brake is gone.

A model will happily produce the general version of a problem you do not have, with an options object, three unused branches, and defensive handling for conditions that cannot occur, in about nine seconds. All of it plausible, all of it passing review at a glance, all of it now yours to keep true forever.

So the discipline has to move from production to acceptance. The question stopped being "can we build this" and became "do we want to own this".

The addition test

Before adding an abstraction, a layer, an option, a flag, or a dependency:

  1. What breaks if this is simply absent? If the honest answer is nothing,

that is the answer.

  1. What is the second caller? Name it. If you cannot name a real one, this

is indirection, not abstraction.

  1. How would this be removed? If there is no answer, you are not adding a

feature, you are adding a permanent resident.

The third question is the one nobody asks and the one that costs most. Anything that enters without a removal path stays until a rewrite.

The rule of three

Do not extract on the second occurrence. Extract on the third, and extract the thing all three actually share.

The reason is not superstition. Two cases give you exactly one axis of variation to generalise over, so you invariably generalise the wrong way, and then the third case does not fit. Now you have an abstraction, a special case inside it, and a comment apologising. Three cases show you the real shape.

Duplication is cheaper than the wrong abstraction, because duplication is obvious and local, and a wrong abstraction is subtle and global.

The corollary, from change-cost.md: extract upward only on the third consumer. This is the same rule react-native-expo applies to promoting a domain enum to lib/, and mongodb-production applies to denormalizing a field.

Finding dead code

Static tools first, because they are free:

# TypeScript: unimported files and unused exports
npx knip
npx ts-prune

# Python
vulture app/
ruff check --select F401,F841        # unused imports and locals

# Anything: files nobody has touched in years, as a starting list
git log --format='' --name-only --since='2 years ago' | sort -u > touched.txt

Static analysis finds unreferenced code. It cannot find code that is referenced but never reached: the endpoint no client calls, the branch no config enables, the migration path for a version nobody runs. That needs runtime evidence.

The most reliable way to find a dead endpoint is to log it. Add a counter per route, wait one full business cycle including month-end, then delete what never fired. A quarter is not too long to wait to remove something safely, and it beats an argument about whether anyone still uses it.

For a library or a public API, you do not have that evidence and must use the deprecation path instead.

The deprecation path

Deleting something with consumers you do not control is a sequence, not an event:

  1. Announce. Mark it deprecated in code, with a date and the replacement.

@deprecated in TS, warnings.warn(..., DeprecationWarning) in Python.

  1. Instrument. Log every use with enough identity to know who is calling.

This is the step that gets skipped, and without it step 4 is a guess.

  1. Migrate. Move the callers you control. Contact the ones you do not.
  2. Verify silence. No calls for a full cycle.
  3. Delete. All of it, including the tests, the docs, the config, the feature

flag, and the database columns.

Step 5 is where most deprecations stop, leaving a codebase full of things that are marked dead and behave as if they are alive. A deprecation that never completes is worse than no deprecation, because now every reader has to determine whether the warning is real.

For an HTTP API, version at the boundary and delete whole versions rather than individual endpoints. /v1 retired as a unit is one negotiation with clients. Twenty endpoints retired individually is twenty.

Feature flags

A flag is two codebases sharing a file. Three flags are eight codebases. This compounds faster than anyone expects and is a leading cause of systems nobody can reason about.

Rules that keep it survivable:

  • Every flag gets a removal condition at birth, written next to it. A date,

a rollout percentage, or an event. Not "when we are confident".

  • Flags are boolean and short-lived. A flag that has been on for everyone

for six months is not a flag, it is dead code with extra steps and an untested else branch.

  • Separate the two kinds. Release toggles are temporary and must be deleted.

Operational switches (a kill switch, a rate limit) are permanent configuration and should not live in the same system or the same list, or the permanent ones will teach you to tolerate the temporary ones.

  • Track flag age. Report the oldest release toggle in the repo. It is the

single best proxy for whether this discipline is real.

Dependencies

Every dependency is code you own with none of the control.

Before adding one:

  • What is the cost of writing this? A left-pad is not a dependency decision,

it is a typing decision. A date library is a real dependency decision.

  • How hard is it to remove? A dependency behind your own thin interface is

replaceable. One whose types appear in your public signatures is permanent. This is worth an adapter for anything you genuinely might swap, and worth nothing for anything you never will.

  • Is it maintained? Last release, open issue trend, number of maintainers.

One maintainer is a risk, not a disqualification.

After adding it:

  • Stay close to upstream. Batched, scheduled updates via Renovate or

Dependabot. The cost of upgrading is roughly linear in how far behind you are, until it stops being linear and becomes a rewrite. react-native-expo is the sharp case: skipping Expo SDK versions turns a routine afternoon into a multi-week project, because each SDK carries native changes and the migration guides assume you took the previous step.

  • Pin exactly, update deliberately. A lockfile committed, and a real

decision to move. Floating ranges mean your build is not reproducible and a failure at 2am might be someone else's publish.

  • Audit what you actually use. depcheck, pip-audit, npm ls. Removing

four unused dependencies is a smaller attack surface and a faster install, for an hour of work.

Data

Deleting data is the one that is genuinely irreversible, so it has its own sequence: stop writing, verify nothing reads, then remove.

  • Stop writing the field. Deploy.
  • Confirm no reads, from logs and from code search, over a full cycle.
  • Drop the column, or unset the field, in a batched migration.

Never combine those into one deploy. And never drop before the backup you would restore from has itself been verified, which is a point mongodb-production and zitadel-production both make: a backup nobody has restored is a hypothesis.

Keep schemaVersion on documents so the read path can tell you what still exists. Without it, "does anything still have the old shape" is a full scan and a guess.

Deleting a whole feature

The test from SKILL.md: could you delete this feature in an afternoon?

Run it as a thought experiment on something real and list what you would have to touch. A well-bounded feature is a folder, a route, a table or collection, a flag, and a handful of tests. A badly bounded one has fragments in shared utilities, columns on shared tables, branches inside unrelated components, and special cases in the billing code.

You will not usually do the deletion. The list is the point: it is your blast radius, measured honestly, and it tells you where the boundaries actually are rather than where the directory structure claims they are.

What not to delete

  • Tests, unless the behaviour is genuinely gone. A test deleted to make a

build green is a bug accepted silently.

  • Comments explaining why. The what is redundant with the code and should

go. The why is irreplaceable, especially the ones recording a constraint that is not visible locally: a workaround for an upstream bug, an ordering that matters, a limit imposed by a client's system.

  • Code that is load-bearing and ugly. Ugly is not the criterion. Unused is.
  • The decision records. See decisions.md. A superseded

decision is still the answer to "why was it ever like that".