A module can have private fields and still expose too much. Callers may know which storage format it uses, which sequence of operations is required, or which implementation rule determines a result. When that hidden-looking detail changes, code outside the module must change with it.

Information hiding is a design principle for preventing that spread. The idea is simple: identify a design decision that other code should not need to know, then place that decision behind a boundary whose public contract can remain stable when the decision changes.

This article explains how to find useful hiding boundaries, how information hiding differs from merely restricting access, and how to judge whether a module is actually protecting callers from change.

Hide decisions, not just data

Consider a service that stores report templates. The first implementation keeps each template as a JSON file. A caller wants to load one, so the API exposes the storage process:

path = template_directory() + "/" + report_type + ".json"
text = read_file(path)
template = parse_json(text)

The code works, but the caller now knows several design decisions:

  • templates live in files;
  • filenames are derived from report types;
  • the serialization format is JSON;
  • loading requires reading and parsing in a particular order.

If templates later move to a remote service, those details become liabilities. The change is no longer confined to the storage implementation because callers have learned how storage works.

A boundary that hides those decisions is smaller from the caller’s point of view:

template = template_store.load(report_type)

The important change is not shorter syntax. The caller now depends on a capability—load a template for this report type—rather than on the current mechanism used to provide that capability.

That is the core mental model:

A good boundary hides a decision that may change behind a contract that callers still need after the decision changes.

The terms are often used together, but distinguishing them is useful when reviewing a design.

Encapsulation groups state and behavior behind a boundary and controls how that state can be accessed. A class with private fields is a familiar example.

Information hiding asks a different question: which design knowledge is allowed to escape the boundary?

A class can encapsulate its fields while leaking implementation knowledge:

class TemplateStore:
    private json_directory

    function directory():
        return json_directory

The field is private, but callers can still build paths, read files, and assume JSON. Access control has hidden the variable while the design decision remains public.

Conversely, information hiding does not require a particular language feature. A module, package, service, function, or process boundary can hide a decision if callers interact through a stable contract and do not need to reproduce the hidden knowledge.

This distinction matters because private is mechanically checkable, while information leakage is a design property. You have to inspect what callers need to know.

Look for knowledge that changes for one reason

Useful hiding boundaries often appear around decisions that have their own reason to change. Examples include:

  • how an identifier is generated;
  • how a price is rounded;
  • how a document is serialized;
  • how retries are scheduled;
  • how a feature’s eligibility rule is calculated;
  • how an external provider’s response is translated into application concepts.

Suppose several callers calculate invoice identifiers like this:

id = region + "-" + year + "-" + sequence.pad_left(8, "0")

The expression is small, so extracting it may initially look unnecessary. But its size is not the important property. The format is a business or integration decision. If the format changes, every caller that knows the rule must change together.

Hiding the rule gives it one owner:

id = invoice_id_generator.next(region, year)

Now a format change can often remain inside the generator. The callers still ask for the same capability.

This is why information hiding is closely connected to maintainability: it reduces the number of places that must understand a decision, not merely the number of lines that implement it.

Design the boundary around the caller’s need

A common mistake is to create an abstraction that simply mirrors the implementation.

Imagine replacing direct file access with this interface:

template_store.open_json_file(name)
template_store.read_json(handle)
template_store.close_file(handle)

The storage operations have moved behind an object, but the caller still knows that templates are JSON files and still coordinates the storage protocol. Changing the implementation to a remote service would force the public interface and its callers to change.

A stronger boundary starts from what the caller is trying to accomplish:

template_store.load(report_type)

This does not mean every interface should be extremely high level. The contract must still expose distinctions callers genuinely need. If callers must choose between a published template and a draft template, hiding that distinction would make the API less useful or force callers into indirect workarounds.

The practical question is:

If the implementation decision changed, which facts would the caller still need?

Those facts belong in the contract. Facts that exist only because of today’s implementation are candidates to hide.

Keep the hidden knowledge inside the boundary

Creating an interface is not enough if the same knowledge leaks through other channels.

Suppose TemplateStore.load returns this result:

{
    file_path: "/templates/monthly.json",
    json_text: "..."
}

The method name suggests a storage abstraction, but its result exposes the file and serialization model. Callers still need to parse JSON and may start depending on the path.

A result expressed in application terms hides more:

Template {
    subject
    body
    variables
}

The storage implementation can now change while the application-level representation stays the same.

