Information Hiding: Designing Modules Around Change

A module can have a small API and still be difficult to change. The problem appears when callers know details they shouldn’t need to know: which storage format is used, how identifiers are assembled, which retry sequence is required, or what intermediate states exist inside a workflow. Once that knowledge escapes, an internal change becomes a coordinated change across the codebase.

Information hiding is a design principle for preventing that spread. A module hides a design decision by giving other code a stable way to use the capability without depending on the decision itself. This article develops a practical way to recognize leaked decisions, choose useful module boundaries, and avoid interfaces that merely disguise the implementation.

The mental model: hide decisions, not code

Information hiding is often confused with making fields private or placing code in separate files. Those techniques can support the principle, but they don’t define it.

The useful question is:

Which design decision can change without forcing callers to change with it?

Imagine an application that stores uploaded reports. Callers construct storage keys themselves:

key = tenantId + "/reports/" + year + "/" + reportId + ".pdf"
storage.put(key, bytes)

The code is short, but the caller now knows several decisions: reports are grouped by tenant, there is a reports path segment, the year is part of the layout, and files use a .pdf suffix. If ten callers repeat that knowledge, changing the layout means finding and updating all ten.

A module can instead expose the operation the caller actually needs:

reportStore.save(tenantId, reportId, createdAt, bytes)

The storage-key layout still exists. It simply has one owner. Changing from a year-based layout to a hash-based layout can now remain inside reportStore, provided the public behavior callers rely on does not change.

That is the central idea: put a decision behind a boundary when other parts of the system need the result of that decision, but not the details of how it is made.

Start with a change you expect to happen

A useful module boundary usually protects something that has a plausible reason to vary. Trying to hide every detail produces layers that add indirection without buying much flexibility.

Suppose an invoicing service calculates a payment deadline. The current rule is 30 calendar days after issue:

dueDate = invoiceDate + 30 days

If that expression appears throughout the application, the number 30 is not the only leaked detail. Callers also know that the rule is based on calendar days and that no customer or contract information affects it.

A better boundary might be:

dueDate = paymentTerms.dueDateFor(invoice)

Now the policy has one owner. Later, the implementation might use customer-specific terms or business-day calculations without requiring every caller to understand those rules.

Notice what this refactoring does not do. It doesn’t create an abstraction merely because addition is complicated. The arithmetic is trivial. The reason for the boundary is that payment terms are a business decision with an independent reason to change.

This gives a practical test for information hiding: name the decision being protected and name a credible change to it. If neither is clear, the extra abstraction may not be earning its cost.

A good interface describes intent rather than procedure

Moving code behind a method is not enough if the interface still requires callers to understand the hidden mechanism.

Consider a notification component with this API:

connection = notifier.openConnection()
message = notifier.buildMessage(template, user)
notifier.send(connection, message)
notifier.closeConnection(connection)

The implementation may be encapsulated inside notifier, but callers still know its protocol. They must open a connection, build an internal message representation, send it, and close the connection in the correct order. If the notifier later uses a connection pool or a queued transport, callers may need to change even though their goal is still just “send this notification.”

An intent-oriented interface could be:

notifier.sendWelcomeMessage(user)

or, when the application genuinely needs a more general capability:

notifier.send(template, recipient, data)

The second form exposes useful variability while keeping transport mechanics private.

The distinction matters because an interface is not automatically a boundary. If callers must reproduce the implementation’s sequence, data layout, or internal state machine, the implementation has leaked through the interface.

Find leaks by tracing knowledge

Leaked information often looks harmless in isolation. A constant appears in three modules. Two services parse the same identifier. Several callers know that a missing record should trigger one fallback before another. The duplication becomes expensive only when the underlying decision changes.

When reviewing a change, look for repeated knowledge rather than repeated syntax. Useful signals include:

  • several callers constructing the same structured value;
  • callers switching on states that belong to another component’s workflow;
  • multiple modules knowing the same ordering rule;
  • tests outside a module asserting its intermediate representation;
  • a configuration detail that many callers translate in the same way.

For example, suppose order IDs are currently externalized as region-number, such as eu-1842. If controllers, loggers, event producers, and support tools all split the string on -, the representation is effectively public even if the OrderId fields themselves are private.

A focused boundary would let other code ask for what it needs:

orderId.toExternalString()
orderId.region()

Parsing should likewise have one owner:

OrderId.parse(value)

If the representation later becomes eu_1842 or gains a version prefix, the blast radius is smaller because fewer components interpret the format directly.

This doesn’t mean every consumer must be insulated from every format change. A documented external identifier format may be a deliberate contract. Information hiding helps you decide what is internal; it cannot make an intentionally public contract private.

Keep the boundary stable by exposing the right concepts

An interface can become unstable when it exposes parameters that exist only because of the current implementation.

