Interfaces Emerge From Boundaries
Once the boundary is drawn, each edge crossing it is a contract to write — in your terms, not the stranger's. The interface says what your system needs from the other side and what it promises back; the provider's SDK is an implementation of that, not a definition of it.
The situation, the reflex, and why it stalls
Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.
You have found the boundary. What does the interface at each crossing actually contain, and whose vocabulary should it be written in?
I know the payment provider is outside. I have their SDK installed and their types are already in my checkout code — ProviderCharge, ProviderStatus.SUCCEEDED. It works, and I have a feeling I have designed nothing; the provider has designed my checkout for me.
Use the SDK directly. It is well documented, it is typed, and calling client.charges.create(...) from the checkout handler is the fastest path to a test-mode payment succeeding. Wrapping it feels like adding a layer for the sake of a layer.
The checkout code now speaks the provider's language. Their status enum is compared in your business logic, their error codes decide what the customer sees, and their idea of a charge is stored in your order table. Nothing here is *your* interface; it is theirs, inlined.
- The checkout code now speaks the provider's language. Their status enum is compared in your business logic, their error codes decide what the customer sees, and their idea of a charge is stored in your order table. Nothing here is *your* interface; it is theirs, inlined.
- When you ask "what does checkout need from a payment?", there is no answer written anywhere, because the SDK answered it before the question was asked. Switching provider, adding a second, or testing without the network are all impossible to estimate.
- The contract at the boundary is undocumented in both directions. Nobody has written what the backend promises the provider (an idempotent request, a correct amount) or what it requires (an outcome that can be trusted), so those promises are kept by accident.
- The layering that was skipped to save time arrives anyway, as a refactor, on the day the provider deprecates a field.
The move
Precisely enough to apply it to a problem you have never seen — not a slogan.
- An interface at a boundary is written from the inside out. Start from what your system needs from the other side, in your own nouns: "charge this amount for this order, and tell me whether it succeeded, failed, or is still unknown". That sentence is the interface. It exists before any provider is chosen and is true of every provider.
- Then say what your side promises in return, because a contract has two directions: the request carries an idempotency key so a retry is safe; the amount is authoritative; an outcome reported twice will be tolerated. The provider's documentation tells you what they require; the interface records that you meet it.
- Only now map the interface onto a provider. Their SDK becomes an adapter that turns your
charge(order, amount)into theircharges.create(...)and their status enum into your three outcomes. The adapter is the only file that knows the provider's name. - Do the same for the edge coming back. "Something outside tells me an outcome for an order" is your interface; the provider's webhook payload is one way that sentence gets delivered, and the adapter translates it at the door.
The interface, before the provider
This is the outbound payment interface as checkout needs it, written before any SDK is opened. It is short on purpose. The promises are as much a part of it as the signature; a caller that does not keep them will get behaviour the interface does not describe.
Notice the third outcome. A timeout is not a decline, and an interface that forces the adapter to pick one is asking it to invent a fact.
“We need a full payment abstraction layer that supports any provider.”
- ↓Why? So we can switch providers later without rewriting checkout.
- ↓Why would that be hard today? Because the provider's types and status codes are used directly inside checkout.
- ↓Why is that the problem, rather than the number of providers? Because a change on their side becomes a change in our business logic. The problem is where their vocabulary lives, not how many of them we have.
the claim was right when Two providers must genuinely coexist — different countries, or a fallback when one is down — and the routing between them is a requirement rather than a hope. Then the layer above the adapters earns its place.
1interface TakePayment2 charge(orderId, amount, currency) -> PAID | DECLINED(reason) | UNKNOWN3 -- promises made by the caller:4 -- orderId is the idempotency key: calling twice for one order is safe5 -- amount is computed by checkout and is final6 -- promises required of the implementation:7 -- UNKNOWN means "may or may not have happened"; never report it as DECLINED8 -- an outcome may later be corrected by recordOutcome(orderId, outcome)9 10interface OutcomeArrives11 recordOutcome(orderId, PAID | DECLINED(reason)) -> ok12 -- safe to call more than once with the same outcome13 -- may arrive before charge() has returnedNothing here names a provider. The adapter's job is to keep it that way.
Leaking versus translating
The pair below is the difference between the reflex and the move, in the handler that receives the provider's outcome. Both work on the day they are written. Only one of them still describes checkout after the provider renames a field.
The webhook handler reads `event.data.object.status`, compares it to the provider's `"succeeded"`, and updates `orders.provider_status` with the raw string. The order table now has a column whose meaning is defined by someone else's documentation, and the admin page shows it verbatim.
The handler verifies the signature, maps the provider event to `recordOutcome(orderId, PAID)` or `DECLINED(reason)` through the adapter, and knows nothing else. The order table stores your status; the provider's event id is kept for deduplication and audit, not for meaning.
When the provider changes the event shape, one adapter changes and every test above it still passes. When you add a second provider, the order table does not gain a second status column. And the mapping is a written decision — "their succeeded means our paid" — which is the sentence someone will need to check when it turns out not to be true (Consumer-Side Idempotency).
Which to write first: the contract or the call
The order below builds the interface before the adapter, so that the fake exists before the real thing and checkout can be finished against it. It is a defensible sequence, and the alternative is defensible too: when the provider's behaviour is the unknown, the spike goes first and the interface is written from what the spike taught.
- 1Write the needs, promises and outcomes in your nouns
because It is the cheapest artefact and it is the one everything else has to agree with.
- 2Build the fake adapter and finish checkout against it
because Checkout's logic — including the UNKNOWN branch — can be complete and tested with no network and no credentials.
- 3Build the real adapter in test mode
because Now the only thing being learned is the provider's protocol; the domain logic is already known to work.
- 4Run one contract test against the real provider
because The fake proves the logic; only the real thing proves the translation — that their success is your PAID.
How to do it
Most important first.
- For each boundary edge, write the interface as what you need and what you promise, in your domain's nouns. Order, amount, outcome — not
ProviderCharge. - Make the outcome honest. Most external calls have three results, not two: succeeded, failed, unknown. An interface with only two has hidden the case that matters most (A Timeout Tells You Nothing About Whether It Happened).
- Write the adapter as the one place the provider's vocabulary is allowed. Grep for the provider's name afterwards; it should appear in one directory (Boundary Adapters).
- Write a fake adapter that satisfies the same interface and use it in tests. If the fake is hard to write, the interface is leaking provider detail.
- Give the interface a version in your head: what would change if the provider changed? If the answer is "the adapter", the interface is doing its job (Versioned Interfaces).
Worked on a concrete problem
The move has to produce something. This is what it produced.
- Checkout needs, in its own words: "take payment for order O of amount A; result is paid, declined, or unknown". Checkout promises: "the same order will never be charged twice on retry, because every request carries the order id as the idempotency key; the amount is computed here and is final". That is the whole outbound interface. The adapter for the chosen provider is a page long and is the only file that imports their SDK.
- The inbound edge: "an outcome arrives for an order, possibly more than once, possibly before my own request returned". The interface is
recordOutcome(orderId, outcome), and it must be safe to call twice. The provider's webhook handler verifies the signature, translates the payload, and calls it. When a second provider is added for a new country, checkout does not change; a second adapter does. - File upload with an object store outside: the interface is "store these bytes under this key and give me a reference I can hand to a browser later". Written that way, a folder on disk satisfies it in development and the object store satisfies it in production, and nothing above the adapter can tell.
How you know it worked
What now exists that did not before, and what question you can now ask.
- The interface at each boundary edge is a short list of sentences in your nouns, and it would still be true if the provider were replaced.
- The provider's name appears in one adapter directory and nowhere else; a search proves it.
- A fake implementation of the interface exists and the checkout tests run against it without a network.
- The "unknown" outcome exists in the interface and something in checkout handles it deliberately.
The questions you can now ask
The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.
- ?What does my system need from this edge, said in my own nouns and without naming the provider?
- ?What does my side promise across this edge — idempotency, authority over the amount, tolerance of repeats?
- ?Does the interface admit "unknown", and what does the caller do with it?
- ?If the other side changed tomorrow, which files would change — and is that the list I intended?
What can go wrong
- The interface is generalised for providers you do not have. A payment abstraction that anticipates every provider's every feature is a second SDK, and worse than the first. Write the interface for what checkout needs today.
- The adapter translates types and not meaning. Their
SUCCEEDEDbecomes yourPAIDwithout asking whether their success means money moved or means the request was accepted; the vocabulary changed and the misunderstanding survived. - The interface is written and the two-way promise is not. Retries are unsafe because nobody wrote down that the request must be idempotent, and the adapter is blamed for a contract that was never stated.
- Everything internal gets the same treatment. An adapter between checkout and the cart module you own is a boundary you invented.
- An adapter is a layer, and a layer is code to read, test and keep in step with the provider. On a system with one provider for its whole life it is a bet that did not pay.
- Writing the interface in your own nouns can hide provider capabilities you would have used — a partial refund, a delayed capture — until you extend the interface to expose them.
- The fake adapter makes tests fast and makes it possible to pass every test without ever having spoken to the real provider. The contract test against the real thing still has to exist somewhere.
- "So I should never use the SDK directly." Use it — inside the adapter. The SDK is the fastest correct way to speak the provider's protocol; the point is that only the adapter speaks it.
- "An interface is a TypeScript interface." The type is the shadow of the interface. The interface is the sentences — needs, promises, outcomes — and a type that omits the promises has recorded half of it.
- "Provider-independent means the provider does not matter." It matters enormously for the adapter and the failure plan. Independence is about where their vocabulary lives, not about pretending the choice is free.
Where this applies
Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.
- GENERALNeeds-and-promises in your own nouns is how any contract is written, from an HTTP API to a function signature; the adapter is the general form of "the caller's vocabulary and the callee's are different".
- CONTESTEDThe opposing view, at its strongest: a thin wrapper around a well-designed SDK is a premature abstraction that costs real code now against a provider switch that almost never happens, and when it does happen the switch is never as clean as the abstraction promised because the second provider's semantics differ. Teams who hold this view call the SDK directly, keep calls in one module by convention, and pay the refactor if it ever comes. They are right when the provider is stable, the integration small, and the team disciplined about where calls live.
- ILLUSTRATIVEThe three-outcome interface, the adapter and the second-country provider are invented to show the shape; the real contract with any provider has more in it.
Where the depth lives
This domain asks the question and hands the answer off by name.
- — The manifesto's delegation cards at /manifesto/delegating draw the line this lesson draws in code: what the SDK does for you, and what you still have to understand to call it correctly.