Skip to content
Chapter 7 of 7 · Multi-provider abstractionDocumented escape hatches

15 min read

Documented escape hatches

On this page

A capability that exists at one vendor and nowhere else has three possible homes: the shared contract, an escape hatch, or a forked code path with no shared type at all. Chapter 2 ruled out the first for anything the other vendors cannot honour with the same meaning, which leaves a practical question this chapter answers — what the second looks like in code, what it costs the day you genuinely swap vendors, and what stops hatches spreading until the seam is decoration.

The version of this decision that gets defended in review is the first option handled honestly: one contract, one interface, and the unsupported case raises a clear exception. What that produces is this, at runtime, on live traffic:

text
NotImplementedError: OpenAIClient.search() is not supported
  at OpenAIClient.search (integration/openai.ts:212:11)
  at researchAnswer (features/research/answer.ts:44:28)

A method that throws is a type that lies

What the throwing method actually does is a conversion. Before it existed, "OpenAI cannot do server-side search" was a fact you could establish at compile time, because there was no way to write a call that asked it to. After, that fact is enforced at runtime, on production traffic, on the day a config flip or a failover routes a search request to the wrong implementation — and failover is exactly the condition under which that flip happens without a human deciding it.

The mechanism behind that conversion is worth stating in full, because "the type lies" is a slogan until the steps under it are visible. A type checker verifies calls against declared types. When researchAnswer calls client.search(), the checker looks at the declared type of client, which is LLMClient, finds a search method on it, and passes the call — and it is right to, because the call is well-formed against everything it was told. Which implementation the value will actually be is not something the checker can know, because that is decided by whatever wires the object up, and wiring is data: a configuration value, an environment variable, a failover controller's decision. So the checker's guarantee is about the type and the failure is about the value, and those two are settled at different times by different parties. Adding the method moved the fact across that boundary. It did not weaken the type system; it told the type system something untrue and got exactly what it asked for.

Failover being the trigger is the last link, and it is not bad luck. Every other way the wrong implementation arrives has a person in it. Somebody switching providers deliberately reads the code, runs the tests, and meets the gap in a pull request. A failover controller reads a health signal and substitutes one value for another, because substituting values of the same type is precisely what it was built to do and the type said the substitution was safe. The one path with no reader on it is the path the type system was the only remaining guard on.

The secondary damage is to the reader. A caller holding an LLMClient cannot tell from the type which of the three implementations behind it are safe to call search on, so the knowledge lives in a wiki page, a code comment, or somebody's memory. Any of those is a worse place for it than the type system that was right there.

Adding a capability flag — client.supportsSearch — is the usual next proposal, and it improves the crash into a branch while reintroducing the problem chapter 1 spent its length removing. Domain code that reads a capability flag is domain code making decisions about vendor identity, one level of indirection removed. A feature now has two behaviours, one of which only runs on one vendor, and testing both means constructing two clients that differ in a boolean nobody can trace to a real product decision.

Add search() to the shared interfaceDocumented escape hatchForked provider-specific path
Does the type tell the truthNo — promises what two implementations cannot doYes — the capability has its own typeYes
Where the gap is discoveredProduction, on a failover or config changeCompile time, at the injection siteCompile time
Cost to add todayInterface + 3 implementations + tests + call sitesOne module, one composition-root lineDuplicate the feature's code path
Cost when you genuinely swapRewrite the feature and delete a method from a shared contractRewrite the feature; the seam is untouchedRewrite one path; the other stays
Effect on the rest of the codebaseEvery future provider must answer for itNone outside the feature that asks for itNone
Failure mode if misusedRuntime exception on a live requestCompile errorDrift between the two paths
Right whenEvery provider you support has real equivalentsOne vendor has a capability you need nowThe two vendors' feature sets diverge broadly

The first row is the one worth arguing out loud, because it decides between the left and middle columns and it turns on meaning rather than on naming. Two vendors can both ship something called web search and still fail the test: one returns spans with byte offsets into the retrieved page so you can render an exact citation, the other returns a URL and a page title, and a shared search() would have to return either the weaker of the two shapes or a union that every caller narrows before it can do anything with it. Chapter 2's rubric applies without modification, since a port earns its place when more than one implementation can honour it with the same meaning, and until that condition holds the capability is a property of one vendor rather than of your application.

Flowchart: Product needs a vendor capability → Two or more vendors, same meaning?; Two or more vendors, same meaning? → Promote to a port (Yes); Two or more vendors, same meaning? → How much diverges? (No); How much diverges? → Documented hatch + ADR (One feature); How much diverges? → Fork the code path (Broad).

