A module can have a tidy directory, a small public API, and still be difficult to change. The problem often appears when callers know details that should have remained private: how an identifier is formatted, which algorithm selects a price, or which fields must be updated together.
When those details change, callers change with them.
Information hiding is a design principle for reducing that coupling. The idea is simple: identify a design decision that may change, place it behind a module boundary, and expose the capability callers need rather than the decision’s internal details.
This article develops that mental model, shows how it differs from merely making fields private, and explains when the extra boundary is worth having.
Hide decisions, not just data
Suppose an application creates shipment references from a warehouse code and a sequence number:
reference = warehouseCode + "-" + padLeft(sequence, 8, "0")If every caller constructs references this way, the format is effectively public knowledge. Changing from AMS-00001234 to another representation means finding every place that knows the rule.
A module can hide the decision instead:
shipmentReferences.create(warehouseCode, sequence)Callers now ask for a shipment reference. They do not need to know its separators, padding, or internal representation.
The important boundary is not the function itself. It is the knowledge boundary:
before:
caller --> knows reference format
with information hiding:
caller --> asks for a reference
|
+--> module knows reference formatIf the format changes while the meaning of create remains valid, the change can stay inside the module.
This is the core mental model: a useful module owns a decision so that other code does not have to know it.
Find the knowledge that leaks across the boundary
Information hiding becomes practical when you ask what callers must know to use a module.
Consider a pricing component. Its callers currently do this:
if customer.tier == "gold" and order.total >= 100:
discount = order.total * 0.10
else:
discount = 0The caller needs to know the tier name, threshold, discount rate, and ordering of the conditions. Those values may look like ordinary data, but together they encode one policy decision.
A boundary that hides the policy can expose the question the caller actually cares about:
discount = pricing.discountFor(customer, order)Now the pricing module owns the policy. If the threshold changes, a new tier appears, or the calculation becomes table-driven, callers do not necessarily need to change.
This does not mean every conditional belongs behind an abstraction. The signal is shared or changeable knowledge. If several callers must understand the same rule, or a rule changes for reasons unrelated to those callers, that knowledge is a strong candidate for one owner.
Separate the stable capability from the changeable decision
A good boundary usually has two sides:
- a capability that callers need and that is relatively stable;
- an implementation decision that can change without changing that capability.
For the shipment example, the stable capability is “create a shipment reference.” The changeable decision is its textual representation.
For pricing, the stable capability might be “calculate the discount for this order.” The changeable decisions include thresholds and calculation rules.
This distinction matters because hiding an unstable concept behind an unstable interface gains little. Suppose callers receive a structure like this:
{
tier: "gold",
threshold: 100,
rate: 0.10
}and then calculate the discount themselves. The data may come from a pricing module, but the policy is still distributed. Callers still know how those fields combine.
Information hiding improves when the module performs the decision:
pricing.discountFor(customer, order)The interface expresses the capability. The representation and algorithm stay behind it.
Encapsulation is a mechanism; information hiding is the goal
The terms are often used together, but a useful distinction helps during design.
Encapsulation groups data and behavior behind some boundary. Languages provide mechanisms such as private fields, modules, packages, and interfaces to enforce or communicate that boundary.
Information hiding asks a different question: which knowledge should the boundary prevent other code from depending on?
A class can have private fields and still leak its decisions:
class ShipmentReference:
private warehouseCode
private sequence
getWarehouseCode()
getSequence()The fields are private, but if every caller reconstructs the formatted reference from the getters, the representation decision is not hidden in practice.
Conversely, a small function can provide useful information hiding without a large object hierarchy if it owns the relevant decision and callers depend only on its result.
The design goal therefore comes before the language mechanism. Decide what knowledge should have one owner, then choose the simplest mechanism that keeps that knowledge behind the boundary.
A realistic change shows the payoff
Assume shipment references initially use this rule:
AMS-00001234Later, operations requires a check character at the end to catch transcription errors:
AMS-00001234-KIf callers construct references directly, the change may affect label generation, notifications, exports, tests, and any parser that duplicated the old assumptions.
If a shipment-reference module owns creation and parsing, the change is more local:
shipmentReferences.create(warehouseCode, sequence)
shipmentReferences.parse(text)The implementation of those operations changes. Code that merely stores, prints, or passes a reference may remain untouched.
Notice the limit of the guarantee. If the new requirement changes what callers must do—for example, every user interface must display the check character separately—then the public capability itself has changed. Information hiding cannot make a genuine contract change disappear.
Its value is narrower and more useful: implementation decisions that remain internal should not force unrelated callers to change.
Hide enough to preserve invariants
Information hiding also helps when several values must remain consistent.
Imagine an account exposes these operations separately:
setBalance(...)
setAvailableCredit(...)
setStatus(...)If a withdrawal requires coordinated updates to all three, every caller performing a withdrawal must know the invariant. The setters hide fields but expose the responsibility for keeping them consistent.
A stronger boundary can expose the domain operation:
account.withdraw(amount)The account can then update its internal state together or reject an invalid withdrawal.
The principle is not “never use setters.” It is that a module should usually own rules that must hold across its hidden state. Otherwise the most important knowledge still lives outside the boundary.
Avoid hiding details that callers genuinely need
A boundary becomes harmful when it conceals information that is part of the caller’s real decision.
Suppose a job scheduler needs an operation’s estimated cost to decide whether it fits within a budget. An API that exposes only:
operation.run()may hide too much if cost is essential to correct scheduling. In that case, cost is not merely an implementation detail; it is part of the interaction between the scheduler and the operation.
The right question is not “can this detail be hidden?” but “whose decision requires this information?”
If callers must reason about a property to behave correctly, expose that property deliberately. If callers only need it because the current implementation happens to work that way, consider keeping it private.
Do not create a module for every possible change
Predicting every future change produces abstractions that are harder to understand than the code they protect.
Information hiding works best when there is evidence that a decision deserves a boundary. Useful signals include:
- several callers repeat the same rule or representation knowledge;
- a decision changes for reasons different from the code that consumes its result;
- an invariant is maintained by multiple callers;
- tests repeatedly reproduce internal setup details;
- replacing an algorithm or representation would otherwise require broad edits.
For a local calculation used once and unlikely to vary independently, a direct implementation may be easier to maintain. A boundary has a cost: names to learn, code to navigate, and a contract to preserve.
The goal is not maximum hiding. It is a useful distribution of knowledge.
Review interfaces by asking what they reveal
When reviewing a module, look beyond the number of public methods. A tiny API can still reveal too much, while a larger API can be coherent if every operation expresses a legitimate capability.
Ask practical questions:
- What design decisions must a caller understand before it can use this module correctly?
- Which of those decisions belong to the caller’s job, and which belong to the module’s job?
- If an internal representation or algorithm changed, which callers would need edits?
- Are callers reconstructing rules from getters or raw data that the module could perform itself?
- Does the module expose enough information for callers to make decisions that genuinely belong to them?
These questions turn information hiding from an abstract principle into a design review technique.
Conclusion
Information hiding is not mainly about private keywords or smaller interfaces. It is about controlling where design knowledge lives.
Start by identifying a decision that callers should not need to understand. Give that decision one owner, expose the stable capability callers actually need, and keep the representation, algorithm, or invariant behind the boundary. Then check the opposite risk: do not hide information that callers genuinely require to make their own decisions.
A good module does more than contain code. It limits how much of its internal reasoning the rest of the system must know.