Yurii SerhovskyiWriting

Mechanisms, not agreements

The type-check was green. All 263 tests were green. Every nutrient value on the screen read undefined.

This was Pantensa, a household pantry and nutrition tracker — FastAPI and PostgreSQL on the backend, Vue on the front — midway through replacing eight hard-coded nutrient columns with a proper many-to-many catalog. The frontend carried a hand-written mirror of the backend’s read schemas: a TypeScript interface for every Pydantic model, kept in sync by me remembering to keep it in sync. I had renamed fields on the backend. The mirror still described the old shape, the tests still asserted against the mirror, and the compiler still agreed with both. Everything was internally consistent and none of it was true.

That is the failure mode worth writing about. Not a red suite — a red suite tells you something. A suite that is green because it agrees with itself tells you nothing, and it tells you nothing loudly, in the exact tone of voice that means “ship it”.

I work on this codebase alone. Nobody reviews my pull requests, nobody notices when I get lazy at 1 a.m., and nobody remembers on Thursday what I decided on Monday. Under those conditions a rule that lives in a document, a convention, or my own good intentions is not a rule. It is an agreement I have made with myself, and I break agreements with myself constantly. The only rules that survive are the ones a machine enforces.

So the question I have been answering for the past few months is narrow and practical: for each architectural boundary I care about, what exactly goes red, and when?

The wire contract generates itself

The undefined bug had an obvious-looking fix and two tempting wrong ones.

Committing a fixture of the API response just moves the problem: the fixture becomes the new un-checked copy, drifting from the backend exactly the way the interfaces did. Standing up a real FastAPI process inside Vitest drags a cross-language runtime into the unit layer, which is the wrong altitude and flaky besides.

The fix that holds is to remove the copy. The backend’s Pydantic schema is the single author of the wire; everything downstream is derived from it, and the derivation runs on a machine.

// frontend/openapi.json — committed, deterministic projection of app.openapi()

Three gates keep that chain honest, and each one names the thing that goes red:

  1. A backend test asserts app.openapi() == frontend/openapi.json. Rename a field without regenerating, and the backend suite fails — the side that made the change, not the side that suffers from it.
  2. openapi-typescript regenerates the TypeScript types from openapi.json before every entry point. Not a committed file — a build product, git-ignored, regenerated by a pre-hook on type-check, lint, test, coverage, build and dev. A build product cannot drift, because there is no stale copy for it to drift from.
  3. The frontend’s read types are derived, not written:
// modules/catalog/model/types.ts
type Schemas = components['schemas']

export type Product = Required<
  Pick<Schemas['ProductResponse'],
    'id' | 'origin' | 'name' | 'dimension' | 'is_fresh' | 'units' | 'provenance'>
>

A rename that somehow survives gate 1 removes a key from that Pick once the types regenerate, and vue-tsc goes red on the frontend. Required<> is doing real work there: OpenAPI marks defaulted fields optional, and stripping the ? while keeping | null leaves the compiler as a guard instead of letting me silence it with ?? 0.

One field is deliberately exempt. nutrients stays an explicit Partial<Record<NutrientKey, number>> rather than deriving from the generated { [key: string]: number }, because the sparse invariant — nutrients.fiber === undefined is not the same claim as fiber === 0 — is exactly what the generated type would erase. Deriving everything is not the goal. Deriving everything whose shape the backend owns is.

What it costs: the mechanism is repo-wide, the enforcement is catalog-only. Those derived types cover one bounded context; the rest of the frontend still reads the wire the old way and would still go green while lying. Extending it is mechanical, and it is not done. A gate that covers a quarter of the surface is worth writing down as exactly that.

A tier that drains itself

The frontend has the same problem in a different shape: not “is this value real?” but “is this import allowed?”.

ESLint classifies every file into one of six element types and forbids every dependency it does not explicitly name. Read the policies as a matrix of who may import whom, and one row stops you:

Tier May import Importable by
app everything legacy only
pages modules, shared, legacy app, legacy
modules shared, legacy app, pages, legacy
shared shared everyone
legacy everything everyone

legacy — the tier holding code I intend to delete — was the only one that could both import everything and be imported by everything. That is not a quirk of migration. It is a structural attractor: any code with real dependencies and many consumers has exactly one legal address, and it is the doomed one.

This was not theoretical. A health store read by eight surfaces and a nutrition-limits store read by twelve were about to stay in legacy, and I had written down that leaving them there was “simultaneously the correct architecture and the lowest-risk move”. Only the second half was true. A composable whose own docstring opens “THE ONE PLACE THAT ANSWERS which nutrients does this family show” was going to be moved into the tier I was trying to shrink, because there was nowhere else it was allowed to go.

The gap was precise: no tier could be imported by everyone while itself depending only on infrastructure. So application-level state — session, tenant context, the nutrient catalog, daily limits — had no legal home outside legacy.

The fix is a new tier, entities/, and one clause that does all the work:

app       → app, pages, modules, entities, shared, legacy, assets
pages     → modules, entities, shared, legacy, assets
modules   → entities, shared, legacy, assets      (still NOT other modules)
entities  → entities, shared, assets              ← may NOT import legacy
shared    → shared, assets
legacy    → everything                             (shrinking, never growing)