The right-hand branch is a legitimate destination rather than a failure state. Once the divergence is broad — one vendor's search returns citations you render inline while the other's returns a document set your own code has to summarize before it is showable — the two versions of the feature have different products in front of them, and forcing them through one code path produces conditionals that encode vendor identity in exactly the place chapter 1 worked to keep it out of. Two independent modules with no shared type between them are longer in total and shorter to read.

The hatch as an explicit dependency

The mechanism is not a raw() method on the shared port, because putting it there gives every caller access to every vendor client and makes the hatch invisible in dependency terms. The hatch is its own type, exported from one module, and injected only into the feature that needs it. It also carries the condition the diagram above attaches to its middle destination: an architecture decision record, a short dated document stating a choice, the alternatives weighed against it and the reasons, kept in the repository beside the code it governs.

ts
// integration/anthropic/native.ts
// ESCAPE HATCH. Vendor-specific by design. See ADR-0041 before adding a usage.
export interface AnthropicNative {
  readonly client: Anthropic;
}
ts
// features/research/answer.ts
export function makeResearchAnswer(deps: {
  chat: ChatClient;          // the portable path
  native: AnthropicNative;   // the hatch, declared in the signature
}) {
  return async function researchAnswer(question: string): Promise<Answer> {
    const res = await deps.native.client.messages.create({
      model: "claude-sonnet-4-5",
      max_tokens: 2048,
      tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }],
      messages: [{ role: "user", content: question }],
    });
    return toAnswer(res);
  };
}

Three things follow from that shape without any further discipline. The dependency is in the function signature, so anybody reading researchAnswer knows it is vendor-bound before reading the body. The composition root — the one place in the application that constructs concrete objects and hands them to the code that uses them — cannot wire this feature to a provider that does not have the capability. And the rest of the application is unaffected: no shared interface changed, no other implementation gained a method, and the fourteen-method problem from chapter 2 did not advance by one.

The middle one is the whole trick, and it is worth following step by step because it is doing the work the throwing method could not. Substituting one provider for another means supplying a different value where a dependency is declared, and a value is only eligible if it has the declared type. The declared type here is AnthropicNative, which is exported from exactly one module, and that module constructs exactly one vendor's client. So there is no second value of that type anywhere in the program for anything to substitute — not a stub, not a second provider, not a failover target. A controller that wanted to route search traffic elsewhere would have to find such a value, and the search fails at compile time in a file somebody is looking at. It can only fail over the calls that go through ChatClient, which is exactly the set of calls that any provider can serve.

Compare that with the shared-method version and the difference is where the substitution is checked rather than how strict anybody was. There, the declared type was LLMClient, three values had that type, and swapping them was legal by construction — the whole point of the interface was that it should be. The hatch does not add a rule forbidding the swap. It removes the second value that made the swap expressible.

The cost is that this feature is now explicitly non-portable, which was already true and is now written down. That is the whole trade, and it is the point most often argued backwards in review: a hatch looks like an increase in lock-in because it is the first time the vendor's name has appeared in the application layer, and the name appearing is the only thing that changed. The feature was already unable to run on another provider — that was true the moment it started depending on server-side search, whichever type the call went through. What the hatch changes is whether the codebase knows. It does not reduce your coupling to the vendor, it makes the coupling visible and bounded instead of diffuse and denied.

Knowing the swap cost before you need it

The day a swap becomes real — a price change, an outage record, an acquisition — the question is how much the hatches cost, and the answer should be a number you already have rather than a discovery exercise. Because every hatch is a single import path, the inventory is a command — rg here is ripgrep, a recursive search across the working tree:

bash
$ rg -l "@/integration/anthropic/native" src/
src/features/research/answer.ts
src/features/support/citations.ts

$ rg -c "native\.client" src/features/
src/features/research/answer.ts:3
src/features/support/citations.ts:1

Two features, and three call sites in one plus one in the other, so four in total. That the inventory is a command rather than an investigation is a consequence of the shape rather than of anyone's diligence. A hatch is reached through one import path, imports are written literally in the source, and a literal string in source is something a text search finds exhaustively and without a type checker. The shared-method version has no equivalent command: every call site holds a value declared as LLMClient, so a search for search( returns calls whose receiver could be any of the three implementations, and narrowing that to the ones that will actually reach the vendor requires resolving the wiring — which is the same information the compiler was denied. One design makes the question a grep and the other makes it a research project, and neither of them chose that; it follows from where the vendor's name is allowed to appear.