Errors can leak knowledge too. Returning FileNotFound from a supposedly storage-independent interface tells callers about the current mechanism. If callers only need to distinguish “template does not exist” from “template could not be loaded,” translate implementation-specific failures into errors meaningful at the boundary.

Do not erase useful diagnostic information to achieve this. The implementation can preserve the original failure as internal context for logs or tracing while exposing a stable error contract to callers. The goal is to separate operational evidence from public coupling.

Test whether the boundary contains a plausible change

A practical way to evaluate information hiding is to imagine a realistic replacement for the hidden decision.

For the template example, ask what happens if JSON files become records fetched from a service.

With the leaky design, callers construct paths, perform file I/O, parse JSON, and handle file-specific errors. The replacement touches many callers.

With the hiding boundary, the implementation of TemplateStore changes, but callers can continue to request load(report_type) and receive a Template. Tests for callers can remain focused on application behavior rather than storage mechanics.

This thought experiment is not proof that the abstraction will survive every future requirement. No useful interface can anticipate arbitrary change. It is a way to check whether the boundary protects callers from a specific decision that you deliberately chose to hide.

The test should therefore be concrete: “Could we change JSON files to another storage mechanism without teaching every caller about the new mechanism?” is more useful than “Is this abstraction flexible?”

Do not hide every possible change

Information hiding has a cost. Every boundary introduces vocabulary, ownership, and indirection. A speculative abstraction around a decision that is unlikely to vary can make simple code harder to follow.

A local calculation used once may be clearer inline. A wrapper that exposes exactly the same operations as a stable library may add no meaningful protection. Two implementations that differ in fundamental behavior may be misleading if forced behind one interface merely because they look similar today.

Prefer a hiding boundary when at least one of these conditions is meaningful:

  • several callers would otherwise duplicate the same design knowledge;
  • the decision is likely enough to change that containing it has practical value;
  • the implementation is complicated or platform-specific while callers need a simpler capability;
  • callers should not be allowed to depend on internal sequencing or representation;
  • a boundary already exists for domain or ownership reasons, and keeping the decision inside it makes that boundary more coherent.

Use the simplest form that works. A function may hide a formatting rule. A module may hide serialization. A service boundary may hide an independently operated subsystem. Information hiding does not require adding a class or a network hop.

Watch for common failure modes

A getter for every private field

If callers can reconstruct the internal representation through getters, the implementation may be syntactically private but conceptually public. Expose operations or application concepts that callers need instead of automatically exposing internal state.

A generic interface that hides useful meaning

An API such as execute(data) may conceal implementation details but also conceal the domain contract. Hiding information should reduce irrelevant knowledge, not remove useful semantics. Prefer names and types that make the caller’s responsibility clear.

Leaking the protocol through required call order

If callers must invoke prepare, then open, then load, then close in exactly the right sequence, they know part of the implementation protocol. When possible, let the boundary own that sequencing and expose the operation the caller actually wants.

Some protocols genuinely require staged interaction. Streaming and transactions are common examples. In those cases, the stages may belong in the public model because callers need control over them. The design question is whether the sequence is part of the caller’s problem or merely part of the implementation’s machinery.

Treating every future possibility as a variation point

Designing for hypothetical replacements can produce layers that have no current job. Hide decisions you can identify and explain. Do not add abstraction solely because anything might change someday.

Use change as feedback

You do not need to predict every useful boundary in advance. Real changes reveal leaked knowledge.

When one requirement forces edits across unrelated modules, inspect what those edits have in common. If each location repeats the same rule, format, protocol, or implementation assumption, that shared knowledge may need a clearer owner.

The next refactoring can move the decision behind a boundary before changing it. Afterward, future changes to that decision have a better chance of staying local.

This approach keeps information hiding grounded in observed engineering pressure rather than abstract preference.

Conclusion

Information hiding is not mainly about making fields private. It is about deciding which parts of the system are allowed to know a design decision.

Start with a concrete source of knowledge: a storage mechanism, format, rule, protocol, or provider detail. Give that knowledge an owner. Design the public boundary around what callers need to accomplish, and keep implementation-specific representations, sequencing, and errors from leaking through it unless callers genuinely need them.

Then test the design with a plausible change. If the hidden decision can change without teaching many callers something new, the boundary is doing useful work. If callers still need to change because they understand the old mechanism, the information was never fully hidden.