Blueprint 03 · Integration
The Pluggable Adapter Seam — Stub to Real
Every external capability sits behind one narrow interface. The default that ships is a built-in implementation you fully control. The production integration is a one-line swap, gated by configuration, not a rewrite.
Ship the whole system before integrating any of it
Most products stall at the same place: the end-to-end flow can't be demonstrated because some heavyweight external dependency — a vendor API, a managed broker, an enterprise engine, a paid model — isn't wired up yet. Teams either block on procurement or build throwaway mocks that drift from reality. This blueprint refuses that trade.
The idea is to treat every external capability as a port: a small, canonical interface defined in your terms, not the vendor's. Behind it you keep two adapters that satisfy the same contract — a native implementation that runs entirely inside your stack, and a real one that calls the external service. A registry picks which is active per capability, driven by configuration. The same code path runs in both cases; only the adapter changes.
A mock returns fake data and proves nothing. A native implementation actually fulfils the capability — storing data in your own database, computing a result with your own rules — so the product genuinely works on it. Demos are real, tests exercise real logic, and air-gapped deployments are first-class. The vendor becomes an upgrade, not a prerequisite.
One contract, two (or more) implementations, a registry
Canonical interface (the port)
Define the capability in your own domain terms — summarise(), encrypt(), publish(), classify(). Nothing above this line ever sees a vendor-native shape.
Native + real adapters
At least two implementations satisfy the port: a built-in one (in-process, in your database) and a real one (the external service). More can be added — multiple vendors, a local model, a self-hosted engine.
Selection registry
A central registry maps each capability to its active adapter, resolved from configuration — and, where useful, per tenant. Switching providers is a registration change, with no caller aware it happened.
Once you have the discipline, you apply it everywhere the system meets the outside world: model providers normalised to a few wire protocols; an encryption key provider that is an env-var key in dev and a vault in production; a message bus that is in-process today and a managed broker tomorrow; entire third-party engines that have a "compute it ourselves in the database" twin. The pattern is identical each time.
The seam, drawn out
flowchart TB
subgraph app["Application / domain code"]
CALL["Use case logic
(depends only on ports)"]
end
REG{"Adapter registry
config / per-tenant"}
subgraph ports["Capability ports"]
P1["AI port"]
P2["Key / secret port"]
P3["Messaging port"]
P4["Engine port"]
end
CALL --> P1 & P2 & P3 & P4
P1 --> REG
P2 --> REG
P3 --> REG
P4 --> REG
REG -->|default| NATIVE["Native adapters
in-process · in-database · rule-based"]
REG -->|opt-in| REAL["Real adapters
vendor APIs · vault · broker · engine"]
Capabilities that commonly live behind the seam
| Capability | Native / default adapter | Real adapter(s) |
|---|---|---|
| Intelligence | Deterministic rule-based responder, offline | Hosted LLM(s), local model server, normalised to a few wire protocols |
| Encryption keys | Env-supplied key, versioned blobs | External secrets manager / vault with rotation |
| Domain engine | Computed in your own database | Specialised third-party service / SaaS engine |
| Messaging | In-process dispatch | Managed message bus / streaming platform |
| External data source | Locally materialised sample set | Live connector to a system of record |
Same path, different adapter
sequenceDiagram
participant L as Use case logic
participant P as Capability port
participant R as Registry
participant N as Native adapter
participant X as Real adapter
L->>P: capability(request)
P->>R: which adapter is active?
alt config = default
R-->>P: native
P->>N: fulfil
N-->>L: canonical result
else config = real
R-->>P: real (vendor / vault / broker)
P->>X: fulfil
X-->>L: canonical result (same shape)
end
A powerful corollary: a "demo" or "offline" mode is not a special code branch riddled with if statements — it is simply the configuration that selects native adapters everywhere. Because the exact production code path runs against them, the demo can never drift from the real product.
Five alternate use cases
Multi-gateway checkout
A single payments port with adapters per processor lets you route by region, fail over between providers, and run a sandbox adapter in test — without checkout logic knowing which gateway charged the card.
Channel-agnostic delivery
One notify() port over email, SMS, push and in-app adapters; a console adapter in dev. Adding a new channel never touches the code that decides what to send.
Portable object storage
A storage port with a local-filesystem default and cloud-bucket adapters keeps the app cloud-agnostic and lets contributors run it with zero cloud credentials.
Swappable SSO
An identity port abstracts the provider so the same product ships with a built-in credential store for small customers and enterprise SSO adapters for large ones — selected per tenant.
Pluggable enrichment sources
External reference data (maps, weather, registries) sits behind a connector port with a cached sample default, so the product degrades to last-known data instead of failing when a source is down.
The cost of the seam
| Risk | Why it happens | Mitigation |
|---|---|---|
| Leaky abstraction | A vendor-specific capability leaks through the port, so the native adapter can't really satisfy it | Design the port around the domain need, not the richest vendor; accept a least-common-denominator contract and handle extras out of band |
| Native/real divergence | The two adapters behave subtly differently, so passing tests on native hides real-world bugs | Run the same contract test-suite against every adapter; treat the native one as a conformance reference |
| Registry sprawl | Too many ports and per-tenant overrides become hard to reason about | Keep ports few and coarse; document the active matrix; default everything sensibly |
| False sense of portability | "We can switch any time" goes untested until the day you must | Periodically exercise the real adapters in CI/staging; don't let them rot |
The seam is also a control point
Because every outbound dependency passes through an adapter, the seam is the natural place to enforce data-minimisation (what leaves the system), to centralise credential handling (the real adapter reads from the secret store; callers never see keys), and to add resilience (timeouts, retries, circuit breakers) uniformly rather than per call site.
- Gate the native-vs-real choice behind explicit configuration, and make the safe option the default so a missing config never accidentally calls an external service.
- Version the contract and any stored artifacts an adapter produces (e.g. encrypted blobs carry a key version) so you can rotate or migrate adapters without losing access to old data.
- Record which adapter served each significant operation — provenance at the seam makes incidents and audits tractable.
When to reach for it — and when not to
Use it when
- You need to demo or pilot an end-to-end product before integrations are procured or built.
- Vendor lock-in is a real commercial or compliance concern, or customers demand a choice.
- Different deployments need different providers — sovereign, on-prem, local, or cloud.
- A capability has several interchangeable providers (payments, model, storage, identity).
Avoid it when
- There is exactly one provider, deeply embedded, with no plausible alternative — the abstraction is pure overhead.
- You need a vendor's most advanced, idiosyncratic features that a portable contract would throw away.
- The team won't maintain conformance tests across adapters — divergence will bite.
- The capability is trivial and stable; wrapping it earns nothing.
Want this pattern applied to your build?
These are the structures we reach for. Tell us the system you're trying to build.