What an Abstraction Actually Is
A useful model that lets a caller ignore something specific. PaymentGateway.charge() hides provider HTTP; UtilityManagerFactoryHelper hides nothing and is therefore not an abstraction at all.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
What distinguishes a real abstraction from a wrapper with an important-sounding name?
Checkout must take money. Today that means a Stripe SDK call, three retries, an idempotency key, a webhook that arrives later, and four error shapes that mean different things.
Wrap it. Create PaymentService with a charge() method that takes the SDK's request object and returns the SDK's response object. Nothing about it is wrong to write, it takes ten minutes, and it satisfies a reviewer who asked for "a layer".
The caller still has to know the provider. The types in the signature are the provider's types, so every caller imports the SDK anyway and the "boundary" is a file, not a boundary (Leaky Abstractions).
- The caller still has to know the provider. The types in the signature are the provider's types, so every caller imports the SDK anyway and the "boundary" is a file, not a boundary (Leaky Abstractions).
- It hides nothing, so it makes nothing simpler. Callers still handle four provider error codes, still supply an idempotency key, still know that success is asynchronous.
- It cannot be faked in a test without faking the whole SDK response shape, so the tests get *more* elaborate, not less (Test Doubles, Precisely).
- When a second requirement arrives — invoicing, or an internal wallet — the wrapper cannot absorb it, because it is shaped like one provider's API rather than like the thing the business does.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The provider's SDK types appear in eleven files today; whatever is built has to be adoptable one file at a time.
- Nobody is planning to change provider. The motivation here is comprehension and testability, not portability.
- Payment errors have legal and financial consequences, so any model that hides a distinction the business cares about is worse than no model (Error Modeling).
- A retried charge never takes money twice, whatever the transport did (Idempotency by Design).
- A caller can always tell "declined" from "our system failed", because those two require different actions from a human (An Error Taxonomy That Survives Contact).
- No caller outside the boundary ever constructs a provider request or reads a provider response.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The abstraction owns the *model*: what a charge is in this business, what outcomes exist, and what a caller must supply.
- The adapter behind it owns the translation to one provider, including its retries, its error codes and its asynchrony (Boundary Adapters).
- The caller owns the decision to take money and what to do with each outcome. It owns nothing about HTTP.
- Nobody owns "being generic". Provider independence is a possible side effect here, not the goal (Choosing the Model).
- The seam is where the vocabulary changes: our words on one side (
Charge,Declined,Settled), theirs on the other (PaymentIntent,requires_action). - A boundary is real when the types on the caller's side contain nothing from the other side. That is a mechanical test and it is the only one worth applying (Designing a Module Interface).
- The boundary must expose the distinctions the business acts on, and hide the ones it does not — which is a domain decision, not a technical one (Information Hiding).
The test: what does the caller stop needing to know?
Two pieces of code, both called "the payment abstraction" in real codebases. Only one of them changes what a caller has to understand, and the difference is visible entirely in the signatures — you do not need to read either implementation.
Notice what is absent from the second signature. No retry count, no idempotency key, no provider status string, no HTTP. Those still exist; they moved somewhere the caller does not look. That relocation is what the word abstraction is for.
class PaymentService {
async charge(req: Stripe.PaymentIntentCreateParams)
: Promise<Stripe.PaymentIntent> {
return this.stripe.paymentIntents.create(req)
}
}
// Caller must still know:
// - to set idempotency_key
// - that status may be 'requires_action'
// - which of 40 decline_codes are retryable
// - that success here is not settlement
// It imports the SDK to call this. Nothing was hidden.type Outcome =
| { kind: 'settled'; ref: ChargeRef }
| { kind: 'declined'; reason: DeclineReason; retryable: boolean }
| { kind: 'pending'; ref: ChargeRef; action: CustomerAction }
| { kind: 'failed'; cause: SystemFailure }
interface PaymentGateway {
charge(amount: Money, source: PaymentSource, of: OrderRef): Promise<Outcome>
}
// Caller knows: four outcomes, in domain words.
// Retries, idempotency keys and webhooks live behind it.The second signature contains no provider type, so a caller can be written, read and tested with the SDK entirely out of scope — and retryable exists because the business acts on it, which is a domain decision the wrapper silently deferred to whoever read the decline code. The cost is honest and visible: someone must maintain the mapping from forty decline codes onto one boolean plus a reason, and every mapping is a place to be wrong (An Error Taxonomy That Survives Contact).
Names that describe mechanism instead of service
A name that describes how something is built rather than what it provides is usually a sign that there is no model behind it. UtilityManagerFactoryHelper is a caricature only in its length; OrderDataHelper, PaymentUtils and ServiceManagerImpl are the same thing at production scale.
The reason this matters beyond aesthetics is that a caller reads the name and decides whether to open the file. A name that promises nothing forces the file open, which means the abstraction failed at the only job that makes it worth its cost (Local Reasoning).
looks like A type whose name is built from Manager, Helper, Util, Handler, Processor, Factory or Impl, often two or three of them together. Its methods are a grab bag with no common subject, and its callers pass it whatever it needs each time.
suggests No model exists. The unit was created because code needed somewhere to go, so it is defined by its mechanism rather than by what a caller may stop thinking about. Expect it to grow monotonically and to be imported everywhere (The Utility Dumping Ground).
fix Try to rename it after what a caller stops needing to know. If no such name exists, that is the finding: there is no abstraction here, and the right move is usually to distribute the contents to the units that own the knowledge rather than to rename the bag (Move Responsibility).
ConnectionPoolManager genuinely manages a pool lifecycle — the name states the service. A Factory in the strict sense, where construction is a real decision made from data at runtime, earns the word (Factory). And in framework code, Handler and Middleware are the framework's vocabulary, where deviating would obscure more than it reveals (What a Framework Charges). The discriminator is whether the name lets a reader predict what the unit does without opening it.What the model buys, priced against what it does not
Two requirements, a quarter apart. The first is what the abstraction was built for and it lands cheaply. The second is the honest counterweight, and it is the one that abstraction advocacy usually leaves out.
The pattern generalises: an abstraction contains changes to the *mechanism* it hides, and does nothing for changes to the *concept* it models. Knowing which kind of change is arriving is the whole skill.
First: accept an internal wallet balance alongside cards. Then, a quarter later: support split payment — part wallet, part card, on one order.
There is no interface to add a second implementation to, so wallet support becomes a branch at every call site. Six callers each learn that payment now has two kinds, and each does it slightly differently.
One new implementation of an existing interface, and the callers do not change — which is the specific benefit the abstraction was bought for, arriving as advertised.
Outcome changes, the interface changes, and every caller changes — the abstraction gave no protection at all, and arguably slowed things down, because the wrapper version could have been hacked in one place while the model has to be re-derived first. The abstraction also charges continuously and quietly: a new engineer must learn Settled, Pending and retryable before touching checkout, and a payment bug is now debugged one layer away from the wire (Debuggability by Design).How to build it
Most important first.
- Name the thing a caller wants to stop thinking about. Here: "how money physically moves". If you cannot finish that sentence, you do not yet have an abstraction to build (What an Abstraction Costs).
- Model the outcomes in your own vocabulary, exhaustively.
Settled | Declined(reason) | Pending(ref) | SystemFailureis a model; returning the provider's response object is not (Result Types). - Put the accidental complexity behind it — retries, idempotency keys, webhook reconciliation — and let none of it appear in the signature (Essential and Accidental Complexity).
- Keep the interface as narrow as the callers actually need. An interface that mirrors the SDK method-for-method has copied the provider's design decisions into your codebase (Exposing Too Much).
- Judge it by what a caller no longer needs to know. If the answer is "nothing", delete it and call the SDK directly — that is a legitimate outcome (Over-Decomposition).
- Name it for what it provides, not for its mechanism.
PaymentGatewaynames a role;PaymentServiceFactoryHelpernames some plumbing and tells a reader nothing (Naming).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Next change: "handle 3-D Secure challenges". With a real model,
Pending(ref)already exists as an outcome, so this is one new adapter path plus the UI that handles the existing pending case. With the wrapper, every caller learns a new provider status string. - Next change: "add an internal wallet as a payment source". With the model, a second implementation of the same interface and no caller changes. With the wrapper, there is no interface to implement — the wrapper *is* Stripe — so callers branch.
- What stays expensive either way: a change to what a payment *means* — instalments, split payments across sources, partial capture. That changes the model itself, so every caller changes. An abstraction contains changes to the mechanism, never changes to the concept (Choosing the Model).
- A model adds vocabulary. Every engineer must learn what
Settledmeans here, which is a real cost paid by every new joiner, forever. - It costs a translation layer whose bugs are entirely your own, and mapping code is where subtle mistakes live (Boundary Adapters).
- It hides the provider's specifics, including specifics that occasionally matter — a debugging session now starts a layer further from the wire (Debuggability by Design).
What can go wrong
- The model is thinner than reality: the provider distinguishes "declined, retry later" from "declined, never retry", the model does not, and the business loses money on legitimately retryable declines.
- The model is wider than reality: it exposes every provider concept in our vocabulary, so it is a translation dictionary rather than an abstraction and every provider change still crosses it (Leaky Abstractions).
- One caller needs a provider-specific field, an escape hatch is added, and within a year half the callers use the escape hatch (Exposing Too Much).
- The mitigation fails predictably: the "narrow interface" grows a method per caller until it is the SDK again, because nobody ever reviews an interface for what has been added since (API Stability).
- Callers depend on the model; the adapter depends on both the model and the provider. The provider depends on nothing of ours, which is the entire point of the arrangement (Dependency Inversion).
- The model should depend on nothing but the domain's own types — no HTTP client, no SDK, no framework — or it is not a model, it is a facade over one (Volatile Dependencies).
- Every caller that adopts it adds a reason it is hard to change later. Adoption is a cost, which is why the interface deserves more thought than the implementation (Stable Boundaries).
- "An interface is an abstraction." An interface with the provider's types in its signatures abstracts nothing; it renames. The question is always what the caller stopped needing to know (Interface Versus Implementation).
- "Abstraction means provider independence." Independence is one possible benefit. The everyday benefit is that a caller can be read, tested and reasoned about without the provider in scope (Local Reasoning).
- "Wrap external dependencies, always." Wrap them when you have a model. Wrapping without one produces a layer that must be maintained and hides nothing (Premature Abstraction).
- "The model should expose everything the provider can do, just in case." Then it is the provider's API with different spelling, and it inherits every one of their design decisions (Speculative Generality).
- primitive-obsession
- utility-dumping-ground
Testing it, and how it ages
- The model gets a fake implementation in ten lines, and most caller tests use it. If a fake is hard to write, the interface is shaped like the provider rather than like the domain (Testing as Design Feedback).
- The adapter gets contract tests against the provider's sandbox — that is the only place provider behaviour should be asserted (Contract Tests).
- Test the idempotency invariant at the adapter, by charging twice with one key, because that is where the guarantee is made.
- Do not test the model against the provider. A test that asserts our
Declinedmaps to theircard_declinedbelongs to the adapter and nowhere else.
- A good model outlives the provider it was extracted from, and the giveaway is that adding the second implementation requires no caller changes (Interface Versus Implementation).
- Interfaces widen over time as callers ask for one more field each. Reviewing what an interface has *gained* every year is the cheapest available maintenance on an abstraction (API Stability).
- The model has to be re-cut when the business concept changes, and that is a different and larger job than swapping an implementation — worth saying out loud when someone claims the abstraction makes the system flexible (Speculative Generality).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat an abstraction is defined by what the caller no longer needs to know holds regardless of language; only the mechanism differs — an interface, a module signature, a trait, a function type, or a header file.
- LANGUAGE-SPECIFICWith sum types and exhaustive matching, an outcome model forces every caller to handle every case at compile time, so the abstraction is enforced. Without them the same model is a convention plus a default branch, and the "caller must tell declined from failure" invariant needs a test rather than a type — the design is identical and the guarantee is much weaker.
- CONTESTEDA serious position holds that wrapping third-party libraries is usually waste: the wrapper is written once and outlives its usefulness, providers are swapped far less often than the argument assumes, and the wrapper inevitably leaks so the claimed independence never materialises — meanwhile the SDK is documented, searchable and understood by every new hire. Engineers who have maintained a decade-old vendor wrapper are often right about this. The response here does not defend wrapping in general: it defends wrapping where you can state a *model* the callers use, and concedes that a wrapper without one is exactly the waste being described.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — how easily you can write a fake for an interface is the fastest available check on whether it models your domain or the vendor's.
- — Programming Languages & Runtime Internals — whether the language can force a caller to handle every outcome decides if an outcome model is a guarantee or a convention.