Be Software Architecture Wise
Be Wise Apps

Leer en español · Lire en français · Auf Deutsch lesen

Section 3.3 of 3.8 · Intermediate · 24 min read

Information hiding, abstraction and interfaces

Video lesson

5 min · narrated, with captions
Listen only (audio)
Read the transcript

Every good module keeps a secret. In this lesson you'll learn what information hiding really means, how interfaces work as contracts, why abstractions leak, and how to keep the interfaces between components stable.

When you post a parcel, you hand it over at the counter and get a tracking number. You don't know, or care, whether it travels by van, train or plane, or which depot it passes through. The postal service can change all of that tomorrow, because the deal at the counter stays the same. Good modules work like that counter. The trouble starts when customers rely on details nobody promised, such as the parcel always arriving before ten.

Information hiding is the principle David Parnas proposed in 1972. Each module hides one design decision that's difficult or likely to change, behind an interface that reveals as little as possible. Good candidates to hide are data structures, the order of operations, input and output formats, algorithms, and today, which supplier or database you use. Whatever is hidden can change cheaply. Whatever is exposed becomes expensive to change, because others depend on it.

Don't confuse this with encapsulation. Encapsulation is a language mechanism, such as private fields. Information hiding is the design decision about what to keep secret. You can encapsulate perfectly and still hide nothing. A class with private fields, and a public getter and setter for each, tells every caller what fields it has. Change them, and every caller breaks. An interface is a contract, and Bertrand Meyer's design by contract makes it precise. Preconditions are what the caller must make true. Postconditions are what the operation then guarantees. Invariants hold between every operation. The contract also assigns blame. If a precondition is broken, the bug is in the caller. If a postcondition is broken, it's in the supplier.

John Ousterhout offers a useful picture. Think of a module as a rectangle. The top edge is the interface everyone must learn. The area is the functionality it provides. A deep module has a small interface over a lot of functionality. Unix file handling is his favourite example: open, read, write, seek and close hide caching, devices, permissions and disk layout. A shallow module has an interface nearly as complex as what it does, such as a wrapper class whose methods each pass one call straight through. It adds cost without hiding much. This is a valuable test for services too. A service whose API simply mirrors its database tables is shallow, and pushes all the real logic onto its clients.

In 2002, Joel Spolsky wrote that all non-trivial abstractions leak to some degree: their hidden details still affect you. A database mapping library hides SQL, until a loop quietly issues one query per row. So be honest about leaks. A remote call can time out, or succeed while the reply is lost. A good remote interface takes a timeout, and tells a rejected request apart from an unknown outcome. Hyrum's law adds a warning. With enough users of an API, every observable behaviour will be depended on by somebody: the order of results, the wording of an error, even how long a call takes. That's why stable APIs keep a minimal surface, change by adding rather than altering, version breaking changes explicitly, and use contract tests to catch accidents.

Now a worked example. A food delivery company calls Payment Provider A's code directly in eleven places. Finance wants to add Provider B, and to be able to switch within a day if one has an outage. The design introduces a payment gateway interface owned by the shop, in the shop's own terms: authorise, capture and refund. Checkout and refunds depend only on it. Each provider gets an adapter that implements the interface, and translates to that provider's API and error codes. Only the adapters know the providers exist. Configuration picks the adapter for each country at start-up. The hidden decision is which provider, and how it's called.

The interface hides which provider is used, its version, its error codes, its retry rules and its identifiers. Its preconditions are a positive amount in minor units, a supported currency, and a unique idempotency key for each attempt. It promises one of three outcomes. Approved means funds are reserved. Declined means no money moved. Unknown means the outcome couldn't be determined, so the call may be retried with the same key. The network leak is part of the contract, not a surprise. The payoff: adding a provider used to mean eleven call sites and the orders table. Now it's one adapter, its tests and a configuration entry.

The full section shows design by contract in code, and eight practices for stable APIs. Information hiding tells you what each module keeps to itself, but not which way dependencies should point. That's Section three point four. See you there.

