Being your own API consumer
Part 2 of an ongoing series on Moty, a fitness data platform I build and run solo in production: one multi-tenant API, two Next.js frontends. All numbers come from production measurements and git history.
The contract nobody would notice me breaking
I write the backend of a fitness data platform, and I also write both of its web frontends. When the frontend needs an API change, the person filing the request and the person shipping it are the same person. The common conclusion is that contract discipline is overhead here: who exactly am I protecting the contract from?
Two people, it turns out. The first is me in two months, who remembers none of the context and will happily "fix" an endpoint in a way that breaks a screen he also wrote. The second is not a person at all: a growing share of my frontend code is generated by AI agents working from written specs, and an agent can only be as correct as the document you hand it. The contract isn't bureaucracy; it's the input to the machine that writes the code. Documents became a means of production.
So the discipline survived the org chart shrinking to one. It lives in three physical artifacts.
Artifact one: a numbered request log
In the frontend repo there's a document that is exactly what a backlog between two teams would be, except both teams are me. Every API gap gets a numbered entry: what the screen needs, what the API currently returns, status. Twenty-six entries so far, each one closed by a backend change that can be traced to the request that motivated it.
One repo rule keeps it alive. An API gap may not be recorded as a one-line wish. It has to be written in enough detail that someone with no context (future me, or an agent) could implement against it. When a security change on the backend forced the frontend's auth guards to be redesigned, the whole exchange is in the log, and the frontend commits reference it. A conversation with myself, in writing, with numbers.
Artifact two: error codes are the contract, messages are not
Every error from the API carries a code shaped like XX-HTTP-NN: domain prefix, HTTP status, variant. The human-readable message next to it is in Korean, always, regardless of the client's language. That sounds like a bug. It's the contract's sharpest rule: the frontend never displays a backend message. It maps the code, and only the code, to its own localized strings.
This one rule solved internationalization across the whole stack. The web app ships in seven locales. The backend translates nothing. Messages can be reworded freely on either side without a contract change, because messages were never part of the contract; codes were. And codes can carry structure: the login-lockout response includes a machine-readable field with the remaining attempts, which the frontend renders as "N attempts left" in the user's language. Field-level validation errors arrive as {field, issue} pairs and bind straight to form inputs.
Artifact three: recording where reality beat the design
Each screen folder in the frontend keeps a short doc with a section I've come to treasure, "where this differs from the design handoff, and who decided." When the implemented screen dropped an OTP step because the real backend didn't need one, that's written down, with the decider named. Six months later, nobody, me included, has to archaeology-dig through commits to learn whether a divergence was a mistake or a decision.
Types that cross the wire intact
The subtler half of the contract is shape. Some API responses vary structurally by mode: different measurement types return genuinely different fields. The lazy encoding is one response type with a pile of nullable fields and a prose rule about which ones appear when. Prose rules don't survive solo development, let alone code generation.
Instead the backend models these as sealed variants (a closed set of shapes, one per mode), and the frontend receives them as a discriminated union keyed by the same tag:
type Performance =
| { mode: "standard"; repetitions: number; averageVelocity: number }
| { mode: "timed"; durationSeconds: number };The compiler on the consuming side now enforces what the prose rule used to beg for. One more wire rule earned its place the hard way. IDs are 64-bit on the backend, and JavaScript numbers corrupt integers past 2⁵³ without a sound, so every ID crosses the wire as a string. That rule is written down too, because it's exactly the kind of thing future me would "simplify."
Honesty section: drift happens anyway
Solo did not save me from contract drift; it just made both sides of the drift mine. Two fossils in the frontend code prove it. The response-envelope reader checks for the error code in two different places, because at some point the backend emitted it inconsistently and the frontend chose to absorb rather than break. And there's a normalizer that accepts three different serializations of the same enum, the sediment of a backend that changed its mind twice. I keep both, partly because they work, mostly because they show what a contract actually is. Not a guarantee, but a place where violations become visible. The clearest proof came later: a bug that lived between the server and the client got caught only because both sides were mine.
What's next
The gap I'd close first is enforcement. The contract lives in documents and discipline; nothing mechanical fails when they diverge. The API's documentation layer is already separated from its controllers, so generating an OpenAPI snapshot and diffing it in CI is a natural next step, turning "I noticed the contract changed" into "the build noticed."
If you're solo and skipping the contract because there's no one to coordinate with, you're coordinating with everyone you'll be later and everything you'll delegate. Write it down, give each entry a number, and make the codes the contract.
0 comments