Information Hiding: Design Modules Around Change
A module can have private fields and still expose nearly every design decision it makes. If callers know which storage keys exist, how records are ordered, which retry sequence is used, or how an identifier is encoded, changing those decisions means changing the callers too.
Information hiding is the design practice of keeping such decisions behind a boundary. The goal isn’t secrecy. The goal is to make a decision replaceable without forcing unrelated code to understand or change with it.
This article develops a practical way to find what a module should hide, shape an interface around what callers actually need, and recognize when an abstraction is only hiding syntax rather than design knowledge.
Hide decisions, not just data
Encapsulation and information hiding are closely related, but they answer different design questions.
Encapsulation groups state and behavior behind a boundary and controls how the state can be accessed. Information hiding asks a more specific question: which knowledge should not escape this boundary because it may change independently?
Consider a component that stores user preferences. A first interface might expose its storage representation directly:
map = preferences.load(user_id)
map["notifications.email"] = "disabled"
preferences.save(user_id, map)The fields may be private inside preferences, yet callers still know several internal decisions. They know preferences are loaded as a mutable map. They know the key is notifications.email. They know the stored representation for the disabled state is the string "disabled". They also know that updating one preference requires loading and saving the whole representation.
Those details form an implicit contract. A change from a map to structured records, a renamed key, or a different persistence strategy can now spread through every caller.
An interface based on the caller’s intent hides more of those decisions:
preferences.disable_email_notifications(user_id)Now the caller states what it wants. The module decides how that intent is represented and persisted.
The point is not that one-method-per-action is always the right API. The example is deliberately small. It shows the direction of the design: expose stable needs; keep volatile choices inside.
Think in terms of change boundaries
A useful mental model is to treat a module as a change boundary. A good boundary contains a set of decisions that tend to change together and prevents those decisions from leaking into code that changes for different reasons.
Suppose an order service needs to calculate a shipping quote. The business workflow needs an amount and perhaps an estimated delivery range. It probably does not need to know:
- which carrier endpoint was called;
- how carrier-specific fields were named;
- whether the provider returned cents or decimal currency values;
- which transient errors are retried;
- how provider responses are cached.
If the workflow knows those details, replacing the carrier integration is no longer an integration-local change. The workflow has become coupled to the provider’s design.
A boundary can instead expose a concept meaningful to the workflow:
quote = shipping.quote(destination, parcel)
quote.price
quote.delivery_windowInside shipping, an adapter can translate provider-specific requests and responses into those application-level concepts. If a provider changes its field names, the translation changes. If the application’s idea of a shipping quote changes, the public boundary may also need to change. Those are different kinds of change, and the boundary helps keep them separate.
This is the practical test for information hiding: when an internal decision changes, how far does the edit travel?
Find the decisions that are worth hiding
Not every detail deserves an abstraction. Hiding everything produces layers that add indirection without reducing meaningful coupling.
Start by looking for knowledge that has one or more of these properties: it is likely to change, it is difficult to get right, it belongs to an external system, or multiple callers would otherwise need to duplicate it.
A date-formatting rule is a simple example. If five call sites independently construct the same external timestamp format, the format is shared knowledge. When the external contract changes, five places must change consistently. Putting the rule behind one boundary gives that decision one owner.
More substantial candidates include serialization formats, cache policies, ranking formulas, protocol details, filesystem layouts, feature-selection rules, and compatibility workarounds. The common feature is not their technical category. It is that callers should depend on the result of the decision rather than on the machinery used to make it.
Change history can help identify these seams. If the same set of files repeatedly changes whenever one policy changes, those files may be sharing knowledge that belongs in one module. You don’t need a long history before acting, though. A known external dependency or an explicitly temporary algorithm is already a strong signal that its details should have a clear owner.
Design the interface from the outside in
Once you’ve identified a decision to hide, don’t begin by wrapping every operation of the current implementation. That often creates an interface shaped exactly like the thing you hoped to replace.
Imagine an application using a key-value store for rate-limit state. A thin wrapper might expose this:
rate_limit_store.get(key)
rate_limit_store.increment(key)
rate_limit_store.expire(key, seconds)The storage library is no longer imported by callers, but its model still leaks through the wrapper. Callers must construct keys, coordinate increments and expiration, and understand the algorithm encoded by those operations.
A boundary based on the application’s need could instead be:
result = rate_limiter.check(client_id, operation)
if result.allowed:
continue_request()
else:
reject_request(retry_after = result.retry_after)This interface hides more than a dependency. It hides the rate-limiting representation and update sequence. A later implementation might use a different storage system or algorithm while preserving the meaning of check.
The interface should still be honest about facts callers genuinely need. If rate limiting can fail in a way the caller must distinguish from an ordinary rejection, collapsing both outcomes into allowed = false would hide too much. Information hiding is not information destruction. A boundary should conceal implementation choices while exposing behavior that matters to correct use.
A stable interface describes meaning, not mechanism
Interfaces become more resilient when their vocabulary belongs to the problem being solved rather than to the current implementation.
Compare these two operations:
archive.move_to_bucket("cold-orders", order_id)and:
orders.archive(order_id)The first tells the caller where archived orders currently live. The second tells the module what state transition the caller wants. If archival later means writing to another service, marking metadata, or emitting an event, the second interface has more room to absorb the change.
That doesn’t mean domain-oriented names are automatically stable. If archive has vague or contested semantics, the abstraction is weak regardless of its name. The module needs a precise behavioral contract: what callers may assume before and after the operation, which failures can occur, and whether repeated calls are meaningful.
A useful interface therefore hides mechanism while making observable behavior explicit.
Keep related knowledge in one place
Information hiding loses much of its value when a decision is split across several modules.
Suppose a payment gateway requires an idempotency key with a particular structure. The integration module generates the key, but a controller independently parses it to extract an order identifier. The representation now has two owners. Changing the key format requires coordinated edits even though the controller should care only about the order.
The stronger design is to keep both creation and interpretation, if interpretation is genuinely required, behind the module that owns the representation:
key = payments.idempotency_key_for(order_id)Better still, if no caller needs the key itself, don’t expose it at all:
payments.charge(order_id, amount)The module can create and use the idempotency key internally.
This leads to a useful review question: who knows this fact? If several unrelated modules know the same representation rule or sequencing rule, the design may be missing an owner.
Don’t confuse wrappers with information hiding
A wrapper can be useful without being a meaningful abstraction. Problems appear when a wrapper is treated as protection from change even though it mirrors the wrapped API one-for-one.
For example:
class QueueWrapper:
publish(topic, payload)
subscribe(topic, handler)
acknowledge(message_id)If every caller still knows topic names, payload schemas, acknowledgement rules, and delivery assumptions, replacing the queue may require changes across the application. The wrapper hides the import path, not the integration knowledge.
A stronger boundary might expose application-level operations such as publish_invoice_issued(invoice) and translate them internally. Whether that is the right level depends on the system. A shared messaging infrastructure library may intentionally expose generic messaging concepts, while a business module should usually avoid leaking transport-specific details into its domain logic.
The question is not “Do we have a wrapper?” It is “Which decisions can change without callers changing?”
Avoid abstractions based only on imagined change
Information hiding has a cost. Every boundary introduces vocabulary, ownership, tests, and another place to navigate. A speculative abstraction can make straightforward code harder to follow while protecting against a change that never arrives.
If an implementation is tiny, stable, and used in one place, keeping it local may be simpler than introducing an interface. The moment it becomes shared knowledge, depends on a volatile external contract, or starts forcing coordinated changes, the value of a boundary rises.
There is also a risk of hiding decisions at the wrong level. A generic DataManager that conceals every storage operation behind execute(action) technically hides details, but it also erases useful meaning. Callers can no longer see what behavior is available without understanding a vague command protocol.
Good information hiding makes the public model smaller and clearer. If the abstraction requires callers to pass implementation-shaped flags, magic strings, or generic option maps, much of the hidden knowledge has probably leaked back through the parameters.
Review boundaries by simulating a change
You can evaluate a module without predicting the future perfectly. Pick one plausible internal change and trace its consequences.
For a notification module, ask what would happen if email delivery moved to a different provider. If only the provider adapter and its focused tests would change, the provider details are reasonably contained. If controllers, business services, and tests all mention provider templates, response codes, or message identifiers, the boundary is leaking.
Then try a change that should affect callers. If the product adds a new notification state that users must see, a public model may need to change. That is not a failure of information hiding. The externally meaningful behavior changed, so consumers may legitimately need to adapt.
This distinction matters. A good boundary does not promise that callers never change. It aims to prevent implementation changes from masquerading as system-wide behavioral changes.
Use information hiding where change should stop
When a change spreads through unrelated parts of a codebase, look for the knowledge that crossed a boundary. A storage key, protocol rule, ordering assumption, algorithm step, or provider-specific field may have escaped from the module that should own it.
Move that knowledge behind a boundary whose interface describes what callers need rather than how the current implementation works. Then test the design with a plausible replacement: if the internal decision changes, can most callers remain untouched?
That is the practical value of information hiding. It gives change a place to stop.