What you need to know

The principle: hide what is likely to change

In Section 3.1 David Parnas's KWIC example showed that decomposing a program by processing steps spread each design decision across several modules. His alternative, information hiding, gives each module one decision to keep secret: how lines are stored, how shifts are computed, when sorting happens. The module's interface is chosen to reveal as little as possible about that decision. Everything else in the system depends only on the interface, so the decision can change without them noticing.

Parnas's list of things worth hiding is still a good checklist half a century later: data structures and how they are stored; the sequence in which operations are carried out; the details of hardware, devices and operating systems; formats of input and output; and algorithms that might be replaced by better ones. Modern additions follow the same logic: which third-party provider is used, which database, which message broker, which version of an external API, and which business rules are under review.

The principle applies identically at every scale. A class hides its fields; a package hides its internal modules; a service hides its database, its language and its deployment; an organisation's public API hides which services exist behind it. In each case, what is hidden can change cheaply, and what is exposed becomes expensive to change because others depend on it.

Encapsulation is not information hiding

The two terms are often used as synonyms, but they mean different things. Encapsulation is a mechanism: bundling data with the operations on it and restricting direct access, through private fields, module boundaries or access modifiers (Section 0.3). Information hiding is a design principle: deciding which decisions to keep secret, and designing the interface so that it does not reveal them. Encapsulation is one of the tools you use to hide information; it does not decide for you what to hide.

It is easy to encapsulate perfectly and still hide nothing. A class with private fields and a public getter and setter for each of them is fully encapsulated, yet every caller knows exactly which fields exist and what types they have. Change the internal representation and every caller breaks. The opposite also happens: Python offers no enforced privacy at all, yet a well-designed Python module can hide its decisions very effectively through a carefully chosen set of functions.

# Encapsulated, but the representation leaks through the interface
class Price:
    def __init__(self, pounds: float):
        self._pounds = pounds
    def get_pounds(self) -> float:          # callers now depend on 'float pounds'
        return self._pounds
    def set_pounds(self, value: float):
        self._pounds = value

# Information hiding: callers ask for what they need, not for the field
class Money:
    def __init__(self, minor_units: int, currency: str):
        self._minor, self._currency = minor_units, currency
    def add(self, other: "Money") -> "Money": ...
    def allocate(self, ratios: list[int]) -> list["Money"]: ...
    def format(self, locale: str) -> str: ...
# Switching from pence to a decimal type later changes only this class.
Both classes are encapsulated; only the second hides the decision about how a price is stored.

Abstraction and interfaces as contracts

An abstraction is a simplified view of something that keeps what matters for a purpose and leaves out the rest. A file is an abstraction over disk blocks, caches and drivers; a payment is an abstraction over a card network, a provider's API and a bank's settlement process. Abstraction and information hiding are two sides of the same act: choosing an abstraction decides what the interface shows; hiding the rest makes the abstraction safe to rely on.

An interface is therefore a contract between a module and its users. Its formal part is what a type checker or API schema can see: operation names, parameter and return types, error types, field names in a JSON body. Its informal part is just as binding: what the operation does, its side effects, whether it can be retried safely, how long it takes, which errors are possible and what they mean. John Ousterhout stresses that the informal part can only be captured in documentation, and that if users must read a module's code to learn how to use it, the module provides no real abstraction.

Design by contract

Design by contract, developed by Bertrand Meyer in the 1980s with the Eiffel language and described in his book Object-Oriented Software Construction and his 1992 article Applying 'Design by Contract', makes the contract precise. Each operation has a precondition, which the caller must make true before calling; a postcondition, which the operation guarantees on return if the precondition held; and each class or module has invariants, conditions that hold between every pair of operations. The contract assigns responsibility: if a precondition is broken the bug is in the caller; if a postcondition is broken it is in the supplier.