entities may not import legacy. Without that prohibition the new tier could lean on the doomed one and I would have rebuilt legacy under a nicer name. With it, promoting anything into entities/ forces its transitive dependencies out of legacy too. The tier drains the bucket by construction rather than by discipline — which matters precisely because my discipline is the thing that failed at the top of this post.

Then the part I think is the actual lesson. The ESLint plugin doing this classification warns, in its own documentation, that a misconfiguration disables the rule silently: green lint, zero enforcement. In a codebase where two i18n rules had already died that way, an unverified boundary config is not a boundary. So the tier shipped with deliberate violations — code that exists to be rejected, proving each policy actually bites.

I had learned that the hard way one tier over. The project rule reads “no raw strings, enforced by lint”, and it was true — of the target tree. The rule was scoped to pages, modules, app and entities, and views/ was excluded entirely. Sixteen legacy views, eight of them with zero translated strings, including login and registration. views/ was melting outside the lint, which means it was rotting faster than it was melting. The enforcement I believed in covered exactly the code that did not need it.

What it costs: legacy is a direction with a ratchet, not a release gate. A guard counts the files under those patterns and holds a ceiling that plans may only lower. It is not zero today and may not be zero at release. Naming it a direction rather than a deadline is what keeps the number honest — a gate I would be tempted to postpone teaches me to postpone gates.

Three more of the same shape

The domain stays pure because an import graph says so. The backend runs import-linter contracts in CI: app.domain may import stdlib and app.domain only — no FastAPI, no SQLModel, no sibling package. It is written as an exhaustive denylist, because the tool has no “allow only X” contract, which means every new production dependency must be added by hand or the domain quietly gains the right to import it.

That cost has a mechanism of its own, and it is the piece I am fondest of. The config is data that nothing executed: a typo in forbidden_modules lints green — measured, exit 0, “5 kept”, not a word about the dead entry. So a test now executes the config as data and checks that every app.* entry names a module that exists, that every child of app is classified by every contract, and that every direct requirement has all of its top-level import names denied to the domain. That last check immediately found one: python-multipart ships two import names, and the second had been missing from the denylist for the config’s entire life.

Behaviour is preserved by an oracle, not by care. The DDD migration is a strangler, context by context, and each workflow moves as four artefacts: a port, an adapter around the legacy call, a flag at the single composition point, and a parity test asserting old and new return the same thing. Three of the four are disposable by design, and the flag exists so a bad flip is an environment variable rather than a deploy. It is more ceremony per workflow than a rewrite would need, and it is the reason no user-visible behaviour changed while roughly a third of the backend moved underneath them.

A missing secret fails at construction, not at 3 a.m. Credential encryption needs a master key from the environment, so Settings() refuses to construct without it — the process does not boot rather than booting into a state where the first background task discovers the problem. The design that key belongs to is deliberately modest: an earlier draft wrapped the data key twice, once under the master key and once under the password hash, and claimed a database dump alone was useless. Re-reading my own code killed it. Dual-wrap is an OR, so it is only as strong as its weakest path, and the password hash lives in the same database as the ciphertext. What is left is a trilemma — the operator cannot read user keys, background jobs can use them, and they survive a password reset: pick two. A key an unattended worker can use is, by construction, usable by whoever controls the process. The mechanism here does not resolve that. It just refuses to let me pretend I resolved it.

Where the tests sit

The same instinct shapes the test tiers, and the rule between them is a boundary, not a preference: only what cannot go red lower down belongs higher up. The shape of a wire field is a contract test. A domain rule is pytest. Layout at 390 px, the cookie session, Cache Storage and a verification email are end-to-end, because none of them can fail in a DOM implementation with no layout engine and no service worker.

Two consequences of taking that literally. Some checks are not tests at all: a script that walks elementFromPoint outward from every control to find the box it actually reaches needs a real browser and a real database, so it is a script, and calling it a test would only let it pass by default in an environment where it cannot run. And the end-to-end tier runs against its own database with no volume attached, so tearing it down takes the data with it — plus a setup step that aborts the run, before a single test, if the base URL is not the disposable stack.

Plans carry a test-count delta rather than a total: a plan that promises five new tests and lands four has a conversation to have. A total would just drift for a dozen innocent reasons.

The limit

Every mechanism here has the same weakness, and it is worth stating plainly rather than ending on a flourish: a mechanism nobody verifies is just an agreement with better syntax.

The boundary config that silently disables itself. The lint rule that was real for the target tree and absent where the mess actually lived. The import contract that lints green with a typo’d entry. In all three cases I had the mechanism, believed I had the enforcement, and had neither. What closed the gap was not more rules — it was tests aimed at the rules: deliberate violations that must be rejected, a test that executes a config as data, a check that a denylist covers every import name a dependency actually ships.

That is the whole trick, if it is one. Enforcement is code, code has bugs, and code with bugs needs tests — including the code whose entire job is to keep the rest honest. It costs more than writing a convention in a document. It costs less than a green suite where every field reads undefined.

Pantensa is pre-release and the migration is not finished. The legacy service layer still exists, views/ is still melting, generated types are enforced on one context out of several. The numbers in this post are from August 2026 and will be wrong by the time they matter. What I would keep, if I threw out everything else, is the habit of asking one question of every architectural decision: what goes red, and when? If the answer is “nothing, but I’ll remember” — I won’t.