Recurring decisions · 7
Endpoints over controllers
Every HTTP endpoint is its own class carrying its own request and response types, rather than a controller holding a dozen loosely related actions.
In his words
Controllers bloat. Finding one action means scanning hundreds of lines or hunting for a method, on top of the decorators a standard controller carries. FastEndpoints does not merely permit one endpoint per file, it expects it, and it keeps that file minimal. The hard work belongs in the service.
Trade-off
More files. A five-operation feature is five endpoint classes plus its contracts, where a controller would have been one file.
Evidence
- The general-ledger upload system for deposit accounting
- The admin tool over internal program settings and their audit history
- The account services desk for bank operations staff
Never throw out of a service
Every service returns a result envelope. Failure is a value the caller has to handle, and only the endpoint layer turns that value into an HTTP error response. The rule is written down inside the repositories that follow it.
In his words
Exception flow never feels like it carries enough substance. Did it really fail, or was the exception planned? Did it come from the direct parent or bubble up from further down? Attaching a message to it is not an option. A result envelope behaves the way he wants code to behave and leaves far more room to move.
Trade-off
Every call site has to unwrap. Where a failure genuinely is exceptional, a result envelope is more ceremony than a throw would have been.
Evidence
- The account services desk for bank operations staff
- The enterprise transactional email platform
The browser never touches the API
A server-side hop sits between the browser and the API. The browser talks to that hop, the hop attaches the caller's identity and forwards. In one system the browser cannot reach the API at all.
In his words
A security environment requirement, not a preference. Individual users are not to reach an API at all, and that needed a hard cut: only service accounts may call one. The hop in front is what guarantees no human ever gets there directly.
Trade-off
An extra deployable, an extra hop of latency, and every endpoint exists twice: once on the API and once as the pass-through in front of it.
Evidence
- The admin tool over internal program settings and their audit history
- The general-ledger upload system for deposit accounting
- The account services desk for bank operations staff
Authorization decomposed to the action
Permissions are granted per action, not per record. Approving, back-dating, auto-reversing, voiding, changing a settlement date and editing the field that grants access are each their own permission, separate from ordinary edit.
In his words
The standard set is read, edit and admin. Anything past those came either from planning the application up front or from discovering over time that a finer permission was needed. Both routes produced entries in the list.
Trade-off
More roles to administer, and a permission model somebody has to keep a map of.
Evidence
- The general-ledger upload system for deposit accounting
- The overnight sweep, overdraft and settlement processor
- The internal IT ticketing and asset register
Legacy marked, not deleted
A repository that is superseded but still standing gets a LEGACY prefix on its name. The prefix stops anyone making changes to it, and it stays until the repository is confirmed safe to remove from master. Renaming a project is easier to reason about than hunting for the commit to revert to, so the old thing is kept, labelled, as the rollback path.
In his words
The prefix exists to stop other people editing a repository that is superseded but still standing. It stays until that repository is confirmed safe to remove from master, because renaming a project is easier to reason about than hunting for the commit to revert to.
Trade-off
Dead repositories stay in the estate, visibly, and somebody has to decide when each one is finally safe to remove.
Evidence
- The enterprise transactional email platform
Shared contracts, so legacy consumers move on their own schedule
A contract package targets both the modern runtime and the decade-old one, so callers on either share the same types. Where that is not possible the old and the new ship side by side as two packages rather than as one breaking upgrade.
In his words
It was a bear. Wrappers and converters carry the old request models alongside the new ones that gained functionality, and unit testing was what made it survivable, probably more than on any other project he has worked on.
Trade-off
Two runtimes to keep a package building against, and a lowest common denominator on anything the old target cannot express.
Evidence
- The internal real-time messaging bus
- The payment solutions support platform
- The shared internal package estate
Record the correction; label an inference as an inference
A superseded decision is struck through and dated rather than overwritten, and where an implementer reasoned but nobody actually ruled, the entry is labelled an inference pending a decision, with the reasoning chain and the alternative reading both stated. In one estate the written record is checked mechanically against the code: retired terms surviving unmarked, citations of decisions that do not exist, verification claims whose covered files have moved.
Trade-off
The record gets longer and messier to read, and somebody has to keep the checks working.
Evidence
- The ACH and government payment processing suite
Case studies · 4
The browser tab label became an alert channel.
Situation
Operations staff keep the general-ledger upload system pinned in a background tab and work in front of something else. A posting that errors needs attention now, and nobody was looking at the page.
The trap
The obvious answers are the ones that need the page to be on screen: a banner, a toast, a badge. All of them are invisible to a person whose attention is somewhere else, which is exactly the case that matters.
How it was found
An approver on that system needed to see a failed real-time post without the page in front of them. Posting time carries a stated guarantee, so a failure that sits unnoticed in a background tab is a broken promise rather than an inconvenience. (interview C1)
The fix
The page title itself flips to an error marker whenever errored real-time posts exist, so the tab label carries the alert into the strip along the top of the browser.
The cost
The non-production environments are permanently full of stale errored posts, so the indicator is almost always lit there. The developer documentation has to say outright that this is not an application failure.
The general-ledger upload system for deposit accounting
A configured zero is a real setting, and reading it as absent fails open.
Situation
The general-ledger upload system caps how far back a posting may be dated. The cap is configuration, and zero is a legitimate value for it: no back-dating allowed at all.
The trap
The natural way to check whether configuration has loaded is to ask whether anything came back. A cap of zero came back as nothing, so the client would have read a real, deliberately strict setting as not loaded yet, and let back-dated rows through.
The fix
Loaded-ness is tracked by whether the floor date is set, not by whether the count is above zero. A one-line property with a four-line comment explaining the trap.
The cost
The rule is enforced on the client and the API no longer re-checks, so the failure mode had to be deliberate rather than incidental. When the cap cannot be loaded at all, every back-dated row is blocked while today and future rows still pass.
The general-ledger upload system for deposit accounting
A red test run that is the bug list.
Situation
Fifteen-year-old validators in the account submission service hold thirty-one live defects. The rules for that work said the tests could not modify the production source.
The trap
A characterization test that asserts what the code currently does locks the bug in and calls it correct. The suite would go green and the defects would become the specification.
How it was found
Part of a push to get unit, integration and end-to-end testing onto every internal repository. Writing an accurate test meant inferring what the code was meant to do rather than recording what it does, so a failure states the gap. Fixing was explicitly not the goal; the audience is the next developer to open the repository, who has to clear the failures before doing anything else. (interview C3)
The fix
Each test asserts the intended behaviour, wrapped so it fails while the defect is alive and names the defect, the production file and line, and what the code should do instead. Fixing the source turns the test green by itself, at which point the wrapper is deleted. Each carries a trait so a build gate can exclude them on purpose rather than by accident, and a defect that escapes as an exception declares its exception type so an unrelated crash is rethrown untouched instead of being mislabelled.
The cost
A permanently red suite has to be explained to everyone who runs it, and the recorded invariant has to be the failure count rather than the pass count, because pass counts drift every time a test is added.
The submission and approval service behind new accounts
The cleanup job deleted live databases, and it surfaced as somebody else's login failure.
Situation
The end-to-end suite for the next-account-number tool provisions a throwaway database per run and sweeps up orphans left by runs that died. The sweep compares each database's creation timestamp against a cutoff.
The trap
The creation timestamp is recorded in the server's local time and the cutoff was computed in UTC, four hours ahead. Every scratch database therefore read as older than the grace period, including the ones belonging to runs still in progress. The damage did not look like a sweep bug: it destroyed a reviewer's probe database in the middle of a review and arrived as an unrelated login failure.
The fix
Compare local time to local time. The fix ships with the reasoning in a comment beside it.
The cost
The sweep had to be refactored to take its prefixes and grace period as parameters before it could be tested at all: the creation timestamp is not settable, so with a hard-coded one-hour grace no test database could ever be old enough to reach the code path. It was untestable by construction, which is why the defect lived there.
The tool that hands out the next available account number