Abstraction Is Not Hiding Complexity — It Is Choosing What to Expose

A payment team at a large e-commerce company calls chargeCard(amount, token). They have no idea whether the processor underneath is Stripe, Braintree, or an internal gateway. They do not know if the call goes over REST or gRPC, or whether a retry queue sits behind it. They know one thing: pass an amount and a token, get back a result. Three years later the company switches processors. The payment code does not change at all.

Now the alternative. A different team wrote chargeStripe(amount, stripeToken, idempotencyKey, retryCount). When the processor changes, every caller changes. The word "Stripe" — an implementation detail — leaked through the interface into every system that depended on it.

Both functions "hide complexity." Only one is a good abstraction. The difference is not how much they hide. It is what they chose to expose.

What an Abstraction Actually Is

An abstraction is a simplified representation of something more complex. In code it takes the form of an interface: a defined set of operations a component exposes, with no commitment about how they are implemented.

The common definition — "an abstraction hides complexity" — is incomplete and quietly misleading. Hiding is necessary but not sufficient. chargeStripe hides plenty of complexity: TLS, HTTP, JSON serialisation, Stripe's error taxonomy. It is still a bad abstraction, because the thing it chose to expose — the identity of the processor — is exactly the thing that should have been hidden.

An abstraction is a decision about the boundary: which facts callers are allowed to depend on, and which they are not. Hiding is the mechanism. Choosing the exposed surface is the actual design.

Three Properties of a Good Abstraction

It hides irrelevant complexity. The caller of chargeCard does not need retry logic, idempotency keys, or processor-specific error codes. Concealing them is the interface's primary job.

It exposes only what callers need. Every method and every parameter in an interface is a commitment. Commitments are expensive — each one constrains every future implementation. retryCount in the signature is a promise that every processor, forever, has a notion of retry count the caller controls.

It does not leak implementation details. The moment stripeToken appears in the signature, the abstraction has named its implementation. Callers will build on that name, and it can never be removed without breaking them.

A good interface is therefore small (few methods), stable (rarely changes), and complete (supports everything callers legitimately need). An interface with twenty methods is either poorly bounded or covering too many responsibilities. An interface that changes every release forces every caller to change with it.

The Same Idea, Stripped Down

Consider a storage abstraction:

interface BlobStore:
    put(key, data)    -> Result
    get(key)          -> Result<Bytes>
    delete(key)       -> Result
    exists(key)       -> Boolean

Two implementations satisfy it — one backed by local disk, one by S3. Callers depend only on BlobStore. Local disk versus S3 is a deployment decision invisible to them. Tests use the local implementation; production uses S3. The abstraction holds.

Where does it break? If the S3 implementation occasionally returns an EventuallyConsistent result — an S3-specific type — and callers start handling it, the abstraction has leaked. Callers now know about S3. Removing S3 means changing callers. The interface has become a lie.

This is the Law of Leaky Abstractions: every non-trivial abstraction eventually reveals some implementation detail under stress. The law does not make abstractions useless. It means the exposed surface must be chosen deliberately, because the parts you did not choose to expose will still leak occasionally — and you want those leaks to be rare and survivable, not designed in.

The Tradeoff: Coupling vs Cohesion

A good abstraction reduces coupling (AT8). Callers depend on the interface, not the implementation, so implementations change independently. But the interface itself becomes a coupling point. Change the interface and every implementor and every caller must change.

This is why good interfaces are conservative: add methods rarely, remove methods never. The exposed surface is a contract with everyone on both sides of it.

There is a second tradeoff — AT3, simplicity vs flexibility. A four-method BlobStore is easy to implement against any backend. A twenty-method BlobStore — metadata, tagging, versioning, lifecycle policies — is more flexible but forces every implementor to cover the whole surface, including the ones that only needed get and put. The wider you make the exposed surface, the fewer things can implement it.

How It Breaks: Contract Violations

The failure mode is FM8 — schema/contract violation. An interface changes without coordinating with callers: a method renamed, a return type changed, error semantics altered. Callers compiled against the old contract fail at runtime. Across service boundaries or in loosely-typed systems, the failure is silent until production.

Concretely: two services share a PaymentResult type from a common library. Team A adds a field and deploys. Team B has not deployed yet, and its code deserialises a shape it does not expect. Nothing is "wrong" in either codebase. The break is at the boundary — which is exactly where the abstraction was supposed to provide safety, and did not, because the exposed surface changed underneath a caller.

There is a subtler failure too — FM2, cascading failure. A leaky abstraction that exposes retry or timeout behaviour invites callers to add their own retries on top. Under failure, double-retry amplifies load instead of containing it. The abstraction meant to simplify behaviour produced emergent failure behaviour, because it exposed a detail callers then built on.

What Survives Proves the Point

The POSIX filesystem interface — open, read, write, close — has abstracted every storage medium from magnetic tape to NVMe SSDs for fifty years. Four operations. The implementations changed beyond recognition; the exposed surface did not. The interface survived because its designers chose to expose the operations (read a byte range, write a byte range) and not the medium (track, sector, flash block).

That is the whole discipline. POSIX did not survive because it hid more complexity than its competitors. It survived because it chose, correctly and narrowly, what to expose.

The One Sentence

Abstraction is not the act of hiding complexity — every interface hides complexity — it is the act of deciding which small, stable set of facts callers are permitted to depend on. When changing a subsystem forces you to change callers that should not have cared, the abstraction did not hide too little; it exposed the wrong thing.