Engineering Journal · SYSTEM DESIGN · October 15, 2024 · 17 min read
When the API Becomes Someone Else's Infrastructure
An endpoint that only your webpage uses is a function. An endpoint a phone, a partner, or last year's app still calls is a product with a compatibility bill.
By Golam Sorwar, Tech Lead and Full Stack Engineer in Dublin.
I used to change JSON the way I changed a private method. The blade or the SPA would deploy with it. The conversation lasted a day. Then the same routes started feeding a Flutter app, and later anything that could hold a token. The method was still ours. The release calendar was not.
That is the moment an API becomes infrastructure. Not when you write OpenAPI. When someone you do not sit next to will break if you rename a field on a Tuesday.
I have seen teams keep calling it "the backend" as if that word still meant a page render. It does not. Other software is now depending on your habits.
Context
College systems, and before that travel and commerce products, grow clients the way ivy grows: first the site you own, then an app, then a script in operations, then a partner. Authentication is tokens or sessions. The server is Laravel. The consumers are not one framework and not one version.
I still like boring REST. I like it more when it is boring on purpose: stable URLs, stable error envelopes, pagination that does not reshuffle the universe, idempotency on the writes that hurt.
The other consumers you forget are your own jobs and your own admin. They encode the same assumptions. A "private" cleanup that only the SPA used will still break a command that has been posting the old shape on a schedule. Inventory callers, including the ones that share your repo.
The problem
The apparent problem is documentation. If we described the endpoints, people would use them correctly.
The real problem is time. Clients age in the wild. A phone that is not updated is still a production caller. A partner who integrated against your example payload will treat your example as law. Your undocumented assumption — missing field means zero, extra field is ignored, sort is by id — becomes their architecture.
When you "clean up" the API, you are asking all of those people to do a project. They will not all say yes this sprint.
The problem is also ownership. An internal endpoint has one team on both sides of the wire. An infrastructural endpoint has a population. You cannot hold a standup with a population. You can hold a deprecation window, a changelog, and a measurement of who is left. If you have none of those, you do not own the change. You own the surprise.
The tempting solution
Ship breaking changes behind "it's a new version" in the ticket, but not in the URL, and hope the only client is the one you deployed today.
Or version everything from day one, /v1 /v2 /v3, and carry three implementations of the same confusion.
Or refuse to document so nobody depends on you. They will depend on you anyway. They will depend on tcpdump.
Why that is not enough
Compatibility is not a header. It is a budget. Every field you expose is a promise. Every implicit default is a promise. Every 200 that used to mean "created" and now means "accepted" is a broken promise.
Idempotency is part of this. A client on a flaky network will retry. If create is not safe to repeat, you will meet them in support.
Pagination is part of this. Offset pages drift when writes happen underneath. Mobile lists feel haunted. You will blame the app. The contract was the ghost.
Auth is part of this. A token that never expires is easy. A token that expires without a refresh story is how you teach users to screenshot errors.
Validation is part of this in a way people underestimate. Tightening a rule — a field that used to accept a blank, a date that used to accept a time — is a breaking change even if the OpenAPI file still says the same name. Old clients were not sloppy. They were obeying last year's honesty. If you need the tighter rule, add a new field or a new route, or give them a window. Silent strictness is how you turn a cleanup into an outage for someone who did not deploy this week.
Options
Treat the API as private and force all clients to deploy with it. Advantage: freedom. Disadvantage: you do not control the store, the partner, or the forgotten script.
Additive change only, explicit deprecation, versions when the meaning of a resource actually changes. Advantage: you can keep shipping. Disadvantage: you carry dead fields. Dead fields are cheaper than dead apps.
A new API style — GraphQL, a Backend-for-Frontend per client. Advantage: clients fetch what they want. Disadvantage: you have moved complexity into a layer you must operate, and you still have compatibility inside the graph. I would add a BFF when one client is distorting the public contract, not as a fashion.
Trade-offs
You trade cleanliness for duration. The JSON will not photograph well. It will work in last year's phone.
You trade some delivery speed. Review now includes "who else calls this." That question is slower than "does the page work."
You should not trade away a way to see usage. If you cannot tell who is still on the old shape, deprecation is a mood. A counter on the old field is a cheaper conversation than a guess in a meeting.
Decision
I treat any endpoint a mobile client or an external party can reach as a product. Additive first. Rename as a new field plus a long goodbye. Errors in one envelope. Writes that create money or messages take an idempotency key.
I version in the URL when I cannot keep the meaning of a resource honest without lying to old callers. I do not version because a blog post said so.
I also treat undocumented behaviour as part of the contract once it has users. If clients depend on a sort order you never promised, you still own the tantrum when you change it. You can document it as undefined and still choose not to break it this quarter. That is not weakness. That is knowing the difference between a spec and a population.
Implementation / Thinking process
Write the contract as if a stranger will implement it on a train. Examples include the ugly extras: empty lists, partials, 409s. Strangers copy examples.
Keep pagination stable. If the list can move while someone scrolls, prefer a cursor. If you cannot, say that the list is a snapshot and live with the implication.
Validation errors should name fields the client sent, not your internal DTO. The client cannot fix $data['nested']['x'].
For auth, pair short-lived access with a refresh you can revoke. A mobile app that stores a forever token is a walking breach you scheduled.
When we must break, we do it with a date, a header or a path, and a log of who is left. The date is a product conversation. Engineering does not get to pick it alone if a store release is involved.
Idempotency keys need a home. If the client must send them, say so in the error when they do not, and store them long enough to cover retries, not long enough to become a second database of everything. If the server invents them, the client cannot help you.
Pagination should state what "the next page" means when a row is inserted above the fold. If you cannot say it, do not pretend the list is live and consistent. Mobile engineers will invent a meaning. That meaning will become your bug.
Think in populations, not in pull requests. A change that is compatible with the SPA you shipped today can still be incompatible with the binary from March, the job that posts a subset of fields, and the partner who coded against a screenshot. Compatibility review is "who is still out there," not "does our frontend compile."
When a field must die, give it a retirement: still accepted, still returned, documented as departing, then a date. The date is the product. Engineering can recommend one. Engineering cannot pick one alone if a store review or a partner contract sits in the way.
I also want one error envelope. Not a new JSON dialect per controller. Clients write one failure handler. If every endpoint invents a shape, they will swallow errors to look polished, and you will debug silence. The envelope is manners. Manners are the long-lived part of an API.
List what "done" means for a write. Created, accepted, already existed, conflicted. If those four share one 200 and a vague body, clients will guess, and their guesses will fork your product. Status codes are cheap. Ambiguous success is not.
Failure modes
A "small" nullability change. Old clients crash on missing keys. New clients assume missing means default. You have two sciences.
A success status that changed from 201 to 200. Someone's sync loop now thinks nothing was created and creates again.
Returning different shapes for the same resource based on who is asking, without saying so. That is not flexibility. That is a maze.
Documenting the happy path only. The undocumented branch becomes folklore, then a dependency.
Operational consequences
Once the API is infrastructure, your on-call includes other people's release trains. A deploy that is fine for the website can be an incident for the app. You will want contract tests that run the old fixtures, not only the new SPA.
You will also grow political weight. Saying no to a breaking cleanup is part of the job. People will call you conservative. They are describing the bill.
The other operational fact is archaeology. Six months after a field is "unused," a partner script will still send it. If you delete on a feeling, you will meet that script as a Sev. Keep a way to see the old shape arrive. A counter is enough. Feelings are not telemetry.
Lessons
A function you can change is code. A behaviour other software has encoded is infrastructure. The same route can be one on Monday and the other by Friday.
Compatibility is not kindness. It is how you do not turn your users into an unpaid QA department for your refactors.
What I would do differently today
I would have frozen error envelopes before I froze resources. Clients handle failure more often than they handle your new field.
I would have added a consumer list — web, iOS, Android, jobs, partners — next to any route change in review. If the list is "I think just us," it is incomplete.
I would have treated pagination and error envelopes as the first frozen surfaces, not the resource names. Clients recover from a new field. They do not recover from a list that reshuffles or a 422 that changed its clothes. Freeze the manners early. Rename the nouns when you must, additively.
Closing thought
The day another programme depends on your JSON, you are in the compatibility business. You can still change. You just have to pay for the change in time and dual-running, not in surprise crashes on a phone you cannot reach.