Chapter 7 of 7 · 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:
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 interface | Documented escape hatch | Forked provider-specific path | |
|---|---|---|---|
| Does the type tell the truth | No — promises what two implementations cannot do | Yes — the capability has its own type | Yes |
| Where the gap is discovered | Production, on a failover or config change | Compile time, at the injection site | Compile time |
| Cost to add today | Interface + 3 implementations + tests + call sites | One module, one composition-root line | Duplicate the feature's code path |
| Cost when you genuinely swap | Rewrite the feature and delete a method from a shared contract | Rewrite the feature; the seam is untouched | Rewrite one path; the other stays |
| Effect on the rest of the codebase | Every future provider must answer for it | None outside the feature that asks for it | None |
| Failure mode if misused | Runtime exception on a live request | Compile error | Drift between the two paths |
| Right when | Every provider you support has real equivalents | One vendor has a capability you need now | The 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.
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.
// integration/anthropic/native.ts
// ESCAPE HATCH. Vendor-specific by design. See ADR-0041 before adding a usage.
export interface AnthropicNative {
readonly client: Anthropic;
}// 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:
$ 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:1Two 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:
$ 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%) FAILThat 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