Skip to content
Kien

The interfaces I refuse to break

· 5 min read

A list of things that are contracts without looking like one — an identifier a client holds between two calls, a documented error code, the difference between a field being absent, empty and null. Why adding is nearly free and removing is a coordinated release across repositories I do not deploy, and what it honestly costs to keep everything working.

  • API design
  • backward compatibility
  • multi-repo
  • release engineering
On this page
  1. An identifier a client holds between calls
  2. Documented error codes are branches
  3. Absent, empty and null are three different answers
  4. Adding is nearly free; removing is a release
  5. When something does have to break
  6. Merge order is the only thing preventing an outage
  7. What it costs

I keep a list of things I will not change without a plan, and nothing on it is labelled "public API". They are the parts of a system that became interfaces without anyone declaring them, because a consumer started depending on something that looked internal from the inside.

An identifier a client holds between calls

The clearest case on the platform I work on is the composite offer ID: source, contract, hotel, uuid, packed together and decoded at prebook, so no server- side session is needed. It reaches the client as an opaque-looking string, which is exactly what invites people to treat its layout as ours to change.

It is not ours to change. The client holds it between search and prebook, through their retries, sometimes across a deploy of ours mid-checkout. Change the layout quietly and every identifier minted before that deploy either fails to decode or, worse, decodes into something plausible and wrong. The word versioned is the entire defence: the ID says which layout it is, so a new layout can exist alongside the old one instead of replacing it at a moment nobody chose.

The rule generalises. Anything a client carries from one call to the next is a public contract, whatever it looks like — a cursor, a token, a reference you invented for your own convenience. Opacity is not privacy; it only means that when you break it, nobody can see why.

Documented error codes are branches

The approval engine I own at VNTrip has 11 documented error codes. Writing them down was the useful act; the consequence is that they became an interface. Somewhere a screen shows a different message per code, and elsewhere a caller treats one of them as transient and retries.

Rename one and you have made a silent behaviour change in a repository you do not deploy. The old code disappears, a switch falls through to its default, and a user gets a generic message where they used to get the one that told them what to do. Nothing throws. No test of mine fails.

Repurposing is worse than renaming: same code, new meaning, consumers still branching on the old one, and now the system is confidently wrong in a place that reports no error at all. Codes get added. They do not get renamed, and they never get reused.

Absent, empty and null are three different answers

A consumer has already coded to the difference, even if they did not mean to. One check is if (field), which folds absent, empty and null into a single outcome; another is a key-presence test, which does not. Both are now behaviour you have promised.

The decision that breaks this is never announced as a contract change. It arrives as a serialiser setting: omit nulls from now on, or emit an empty string where you used to omit the key. One configuration line, and the meaning of a response changes for every consumer that distinguished the two.

We rely on the distinction in one place deliberately: the per-supplier settings table is sparse, holding only keys that differ from defaults, so absence there means "use the default". Fill it out with explicit values equal to the defaults and nothing changes today — then a default changes, and the rows that were supposed to follow it do not. Absence was carrying meaning all along.

Adding is nearly free; removing is a release

The asymmetry is the practical core of all this. Adding a field costs almost nothing: consumers who do not know about it ignore it, and the ones who want it adopt on their own schedule. The exceptions — a strict parser rejecting unknown fields, a test asserting an exact response shape — are worth knowing, and are exceptions.

Removing a field, renaming it, or quietly changing what it means is not a code change at all. It is a schedule: find every consumer, agree it with the people who maintain them, order the merges, ship in that order. The diff may be one line; the work is coordination, and no care inside your own repository substitutes for it.

When something does have to break

Sometimes there is no additive path. Then the change needs two things a changelog line does not provide: a version, so both shapes exist at once, and a migration window long enough for the slowest consumer to move. The versioned identifier above is the pattern — put the version field in on day one, while it costs nothing, because retrofitting one is itself the breaking change you were trying to avoid.

The window is the part that gets skipped. Without it, "we versioned it" means "we broke it and wrote v2 on the box".

Merge order is the only thing preventing an outage

A go-live here can span 11 repositories, and the consumers of my contract sit in repositories I do not deploy. Backend contracts merge before the consumers that read them, and that ordering is designed up front as part of the change rather than worked out on the morning of the release. A checker verifies the feature reached master in every repository it touches, because the characteristic failure of a multi-repo release is not a bad merge, it is a missing one.

The compatibility rules are what make merge order sufficient. If every change is additive, getting the order wrong costs a delay: the consumer ships early, sees a field that is not there yet, and waits. If a change is destructive, getting the order wrong costs an outage.

What it costs

Compatibility accumulates, and the bill is real. There are fields nobody sets, error codes nothing throws any more, a decoder for an identifier layout minted by a version we no longer run. Each is cheap; together they are a shape you would not choose today, and new work routes around the old field instead of replacing it.

I do not know how many fields in our public responses have no consumer left. Finding out means instrumenting reads on the client side, which nobody has asked for, so it stays a suspicion, not a number.

I pay the bill anyway, for one reason: the cost of compatibility is predictable and mine, while the cost of breaking things is unpredictable, somebody else's, and arrives at a time I do not choose. Two habits make it bearable — version the things that will obviously change, on the day they are created, and keep the list of what counts as a contract written down instead of in my head.

Related posts

· 4 min read

Eight gateway services, one shared integration branch, and a promotion to production that is a deliberate, batched act with a human at the button. What "the unit of release is the set, not the repo" looks like when it has to run every week instead of sitting in a principles list.

  • release engineering
  • multi-repo
  • process
  • CI/CD

· 5 min read

"So you call other people's APIs" is the usual summary of my job, and it is wrong in an instructive way. Around 150 supplier codes on about 90 integrations, two documented offer patterns, two execution models behind one public contract, and suppliers that answer 200 with an error inside — a tour of what the work actually consists of, and why it is architecture.

  • integrations
  • architecture
  • API design
  • distributed systems