Suppose a pricing module requires callers to provide these arguments:

calculatePrice(items, discountTable, taxTable, roundingMode)

If callers are responsible for locating the correct tables and rounding mode, they know part of the pricing mechanism. Replacing table-driven discounts with rules fetched from another source would ripple outward.

If those inputs are owned by pricing policy rather than by the caller, a stronger boundary may be:

calculatePrice(orderContext)

The pricing module can obtain or own the policy data it needs. The caller supplies facts from its own domain, not knobs for operating the pricing implementation.

There is a trade-off here. Passing dependencies explicitly can make code easier to test and can make data flow visible. Hiding every dependency behind a module can produce surprising behavior or service-locator-style designs. The decision should follow ownership: callers should pass information they genuinely own, while the module should avoid making callers assemble its private machinery.

An abstraction gives a simpler or more general way to think about something. Information hiding limits which design decisions other code can depend on. A good module often does both, but one does not guarantee the other.

A List abstraction, for example, lets code work with an ordered collection without necessarily caring about its concrete representation. That abstraction can also hide whether storage uses a contiguous array or linked nodes. In that case the two ideas reinforce each other.

But a wrapper can abstract syntax while hiding almost nothing:

reportStore.putAtPath(path, bytes)

This is shorter than calling a storage library directly, yet callers still decide the path layout. If path layout is the volatile decision you wanted to protect, the wrapper missed the target.

Conversely, a module can hide an important policy without being especially generic. paymentTerms.dueDateFor(invoice) may exist for one application and one business process. It is still useful because it keeps ownership of the payment-term decision in one place.

When evaluating a boundary, don’t ask only whether the API looks abstract. Ask what knowledge it prevents from escaping.

Avoid turning information hiding into needless indirection

The principle has costs. Every boundary introduces another place to navigate, name, test, and maintain. A tiny wrapper around a stable operation can make a codebase harder to follow without reducing meaningful coupling.

Three mistakes are especially common.

Hiding facts the caller actually needs

If a caller must make a decision based on information, concealing that information forces awkward workarounds. A shipping quote screen may genuinely need the delivery estimate and price for each option. Returning only an opaque shipping token would hide information required by the feature.

Hide implementation decisions, not domain facts that consumers need to do their jobs.

Designing for hypothetical replacements

Teams sometimes create interfaces for every class because a database, framework, or algorithm might someday be replaced. That can produce many one-implementation abstractions with no clear protected decision.

A replacement is worth designing for when there is evidence of volatility, when the dependency is difficult to control in tests, or when isolating it protects an important architectural boundary. “It could change” is true of almost everything and is not enough by itself.

Letting the hidden module become a dumping ground

Centralizing knowledge can go too far. If every operation involving an invoice is placed in one enormous InvoiceManager, unrelated policies become coupled simply because they mention invoices.

A useful module hides a coherent set of decisions. Payment terms, invoice numbering, and PDF rendering may all involve invoices while having different reasons to change. They can deserve separate owners.

Use tests to preserve the boundary

Tests can either reinforce information hiding or accidentally defeat it.

A test for reportStore.save(...) should usually care about behavior visible through the module’s contract: a saved report can be retrieved, an invalid request is rejected, or a storage failure is surfaced according to the documented policy. If every test outside the module asserts the exact internal key tenant/reports/2026/id.pdf, that representation is no longer easy to change.

Tests inside the module may reasonably verify the key-generation algorithm if that is part of the implementation being tested. The distinction is ownership. Tests at a boundary should depend on the boundary’s promises; implementation-focused tests can depend on details within the component that owns them.

This is also why broad snapshot tests can create accidental coupling. A snapshot that captures private fields, internal error text, or intermediate structures can turn implementation details into de facto contracts. Keep snapshots focused on output that consumers actually rely on.

When information hiding pays off

Information hiding is most valuable when a decision has multiple consumers and a meaningful chance of changing independently. Storage layout, serialization rules, retry policy, identifier representation, workflow sequencing, and business calculations are common examples, but the principle is not tied to any technology.

A simpler design is often better when there is one caller, the operation is stable, and the proposed boundary cannot name a coherent responsibility. You can wait for evidence. If a second caller appears or a change reveals scattered knowledge, the right boundary will often be easier to see then.

The goal is not maximum encapsulation. It is controlled dependency: code should depend on the capability or fact it needs while knowing as little as practical about decisions owned elsewhere.

A practical next step

The next time a small requirement touches several places, inspect the edits before adding another helper layer. Ask which lines changed because they all knew the same decision. Then choose one owner for that decision and give callers an interface expressed in terms of what they need, not how the owner currently works.

If you can change the hidden decision later without coordinating edits across its callers, the boundary is doing useful work. If callers still need to know the representation, sequence, or policy details, the information hasn’t really been hidden yet.