Don't Leak Implementation Details in Your Abstractions
Most abstractions are designed around the implementation, not the domain. Learn practical patterns and red flags to build abstractions that actually protect your codebase.
You’re integrating a new payment provider. The previous one is being phased out, but that’s fine — your codebase has a “transaction service” that abstracts the provider away. Swapping should be straightforward.
Then you start reading the code. The Transaction type has a stripeChargeId field. The service layer checks for charge.status === "succeeded" — a Stripe-specific value. Error handling catches StripeCardError by name. The “abstraction” is just Stripe with extra steps.
This happens constantly. Not because developers are careless, but because the most natural way to build an abstraction is to wrap what’s already there. You start from the provider’s API, mirror its types, name your fields after its fields, and end up with an interface shaped exactly like the implementation behind it.
The problem isn’t that all abstractions leak — Joel Spolsky established that two decades ago. The problem is that many abstractions are designed to leak from the start, because they’re built from the implementation up instead of from the domain down.
This article is a practical guide to spotting and fixing that. We’ll look at three red flags that reveal a leaky abstraction, with before/after TypeScript examples, and a general approach for designing abstractions that actually protect your codebase.
What “Leaking” Actually Means
An abstraction leaks when it forces its consumers to know about the thing it’s supposed to hide. That’s it. If the code calling your interface needs to understand how the implementation works — its naming conventions, its error shapes, its internal sequencing — the abstraction isn’t doing its job.
John Ousterhout frames this well in A Philosophy of Software Design. He distinguishes deep modules from shallow modules. A deep module has a simple interface that hides significant complexity — think of the Unix file system API: open, read, write, close. Five methods that hide disk drivers, caching, journaling, permissions. A shallow module, on the other hand, exposes an interface nearly as complex as the implementation behind it. It adds a layer without adding value.
Most leaky abstractions are shallow modules in disguise. They wrap a provider or a library, but the wrapper’s surface area mirrors the thing it wraps. The consumer still needs to understand the implementation — they just access it through an extra indirection.
Here’s a simple test: if you could swap the implementation without changing any code above the interface, your abstraction holds. If you can’t, it leaks.
This sounds obvious, but it’s surprisingly rare in practice. The next three sections show what it looks like in real code — and how to fix it.
┌─────────────────────────────────────┐
│ Application Layer │
│ (uses domain types & interfaces) │
├─────────────────────────────────────┤
│ Port (Interface/Contract) │
│ (defined by the domain, not the │
│ provider — this is the key) │
├──────────┬──────────┬───────────────┤
│ Adapter │ Adapter │ Adapter │
│ Stripe │ Mollie │ Adyen │
│ │ │ │
│ maps to │ maps to │ maps to │
│ domain │ domain │ domain │
│ types │ types │ types │
└──────────┴──────────┴───────────────┘
The critical detail in this diagram: the port is defined by the application layer, not by the adapters. The arrows of dependency point upward. Each adapter knows about the domain types and translates the provider’s world into them. The application layer never sees the provider.
Red Flag #1 — Provider Naming in Your Domain Types
This is the most common leak, and the easiest to spot. Your domain types contain fields that only make sense for one specific provider.
Consider a fintech application that imports card transactions from external providers. Here’s what a first implementation often looks like:
interface Transaction {
id: string;
amount: number;
currency: string;
date: Date;
stripeChargeId: string;
stripePaymentIntentId: string;
stripeStatus: "succeeded" | "pending" | "failed";
}
This type is not a domain model. It’s a Stripe data structure wearing a domain costume. The moment you integrate a second provider — say, Adyen — you’re stuck. Do you add adyenPspReference? Make the Stripe fields optional? You end up with a union of every provider’s vocabulary, and every consumer has to figure out which fields apply.
The fix has two parts.
First, abstract what’s universal. Every transaction provider has some concept of a unique reference and a status. Those are domain concepts — they belong in your type, with domain names:
interface Transaction {
id: string;
amount: number;
currency: string;
date: Date;
providerId: string;
status: "completed" | "pending" | "failed";
}
providerId is the provider’s reference — whether Stripe calls it chargeId or Adyen calls it pspReference doesn’t matter here. status uses your domain’s vocabulary, not the provider’s.
Second, don’t abstract what isn’t universal. Some fields are genuinely provider-specific. Stripe gives you a paymentIntentId, Adyen gives you a shopperReference — these don’t map to a shared concept, but you still need them to reconcile your data with the provider. The answer isn’t to force them into your domain type. It’s a metadata bag:
interface Transaction {
id: string;
amount: number;
currency: string;
date: Date;
providerId: string;
status: "completed" | "pending" | "failed";
metadata: Record<string, unknown>;
}
Each adapter fills metadata with whatever the provider needs:
function toTransaction(charge: Stripe.Charge): Transaction {
return {
id: crypto.randomUUID(),
amount: charge.amount,
currency: charge.currency,
date: new Date(charge.created * 1000),
providerId: charge.id,
status: mapStripeStatus(charge.status),
metadata: {
paymentIntentId: charge.payment_intent,
receiptUrl: charge.receipt_url,
},
};
}
The TransactionImportService never reads metadata.paymentIntentId. It doesn’t know — or care — that Stripe has such a concept. Only the Stripe adapter touches those fields, and only when it needs to interact with Stripe’s API.
There’s an honest trade-off here: you lose type safety inside the metadata bag. That’s a real cost. But the alternative — baking every provider’s vocabulary into your domain — is far more expensive over time. As Sandi Metz puts it: “duplication is far cheaper than the wrong abstraction.” A loosely typed metadata field is cheaper than a domain type that’s secretly coupled to three different providers.
Red Flag #2 — Implementation Methods Leaking Into Interfaces
This one is subtler. The types look clean, but the interface itself is shaped like the first provider you integrated.
Imagine you’re building a notification system. You start with email — SendGrid, specifically — and define an interface:
interface NotificationService {
sendWithTemplate(
templateId: string,
to: string,
variables: Record<string, string>
): Promise<void>;
addRecipientToList(listId: string, email: string): Promise<void>;
getDeliveryStatus(messageId: string): Promise<string>;
}
Every method here is an email concept dressed up as a general notification contract. sendWithTemplate assumes the provider handles templates — not all do. addRecipientToList is mailing-list management, not notification. getDeliveryStatus returns a raw string because SendGrid’s statuses were strings. If you now need to add SMS through Twilio or push notifications through Firebase, none of these methods make sense.
The problem is the same as before, just one level up: the interface was designed around SendGrid’s capabilities instead of the application’s needs.
Start over. What does the application actually need from a notification system?
interface NotificationService {
notify(recipient: Recipient, notification: Notification): Promise<void>;
notifyBatch(
recipients: Recipient[],
notification: Notification
): Promise<void>;
}
interface Recipient {
id: string;
channels: NotificationChannel[];
}
interface Notification {
type: string;
title: string;
body: string;
data?: Record<string, unknown>;
}
type NotificationChannel = "email" | "sms" | "push";
The application says “notify this person of this thing.” The adapter decides how — which template to use, how to format an SMS body, whether to batch API calls. That’s implementation detail.
The SendGrid adapter might map notification.type to a template ID internally:
class SendGridAdapter implements NotificationService {
private templateMap: Record<string, string> = {
"payment.received": "d-abc123",
"account.verified": "d-def456",
};
async notify(recipient: Recipient, notification: Notification) {
await this.client.send({
to: recipient.email,
templateId: this.templateMap[notification.type],
dynamicTemplateData: {
title: notification.title,
body: notification.body,
...notification.data,
},
});
}
// ...
}
The template mapping, the dynamicTemplateData shape, the SendGrid client — all of that stays inside the adapter. The application layer calls notify() and doesn’t know whether the notification was sent via email template, raw SMTP, or carrier pigeon.
Notice how this also passes the swap test from the previous section. Adding a Twilio adapter for SMS means implementing the same NotificationService interface. The application code doesn’t change. No new methods, no conditionals, no if (channel === "sms") branches leaking into the service layer.
Red Flag #3 — Your Abstraction Forces Callers to Know “How”
Sometimes the interface looks clean — good names, no provider types — but callers still need implementation knowledge to use it correctly. This is the most insidious kind of leak, because it passes a quick code review. The coupling is in the usage pattern, not the type signatures.
Symptom 1: Methods that must be called in a specific order.
const provider = new TransactionProvider();
await provider.authenticate();
await provider.setDateRange(startDate, endDate);
const transactions = await provider.fetchTransactions();
await provider.disconnect();
Every consumer must know this ritual. Forget authenticate() and you get a cryptic 401. Forget disconnect() and you leak connections. Call setDateRange after fetchTransactions and you get stale data. The abstraction’s interface is technically clean, but its usage protocol is an implementation detail leaking upward.
This kind of leak spreads fast. One developer writes the sequence correctly, the next one copies it, and soon you have the same five-line ritual duplicated across ten files. Worse — when the provider changes its authentication flow, you’re hunting down every call site.
The fix: push the sequencing down into the adapter.
interface TransactionProvider {
importTransactions(dateRange: DateRange): Promise<Transaction[]>;
}
One method. The adapter handles authentication, pagination, connection lifecycle — all internally. The caller says what it wants, not how to get it. This is the fundamental contract of a good abstraction: the consumer expresses intent, the implementation handles mechanics.
Symptom 2: Provider-specific error handling leaking into consumers.
try {
await transactionService.import(dateRange);
} catch (error) {
if (error.code === "rate_limit_exceeded") {
// retry with backoff
}
if (error.type === "StripeInvalidRequestError") {
// handle Stripe-specific error
}
}
The caller is doing the adapter’s job. It knows that rate limiting exists as a concept, that Stripe has a specific error type for invalid requests, and that retries need backoff. Swap to a provider that uses HTTP 429 headers instead of error codes, and this handling breaks.
Rate limiting, retries, provider-specific error shapes — those should be handled or translated inside the adapter. The application layer should only deal with domain-level failures — things that actually mean something to the business logic:
try {
await transactionService.import(dateRange);
} catch (error) {
if (error instanceof ProviderUnavailableError) {
// the provider is down, schedule a retry later
}
if (error instanceof ImportValidationError) {
// the data didn't match our domain expectations
}
}
The adapter catches StripeInvalidRequestError and decides: is this a transient issue (translate to ProviderUnavailableError) or a data problem (translate to ImportValidationError)? That decision is provider knowledge — it belongs in the adapter.
Symptom 3: Configuration that only makes sense for one implementation.
interface StorageService {
upload(
file: Buffer,
path: string,
options?: {
acl?: "public-read" | "private";
storageClass?: "STANDARD" | "GLACIER";
contentDisposition?: string;
}
): Promise<string>;
}
Those options are AWS S3 concepts. acl, storageClass, GLACIER — they mean nothing to a GCS or Azure Blob adapter. Yet every caller sees them and has to decide whether to pass them.
The domain usually cares about something much simpler:
interface StorageService {
upload(
file: Buffer,
path: string,
visibility: "public" | "private"
): Promise<string>;
}
The S3 adapter maps "public" to "public-read" ACL and picks a storage class based on its own configuration. The application expresses intent — “this file should be publicly accessible” — and the adapter translates that into provider-specific mechanics.
The common thread across all three symptoms is the same: the caller needs knowledge that should live on the other side of the boundary. Ousterhout calls this building “deep modules” — push complexity down, keep the surface simple. If your callers need a README to use your interface correctly, the interface isn’t done yet.
A useful heuristic for code reviews: read the calling code without looking at the implementation. If anything feels arbitrary, magical, or requires a comment like // must call authenticate first — that’s implementation detail begging to be pushed into the adapter.
Designing From the Domain Down
The three red flags above share a root cause: the abstraction was designed starting from the implementation. You look at the provider’s API, you wrap it, you call it a service. It feels productive — you have working code quickly. But you’ve built the abstraction around the provider, and its shape will haunt every consumer.
The alternative is to design from the domain down. Before writing any adapter code, ask: what does my application need?
Not “what does the provider offer.” Not “what’s the easiest wrapper to write.” What does the business logic actually require from this capability? Define that as your interface — your port — and let it use your domain’s language, your domain’s types, your domain’s level of granularity.
Then write adapters. Each one is a translation layer: it takes the provider’s world — its naming, its quirks, its sequencing — and maps it into your domain’s contract. The adapter is the only place where provider knowledge lives. It’s allowed to be messy, tightly coupled to the provider’s SDK, full of edge-case handling. That’s its job.
This is the Dependency Inversion Principle in practice: the abstraction is owned by the consumer, not the provider. The arrows of dependency point inward — from infrastructure toward the domain, never the other way around.
In practice, this means two design decisions:
Abstract what’s universal. If every provider has some version of a concept — a transaction ID, a delivery status, a file URL — give it a domain name and put it in your interface. providerId, not stripeChargeId. "completed", not "succeeded". These are product concepts that happen to come from a provider.
Leave the rest opaque. Not every provider field maps to a shared concept, and forcing that mapping creates false abstractions. The metadata pattern handles this: a loosely typed bag where each adapter stores what it needs for its own round-trips with the provider. The application layer doesn’t read it, doesn’t type-check it, doesn’t know what’s inside. Only the adapter that wrote it ever reads it back.
// The port — owned by the domain
interface TransactionProvider {
importTransactions(dateRange: DateRange): Promise<Transaction[]>;
}
// The domain type — uses the domain's vocabulary
interface Transaction {
id: string;
amount: number;
currency: string;
date: Date;
providerId: string;
status: "completed" | "pending" | "failed";
metadata: Record<string, unknown>;
}
// The adapter — the only place that knows about Stripe
class StripeTransactionAdapter implements TransactionProvider {
async importTransactions(
dateRange: DateRange
): Promise<Transaction[]> {
const charges = await this.stripe.charges.list({
created: {
gte: Math.floor(dateRange.from.getTime() / 1000),
lte: Math.floor(dateRange.to.getTime() / 1000),
},
});
return charges.data.map((charge) => ({
id: crypto.randomUUID(),
amount: charge.amount,
currency: charge.currency,
date: new Date(charge.created * 1000),
providerId: charge.id,
status: mapStripeStatus(charge.status),
metadata: {
paymentIntentId: charge.payment_intent,
receiptUrl: charge.receipt_url,
},
}));
}
}
Yes, there’s a trade-off. The metadata bag isn’t type-safe. The adapter code is boilerplate. You need to write a mapping function for every provider. But consider what you gain: your entire application — services, handlers, tests — speaks one language. Swapping a provider means writing one new adapter. Adding a provider means no changes to existing code. The domain stays stable while the infrastructure moves around it.
Dan Abramov wrote that abstraction should serve how code evolves with real humans, not cosmetic metrics. A good abstraction earns its cost by making the codebase resilient to change — not by looking clean in a diagram.
TL;DR
- Abstractions leak when they’re built from the implementation up. If you started by wrapping a provider’s API, you probably built a shallow module — an extra indirection that still requires callers to understand the implementation.
- Design from the domain down. Define your interface based on what the application needs, in the application’s language. Then write adapters that translate each provider’s world into that contract.
- Abstract what’s universal, leave the rest opaque. Shared concepts get domain names (
providerId, notstripeChargeId). Provider-specific fields go in a metadata bag that only the adapter reads. - Watch for leaks beyond types. Provider naming in your domain types is the obvious leak. But method sequencing, error shapes, and configuration options that only make sense for one implementation are just as damaging — and harder to spot.
- The swap test is your best friend. If you can change the implementation without touching any code above the interface, your abstraction holds. If you can’t, it leaks.