Software Architecture · Modularity

Information Hiding and the Parnas Decomposition Criterion

How designing each module around a decision it conceals confines the cost of change, and what an interface must expose for that concealment to survive.

A decomposition can be correct in the sense that every requirement is implemented and still be wrong in the sense that every future requirement costs too much. The usual way to get there is to decompose by order of execution: read the input, validate it, transform it, format it, write it out. That structure mirrors the flowchart, which is why it feels natural, and it puts every module downstream of the first one in permanent contact with whatever representation the first module chose. Parnas’s 1972 paper on decomposition replaced the flowchart criterion with a different one: begin with the list of difficult design decisions, and make each module hide one of them from the rest.

The criterion is easy to state and hard to apply, because it inverts the usual direction of design. Instead of asking what a module does, it asks what a module conceals. A module’s secret is a decision that the rest of the system is not allowed to depend on — a data representation, a storage format, an algorithm, a protocol encoding, a policy for when to synchronise. If the secret changes, only the module changes, provided the interface never revealed it.

The two decompositions of the same program

The paper’s running example is a keyword-in-context index: lines of text, each rotated so that every word appears first, then sorted, then printed. A flowchart decomposition yields modules for input, circular shift, alphabetising, and output. A hiding decomposition yields modules for line storage, for producing shifts, for sorting, and for output, where each module’s interface is designed around what the others cannot know.

The difference shows up on a specific change. Suppose lines must be stored in variable-length form to save memory, or that input arrives in a different character encoding. In the flowchart version, the shift module receives whatever the input module produced, so it is written against that representation and must be rewritten when the representation changes. The alphabetising module may need to compare characters, which means it too knows the encoding. The change ripples through three modules even though only one module has anything to do with storing lines.

In the hiding version, the line-storage module exposes operations that return characters and word offsets without disclosing whether the underlying store is an array of fixed records, a packed buffer, or a database handle. The shift module asks for words by position. The number of modules that must change is one, and the number that must be recompiled or retested is one plus the cost of verifying the interface did not move.

The arithmetic generalises usefully. If a decision has M possible representations and N client modules would otherwise depend on it, coding each client against each representation costs on the order of N times M work. Hiding the decision behind a stable interface costs N client implementations plus M module implementations. The saving is not a constant factor; it is the difference between a product and a sum, which is why the criterion survives even when the interface is more awkward than the direct access would have been.

What counts as a secret

Not every implementation detail deserves to be a secret. Hiding something that never changes buys nothing and costs an indirection. The decisions worth concealing share a property: they are likely to change, and they are hard to change everywhere at once. Encoding formats, wire protocols, storage engines, concurrency strategies, and third-party service choices qualify. The choice between a for loop and a while loop does not.

A useful test is to name the change that the module is protecting against. If the sentence “this module hides X” cannot be finished with something a maintainer might plausibly want to alter, the boundary is administrative rather than architectural. Parnas, Clements, and Weiss later applied exactly this reasoning to a system with a documented set of expected changes, assigning each anticipated change to the module whose secret it touched.

There is a second-order effect that the original paper only implies: a module that hides nothing is a pass-through, and pass-through modules multiply indirection without reducing knowledge. A service layer that forwards each call one-to-one to a repository, with identical parameters and return types, has not hidden the repository; it has renamed it. The tell is that a change to the repository interface forces a matching change in the service layer, which is dependency preserved rather than dependency removed.

Interfaces reveal more than signatures

An interface is not just the parameter list. It is every fact a caller can rely on: the types, the exceptions, the ordering of callbacks, the aliasing rules, the thread-safety guarantee, and the timing of effects. Information hiding fails when any of these leaks the secret, even if the method names are abstract.

Three leaks account for most of the failures.

Returning interior references. A method that hands back its internal list lets callers mutate storage the module claims to own. The signature hides the type only if the type is opaque; in languages without opaque types, the leak is structural. Returning a defensive copy closes it at the cost of an allocation per call, which is a real trade rather than a free correction.

public final class Schedule {
    private final List<Slot> slots = new ArrayList<>();

    // Leaks the representation: callers can add and remove slots.
    public List<Slot> slots() { return slots; }
}
    // Hides it: callers can read, and mutations go through invariants.
    public List<Slot> slots() { return List.copyOf(slots); }

Exceptions that name the implementation. An exception type called BTreeCorruptionException thrown from a method whose contract says nothing about trees tells every caller to handle a detail the module was supposed to own. The correction is an exception hierarchy expressed in the module’s own vocabulary, translated at the boundary.

Synchronous assumptions. An interface that returns a value forces callers to assume the computation has completed. If the module later moves the work to another thread or process, every call site is wrong. An interface that returns a handle, or that accepts a callback, keeps the timing decision inside.

The cost of a boundary that hides well

Hiding is not free, and the costs are specific enough to plan for.

Performance transparency is the first. A module that hides its representation cannot offer callers a zero-copy view of that representation, because the view would expose it. Code that hides a buffer behind an accessor may pay a bounds check and a call per element; code that hides a remote store behind a local-looking interface may pay a network round trip on what reads like a field access. The honest resolution is to make the cost visible in the interface — batch operations instead of per-element calls, explicit flush points — without revealing the representation. An interface that hides a 100-microsecond operation behind a getter has hidden the wrong thing.

Interface ossification is the second. Once N modules depend on a signature, changing it costs N edits, so interfaces tend to accumulate optional parameters and deprecated overloads rather than being redesigned. This is an argument for narrow interfaces, not for wide ones: a module that exposes one method per use case can add a use case without disturbing existing callers, while a module that exposes a general-purpose accessor must change the accessor’s shape.

Testability is the third, and it cuts both ways. Hidden decisions are hard to observe, so tests must exercise behaviour through the interface. That is usually an improvement, because it keeps tests from encoding the representation, but it makes some failures harder to localise: a wrong result from a well-hidden module could originate anywhere inside it. The compensating technique is to test the module against its own contract, with the representation visible to the test but not to the callers.

Where the criterion stops being useful

The criterion assumes that likely changes can be identified in advance. For systems whose domain is genuinely novel, that assumption fails, and the resulting boundaries are guesses. Parnas and Clements addressed the gap directly: the rational design process cannot be performed as described, so the documentation should present the design as if it had been, with the secrets and their justifications recorded. The value is not historical accuracy but a maintainable statement of what each module promises not to expose.

The criterion also says nothing about how many modules to create. Hiding every decision produces a module per decision and an interface per module, and the interfaces themselves become the coupling problem. Practical decompositions group decisions that change together, which is why the boundary question and the cohesion question cannot be answered separately; the coupling and cohesion discussion describes the measurement, and this criterion supplies the reason to move a boundary at all. What the interface is allowed to promise, once drawn, is the subject of interface contracts.

References

  1. On the Criteria To Be Used in Decomposing Systems into Modules — D. L. Parnas, Communications of the ACM 15(12), 1972
  2. A Rational Design Process: How and Why to Fake It — D. L. Parnas and P. C. Clements, IEEE Transactions on Software Engineering 12(2), 1986
  3. The Modular Structure of Complex Systems — D. L. Parnas, P. C. Clements, D. M. Weiss, ICSE 1984