Pair that with the record each hatch is required to carry and the estimate writes itself. The record is four fields and takes twenty minutes: what vendor capability this uses, why no portable equivalent exists today, what the feature would do if the capability disappeared tomorrow, and who owns the decision. The third field is the valuable one, because it forces a design sketch — for a research assistant, the fallback might be your own retrieval pipeline over a search API, estimated at three weeks — and having sketched it, the swap cost is three weeks plus whatever the second feature costs, quotable in a meeting rather than guessed at under pressure.

Reviewing those records on a schedule is what keeps the number honest. Vendors ship equivalents; a fallback that cost three weeks last year may cost three days now, and if two other vendors have shipped a comparable server-side search, the hatch has become a candidate for promotion into a proper port under chapter 2's rubric — the capability now has more than one implementation with the same meaning, which is the condition that was missing when the shared method was first proposed.

Keeping hatches from becoming the architecture

A hatch is a local exception, and exceptions spread when they are the path of least resistance. Three things keep that in check without turning into ceremony. Make the hatch slightly inconvenient and highly visible: one import path, a name that says what it is, and a comment at the definition pointing at the record requirement. Count them in continuous integration, with the current count committed to the repository, so that adding one is a deliberate act that changes a tracked file and invites a question in review. And track the proportion of model calls that go through ports rather than hatches; if that number drifts below something like 80%, the seam is decoration and the honest response is to stop maintaining it rather than to keep the diagram.

A hatch that should have been a parameter

The count going up is a signal that has to be read rather than acted on directly, because two unrelated things push it in the same direction and they call for opposite responses. The first is the case the hatch exists for: a product needs something one vendor has and the others do not, a record gets written, and the number rises for a reason anybody in the review can defend. The second arrives as a six-line pull request titled something like "add a timeout to summarization", where the engineer needed a per-request timeout and a streaming flag that ChatClient.chat() does not expose, found the native client one import away, and took the diff that touched one file over the diff that touched a port, three implementations and their tests.

That second kind is how the ports share erodes without anybody deciding that it should. On one codebase the recorded count went from four to eleven over a quarter and the proportion of model call sites going through ports fell from 96% to 78%, which the check states in three lines:

text
$ npm run check:hatches
integration hatches: 11 (recorded: 4)
  new since v2.7.0:
    features/summarize/run.ts     native.client.messages.create   timeout, stream
    features/summarize/batch.ts   native.client.messages.create   timeout
    features/tagging/classify.ts  native.client.messages.create   stop_sequences
ports share of model call sites: 78% (threshold 80%)   FAIL

That output reports two numbers rather than one, and the reason there are two is that the count on its own cannot tell an author anything. A count of eleven is small in a large codebase and large in a small one, and it rises for both of the reasons above without distinguishing them. The share is measured over model call sites because that is the denominator the seam was built to serve: the claim a provider seam makes is about the calls it can move, so the quantity that falsifies the claim is the proportion of calls it cannot. A codebase can add hatches and hold its share steady if it is also adding portable call sites, and that is a system growing rather than a system eroding. A share that falls while the count rises is the second kind of pull request winning.

Telling the two kinds apart takes one question in review: if the port carried one more field, could this call be written against the other two providers as well? A timeout, a stop sequence, a streaming flag and a seed all answer yes, so they belong in the port, and the hatch there is a workaround for an interface that is too narrow rather than a boundary a vendor drew. Server-side web search, a vendor's hosted file store, an execution sandbox that runs generated code inside the provider's own infrastructure — those answer no, and the count rising for them is the mechanism working as intended rather than failing quietly.

The last option in the table deserves to be taken seriously rather than held up as a threat. If a service uses one provider deeply and has no funded plan to use another, writing directly against that provider's SDK with the calls kept in a handful of modules is a legitimate design, and it is frequently the right one. It costs a rewrite if you ever move, and the rewrite is honest work you can estimate, whereas a seam maintained for a year and used once bought a two-day code change and six weeks of incidents — a trade that looked good only because the code change was the part anybody counted.

So the answer to what you would build instead of a universal contract has three shapes, chosen by how much divergence you actually face. Per-capability ports plus documented hatches, when most of what you do is portable and a few things are not. No seam at all, deferred until a second provider is funded, when there is one vendor and speculation about the second is the only argument for the abstraction. Two independent implementations of a feature with no shared contract between them, when the vendors' capabilities diverge so far that a shared shape would be a lie in both directions.

The work these chapters point at is small and specific: name the ports your application actually uses, move the vendor decision to the composition root, replay a few hundred real requests through any path you are considering and read the diff, then write down every place you have deliberately bound yourself to one vendor and what leaving would cost. Do those four and the next vendor conversation is an engineering decision with numbers attached instead of a debate about lock-in.

Multi-provider abstraction — read the current version at https://daviddominguez.dev/learn/en/multi-provider-abstraction/documented-escape-hatches

Back to top