Meyer argued against the opposite habit, defensive programming, in which every routine checks everything in case someone calls it wrongly. Redundant checks add code, complexity and new failure paths. Instead, each condition should be the clear responsibility of one side. A routine with a demanding precondition is simpler; a routine with a tolerant one is easier to call; the choice is a design decision, made explicitly. At the boundaries of a system, where input comes from untrusted users or other organisations, validation is not optional: there the contract says that the boundary module itself checks everything (Chapter 8).

class Account:
    """Invariant: balance_pence >= -overdraft_limit_pence."""

    def withdraw(self, amount_pence: int) -> None:
        # Preconditions (the caller's obligations)
        if amount_pence <= 0:
            raise ValueError("amount must be positive")
        if self.balance_pence - amount_pence < -self.overdraft_limit_pence:
            raise InsufficientFunds(self.id)
        old = self.balance_pence
        self.balance_pence -= amount_pence
        # Postcondition (the supplier's guarantee)
        assert self.balance_pence == old - amount_pence
        assert self.balance_pence >= -self.overdraft_limit_pence  # invariant
A contract written down: preconditions checked on entry, the postcondition and invariant stated and asserted.

The same three ideas scale up to services. An API specification, such as an OpenAPI document, states the preconditions (required fields, formats, authentication), the postconditions (the response and the state change, such as an order exists with status PLACED) and the invariants a client may rely on (an order's total never changes after payment). Consumer-driven contract tests, in which each client publishes the expectations it relies on and the provider runs them in its build, check those contracts automatically.

Leaky abstractions

In November 2002 Joel Spolsky published The Law of Leaky Abstractions, which states that All non-trivial abstractions, to some degree, are leaky. A leaky abstraction is one whose hidden details still affect its users. TCP presents a reliable stream over an unreliable network, until a cable is cut and it cannot. SQL lets you state what you want, not how to get it, until two logically equivalent queries differ in speed by a factor of a thousand and you have to read the query plan. An object-relational mapper hides SQL until a loop quietly issues one query per row.

The consequence for architects is not that abstractions are bad, but that an interface should be honest about the details that will leak. The classic example is the remote call that looks exactly like a local function call. Network calls are slower by orders of magnitude, can time out, can succeed on the server while the response is lost, and can fail independently of the caller; Section 6.1 lists these as the fallacies of distributed computing. A good remote interface exposes this: it takes a timeout, returns errors that distinguish rejected from unknown outcome, and accepts an idempotency key so that retries are safe. Hiding the network completely is the leak waiting to happen.

Deep and shallow modules

John Ousterhout's A Philosophy of Software Design (first published in 2018; second edition 2021) offers a useful way to judge an interface. Think of every module as a rectangle: the top edge is its interface, which every user has to learn, and the area is the functionality it provides. A deep module has a small interface over a lot of functionality, so it pays back the cost of learning it many times over. A shallow module has an interface nearly as complex as what it does, so it adds cost without hiding much.

His favourite example of depth is Unix file input and output: a handful of calls (open, read, write, seek, close) hide file systems, disk layout, caching, permissions, devices and concurrent access. His example of shallowness is a class whose methods each forward one call to another class, or a linked-list class whose interface exposes almost everything it does. Ousterhout argues that the common advice to keep classes and methods very small can produce what he calls classitis: many shallow classes whose interfaces together add up to more complexity than one deeper module would.

Deep and shallow modules

A deep module has a narrow interface over a large implementation; a shallow module has a wide interface over little implementationDeep modulesmall interfacelarge hidden implementationbuffering, caching, retries,device drivers, permissions,disk layout, concurrencye.g. open, read, write, seek, closehigh benefit for the cost of learning itShallow modulelarge interfacelittle behavioure.g. a wrapper class whosemethods each pass one callstraight throughcosts nearly as much to learn as it saves
Ousterhout's picture: the width of the top edge is the interface users must learn; the area below is the functionality it hides.

Not everyone agrees with how far Ousterhout takes this. Advocates of small classes and functions, such as Robert C. Martin in Clean Code, argue that tiny units are easier to name, test and reuse; Ousterhout and Martin debated the point in a written exchange published in 2025. Both sides agree on the underlying goal, which is to reduce the complexity a reader has to hold in mind. At architecture scale the depth test is particularly valuable: a service whose API mirrors its database tables one to one, with create, read, update and delete for each, is shallow, and pushes all the real logic onto its clients.

Hyrum's law: everything observable becomes the contract

Hyrum's law, named after Hyrum Wright, then a software engineer at Google, by his colleague Titus Winters and popularised in Software Engineering at Google (2020), observes that with a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviours of your system will be depended on by somebody. The order in which a function returns items from an unordered set, the exact wording of an error message, a field that is always present though documented as optional, the fact that a call takes 50 milliseconds: if users can see it, some of them will rely on it.

The law explains why information hiding has to be designed in, not hoped for. Every behaviour you expose by accident becomes part of the de facto contract, and changing it will break someone, however clearly the documentation said it was unspecified. Some teams deliberately randomise unspecified behaviour, such as iteration order, so that no one can come to depend on it; Go randomises map iteration order for this reason. Others keep response payloads lean, so there is less to depend on.

Designing stable APIs between components

Between components and services, interfaces are the most expensive things to change, because changing them requires coordinating with every consumer. The practices below follow from everything above; Section 0.6 introduced APIs and Section 7.7 covers schema compatibility in depth.

Practices for stable component and service APIs

PracticeWhat it meansWhy it works
Minimal surfaceExpose only operations and fields that a known consumer needs; add more on request.Less to depend on, so less to break (Hyrum's law).
Model the domain, not the storageOffer operations such as place_order and cancel_order rather than table-shaped create, update and delete.The storage can change without changing the contract; the module stays deep.
Additive changeAdd optional fields and new operations; never rename, remove or change the meaning of existing ones within a version.Old clients keep working; new clients use the new parts.
Tolerant readerClients ignore fields they do not understand and do not depend on field order.Providers can add fields without breaking anyone.
Explicit versioningIntroduce a new major version (in the URL, a header or the event schema) for breaking changes, and run old and new side by side for a published period.Consumers migrate on their own schedule; deployment coupling is avoided.
Expand and contractAdd the new form, migrate consumers, confirm through monitoring that nobody still uses the old form, then remove it, as separate releases.Breaking changes happen without a joint release.
Contract testsConsumers publish their expectations; the provider's build runs them.Accidental breaking changes are caught before deployment.
Honest failure semanticsDocument timeouts, retryability, idempotency and error meanings as part of the contract.Avoids the worst leaks of the network abstraction.

Worked example: a payment interface that hides the provider

A food-delivery company takes payments through Provider A. Finance wants to add Provider B for lower fees in one country, and to be able to switch providers within a day if one has an outage. Today the checkout code calls Provider A's SDK directly in eleven places, uses its error codes to decide what to show the customer, and stores its transaction objects in the orders table. Every one of those is a decision that has leaked.

The design introduces a PaymentGateway interface owned by the shop, not by any provider. It is expressed in the shop's own terms: amounts as integers in minor units with a currency, the shop's order identifier, an idempotency key and three outcomes. Each provider gets an adapter that implements the interface and translates to and from that provider's API, error codes and quirks. Configuration chooses the adapter per country. This is the same move as the intermediary in Section 2.6, now seen as information hiding: the hidden decision is which provider, and how it is called.

from typing import Protocol, Literal
from dataclasses import dataclass

@dataclass(frozen=True)
class PaymentResult:
    outcome: Literal["approved", "declined", "unknown"]  # unknown: safe to retry
    reference: str | None        # our reference, not the provider's object

class PaymentGateway(Protocol):
    def authorise(self, order_id: str, amount_minor: int, currency: str,
                  idempotency_key: str) -> PaymentResult: ...
    def capture(self, reference: str) -> PaymentResult: ...
    def refund(self, reference: str, amount_minor: int) -> PaymentResult: ...

class ProviderBAdapter:            # implements PaymentGateway
    def authorise(self, order_id, amount_minor, currency, idempotency_key):
        resp = self._client.create_charge(value=amount_minor / 100,   # B wants decimals
                                          currency=currency, key=idempotency_key)
        return PaymentResult(_map_status(resp.code), _store_ref(resp.id))
The interface is owned by the shop and speaks its language; only adapters know a provider exists.

The payment interface and its adapters

Checkout depends on a PaymentGateway interface owned by the shop; adapters for two providers implement it and only they know the providers' APIsCheckoutRefundsowned by the shopPaymentGatewayauthorise, capture,refundConfigurationper country: provider BProviderAadapterProviderBadapterA's APIB's APIselects adapterat start-upCallers see only the interface; provider names, error codes, retries and currency rules stay inside the adaptersSolid arrows: depends on or calls. Dashed arrows: implements or selects.
Callers depend on an interface owned by the shop; adapters implement it, and configuration chooses which one runs.

Worked example

What the interface hides, and what it deliberately does not

Checking the design against the ideas in this section.

Hidden decisionWhich provider, its SDK and API version, its error codes, its retry rules, its decimal or integer amounts, its transaction identifiers.
Contract (preconditions)Positive amount in minor units, a supported ISO 4217 currency, a unique idempotency key per payment attempt.
Contract (postconditions)approved means funds are reserved and a reference is returned; declined means no money moved; unknown means the outcome could not be determined and the same call may be retried with the same key.
Deliberate non-hidingThe interface exposes unknown instead of pretending payments always succeed or fail: the network leak is part of the contract, not a surprise.
Cost of a new providerBefore: changes in 11 call sites plus the orders table. After: one new adapter class, its tests and a configuration entry.
DepthThree operations hide a substantial amount of provider-specific logic: deep rather than shallow.

Watch for the lowest-common-denominator trap: if Provider B offers a useful feature that A lacks, such as instalment payments, the interface must either grow an optional capability or the feature is unavailable. Abstractions over suppliers always trade some of each supplier's strengths for freedom to switch.

Hyrum's law still applies: if the checkout starts depending on Provider A's typical latency, a slower Provider B will expose it. Put timeouts in the contract.

How professionals apply it

Experienced designers start an interface from the needs of its callers, written in the caller's language, and only then design the implementation. They keep a list of the decisions each important module hides and review new interfaces against it. They treat every public API as a product with a lifecycle (proposal, review, versioning, deprecation) because, by Hyrum's law, anything published will be used. And they accept that abstractions leak, so they make the leaks explicit: latency budgets, error semantics and retry rules appear in the contract.

Information hiding tells you what each module should keep to itself. It does not tell you which way the dependencies between modules should point, or what happens when they form loops. That is the subject of Section 3.4.

Check your understanding

Why can a class with private fields and public getters and setters for all of them still fail at information hiding?

Because the interface reveals the exact internal representation, so callers depend on it and any change to the representation breaks them; encapsulation is present but no decision is hidden.

In design by contract, who is at fault if a precondition is violated?

The caller, because satisfying the precondition is its obligation; a violated postcondition indicates a bug in the supplier.

What makes a module deep in Ousterhout's sense?

It provides a lot of functionality behind a small, simple interface, so the cost of learning the interface is low compared with what it hides.

Why does Hyrum's law argue for small API responses?

Every observable field or behaviour will eventually be relied on by some client, so anything exposed becomes hard to change; exposing less leaves more freedom.

Next · 3.4DependenciesWhich way dependencies should point and why: Robert C. Martin's acyclic, stable-dependencies and stable-abstractions principles, the instability, abstractness and distance metrics, breaking cycles, layering, and automated dependency checks.Continue →

Sources and further reading

Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.

Ask: “How should I design an internal API between two teams' services so that we can change the implementation without breaking each other?”