Information Hiding
Parnas's real argument: decompose around the decisions most likely to change, so that when one changes it changes inside one module.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Which decisions should a module hide, given that hiding state is not the same as hiding a decision?
Invoices must be retrievable for seven years. Finance wants them in under a second for the current year and does not care about older ones. Legal wants them immutable once issued.
Encapsulate it properly: an Invoices class with private fields and clean methods. getInvoiceBlob(id), saveInvoiceBlob(id, bytes), getInvoiceRowSize(id). The state is private, nothing reaches the table directly, and the class reads well.
That module is perfectly encapsulated and hides nothing. Blob, saveBlob and RowSize are the storage decision spelled out in the method names — the caller now knows invoices are opaque bytes living in rows, and every caller was written against that.
- That module is perfectly encapsulated and hides nothing.
Blob,saveBlobandRowSizeare the storage decision spelled out in the method names — the caller now knows invoices are opaque bytes living in rows, and every caller was written against that. - When storage moves to object storage,
getInvoiceRowSizehas no meaning andsaveInvoiceBlobhas the wrong failure model — it can now fail slowly, partially, and after returning. Six callers change, and the change is not mechanical because each one handled failure on the assumption it was a database write (What Changes at the Network Boundary). - The caching decision leaks the same way. Add a cache and the interface grows
invalidateInvoice(id), which means every caller now participates in a cache-coherence protocol it did not ask for and will get wrong (Cache Invalidation, Stampedes and Hot Keys). - The failure is invisible while nothing changes. A module that hides no decision behaves exactly like one that hides the right decisions, right up until the decision changes — which is why this is the most commonly missed idea in the domain.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Right now invoices are rendered to PDF at issue time and stored as
byteain Postgres. The table is already the largest in the database and grows every month. - The move to object storage is coming — nobody has scheduled it, everyone assumes it — and when it comes it must not require a synchronized deploy of six services.
- Seven years means the format decision outlives the current team, so it has to be recoverable from the code rather than from anyone's memory.
- An issued invoice is byte-identical every time it is fetched, forever. Re-rendering must produce the same document, or it must not re-render.
- No caller can observe where an invoice is stored, because if one can, the storage decision is no longer ours to change.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
InvoiceArchiveowns every decision about where an invoice lives, what form it is in, how long it takes to arrive and whether it is cached. All four are decisions, not state.- Callers own knowing an invoice id and wanting a document. They are responsible for handling "not available yet", because that is a fact about invoices and not a fact about storage.
- Whoever owns the archive owns the migration when storage moves — and owns it alone, which is the entire point of drawing the boundary there.
- The seam falls around the *decision*, not the data: everything that would have to change together when storage changes goes inside, and nothing else does.
- The interface is expressed in invoice vocabulary —
fetch(id),issue(invoice),exists(id)— because domain vocabulary is stable and storage vocabulary is not (Naming and Domain Language). - The boundary must include the *failure* vocabulary. If today's errors are database errors and tomorrow's are HTTP timeouts, an interface that exposes either has not hidden the decision (Error Boundaries).
Two different things, and the one people skip
The words are used interchangeably and the ideas are not the same. Encapsulation is about *state*: reach it through operations so a rule can be enforced. Information hiding is about *decisions*: put the things that will change together inside one module so that the change is local.
The reason the distinction earns its own lesson is that the first is easy and visible and the second is hard and invisible. Every code review catches a public mutable field. None of them catch a method named getInvoiceRowSize, and that one costs a quarter.
- A module can be fully encapsulated and hide no decision. That is the common case and the expensive one.
- A module can hide a decision without encapsulating anything, and for immutable data that is often the right shape (Value Objects).
- The test for the second is not "are the fields private". It is: name a decision this module makes that no caller can observe. If you cannot, it hides nothing (Designing a Module Interface).
| Encapsulation | Information hiding | |
|---|---|---|
| Hides | State — the fields, the collection, the representation | Decisions — where it is stored, in what format, via which provider, whether it is cached |
| Bought by | Operations that every mutation must pass through | A module boundary drawn around what changes together |
| The question it answers | Where does this rule live so nothing can bypass it? | When this decision changes, how many modules change with it? |
| Fails as | A getter that returns the live collection | A clean interface written in the vocabulary of the implementation |
| Noticed | In review, immediately — a public field is visible | Years later, during a migration, by the person doing the migration |
| Example of doing one and not the other | getInvoiceBlob / saveInvoiceBlob — private fields, and the storage decision in the method names | A public record with no rules behind a module that owns which provider serializes it — nothing encapsulated, the volatile decision hidden |
The interface that leaked, and the one that did not
Read the two interfaces below without their bodies and ask what each tells you about the implementation. The first tells you invoices are bytes in rows and that fetching one is cheap and synchronous. All three of those facts are decisions, and all three are wrong within two years.
The second tells you invoices are documents that can be issued and fetched, that fetching may take a while, and that it can fail in three ways one of which is temporary. None of those are implementation facts — they are facts about invoices, and they will still be true after the migration.
1// Encapsulated. Hides nothing.2interface Invoices {3 getInvoiceBlob(id: string): Buffer4 saveInvoiceBlob(id: string, bytes: Buffer): void5 getInvoiceRowSize(id: string): number6 invalidateInvoiceCache(id: string): void // added when a cache appeared7}8// A caller reading this knows: rows, bytes, synchronous, locally cached.9// Four decisions, exported. Six callers written against all four.10 11// Hides the decisions that will change.12interface InvoiceArchive {13 issue(invoice: Invoice): Promise<IssuedInvoice>14 fetch(id: InvoiceId): Promise<Result<Document, ArchiveError>>15 exists(id: InvoiceId): Promise<boolean>16}17 18type ArchiveError =19 | { kind: 'not-found' }20 | { kind: 'not-yet-available'; retryAfter: Duration }21 | { kind: 'unavailable' } // transient, whatever the store isThe tell is not method count, it is vocabulary. Blob, RowSize and Cache are words from the implementation; issue, fetch and not-yet-available are words from the invoice domain. Also notice Promise in the second: making the call asynchronous before it is remote is what stops the migration from changing every caller's control flow — and it is a real cost paid today for a change that might not come (The Cost of Change).
What the migration costs, both ways
The argument for information hiding is only ever settled by a decision actually changing. Here is the one everybody in this example knew was coming and nobody scheduled.
Note what the good design does *not* save. The dual-write window, the backfill, and the verification that seven years of documents arrived intact are the same work either way. Hiding the decision removes the caller changes and the synchronized deploy — which is most of the risk, and none of the data movement (Data Migration).
Invoice documents move out of the invoices.pdf_bytes column into an object store, with the old rows read-only during a dual-read window and deleted after verification.
Six callers change, because each one becomes asynchronous and each one must now handle a transient failure it never had. The deploys must be ordered, and the export job — which loops over ten thousand invoices — turns into ten thousand network calls and has to be redesigned as well.
One module changes. The contract suite already runs against two implementations, so the new one is finished when it passes. Dual-read lives inside the module and is deleted from one place when the backfill verifies.
await since the day the interface was written, and the ones that only ever read from Postgres paid it for nothing. The archive is also now the only place that knows how expensive a fetch is, so the export job could not see that it was making ten thousand of them — the hiding that saved the migration is the same hiding that hid the N+1 (N+1 as a Design Problem).How to build it
Most important first.
- List the decisions this code embodies and rank them by how likely each is to change. Storage location, serialization format, caching, compression, retention policy — those are the module's reasons to exist (Requirements Are a Snapshot).
- Draw the module so the top-ranked decisions are inside it and nothing outside names them. This is Parnas's criterion, and it is a decomposition rule, not a class-design rule (Problem Decomposition).
- Express the interface in the vocabulary of the caller's problem.
fetch(invoiceId): Promise<Document>says nothing about rows, buckets, keys or bytes. - Hide the latency decision honestly: make everything that may become remote asynchronous *now*, so the shape of the interface does not change when it does (Cost-Aware Interfaces).
- Give the module a failure type of its own —
NotFound,NotYetAvailable,TemporarilyUnavailable— so the caller's error handling survives the migration (Error Modeling). - Do not hide decisions that are not going to change. A module that hides the fact that money is an integer number of cents buys nothing and costs a conversion at every boundary (Premature Abstraction).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Moving invoices to object storage costs one module, its tests, and a dual-read migration window. Under the
getInvoiceBlobinterface it costs six modules, six sets of error handling, and a synchronized deploy nobody can stage (Expand and Contract). - Adding a cache costs one module and no caller, because no caller was ever told whether a fetch was cheap. Under a leaking interface it costs an invalidation call at every write site and a class of stale-read bugs.
- What did not get cheaper: changing what an invoice *is* — adding line-level tax breakdowns to the stored document — crosses this boundary and every consumer of the document. The archive hides where and how, not what.
- Hiding the storage decision means hiding its cost. A caller that cannot tell a cached read from a cross-region fetch will write a loop that does ten thousand of them, and the interface will have encouraged it.
- Every hidden decision is a request queue: any caller needing something the module did not anticipate has to negotiate with its owner rather than write four lines.
- Naming decisions in advance is genuinely hard, and ranking them by likelihood is guesswork dressed as analysis. The discipline is worth it because it is falsifiable and revisitable, not because the ranking is right (Revisit Triggers).
What can go wrong
- The interface hides storage and leaks it through types:
fetchreturns apg.Result, or the error type isDatabaseError. The decision escaped through the type system while the method names stayed clean. - The module hides the decision and one caller bypasses it for a report, querying the table directly. The hidden decision is now hidden from five of six callers, which is worse than hiding it from none because the migration will forget the sixth (The Common Module).
- Everything is hidden, including things the caller genuinely needs: a caller that must show "available in about a minute" cannot, because the module refuses to distinguish slow from missing. Over-hiding is a real failure and it produces retry loops.
- The mitigation fails on its own terms: an async interface adopted "so it can become remote later" makes every caller async today, and if the store never becomes remote that is complexity purchased for a change that did not arrive (Speculative Generality).
- Six modules depend on
InvoiceArchive;InvoiceArchivedepends on Postgres today and object storage tomorrow, and that dependency is invisible from outside — which is the property being purchased. - The dependency on a specific store is inverted at the module edge rather than at every call site, so exactly one place knows the driver (Dependency Inversion).
- The module gains a dependency on the clock the moment retention enters it. Inject it rather than reading it, or the seven-year rule becomes untestable (Time as a Dependency).
- "Information hiding is just encapsulation with more words." Encapsulation hides state so a rule can be enforced. Information hiding hides a *decision* so it can change in one place. A module can do the first perfectly and none of the second —
getBlob/setBlobis exactly that (Encapsulation). - "So put an interface in front of everything." An interface with one implementation and no decision behind it hides nothing and costs indirection at every call. The question is always which decision is likely to change (How SOLID Gets Misused).
- "Hide the database, so use a repository." A repository whose methods are
findByCriteriaandsavehas exported the query model and hidden almost nothing; the decision leaks through the criteria object (Leaky Abstractions). - "Parnas said decompose by module." He said decompose by *decisions likely to change*, and specifically argued against decomposing by processing step — which is what a pipeline of
parse,transform,rendermodules does, and why such a decomposition survives no requirement change at all.
- leaky-abstractions
- shotgun-surgery
Testing it, and how it ages
- Test the module against its own contract with a real store and an in-memory one, and require both to pass the same suite. That suite is the definition of the hidden decision (Contract Tests).
- Test the failure vocabulary explicitly: a caller must be able to distinguish "no such invoice" from "storage is unavailable" without knowing what storage is.
- Assert the boundary holds — a build rule or lint that no module outside the archive imports the storage client. Without it the hiding is a convention and conventions decay (Internal Module Contracts).
- Do not test through the archive to check invoice *content*. That is a different module's rule and testing it here couples your storage tests to pricing (What a Unit Is).
- The first migration — Postgres to object storage — is the payoff, and it is the moment the boundary either proves itself or is revealed as decoration.
- The second pressure is retention: cold storage for anything over a year. That fits inside, which is evidence the boundary was drawn around the right decision.
- It stops being right when callers legitimately need to reason about cost or latency — a bulk export of ten thousand invoices cannot pretend each fetch is free. At that point the module needs a batch operation, not a hole (N+1 as a Design Problem).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe criterion — group what changes together, hide what is likely to change — is independent of language and paradigm; what varies is the unit that does the hiding, from an ML-style signature to a Java package to a Go internal directory.
- LIFETIME-SPECIFICThe value is entirely in future changes, so for code with a known short life it is close to zero: a script that runs once should name the bucket. The argument gets stronger the longer the code lives and the more people who edit it after the author leaves.
- CONTESTEDThe strongest opposing view is that predicting which decisions change is unreliable, and that a module hiding the wrong decision is worse than one hiding none — because the abstraction must now be dismantled before the real change can be made. Practitioners who hold this argue for direct, obvious code plus good tests, and refactoring toward a boundary once the change actually arrives. That position is correct whenever the prediction is a guess; the counter is that a small number of decisions — storage location, external provider, wire format, time source — change in almost every long-lived system, and those are not guesses.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — what a module boundary can actually enforce differs enormously by language: an ML signature, a Rust crate's
pubgraph and a Java package-private field give three different strengths of the same promise. - — System Design — the same criterion at a larger grain decides service boundaries: a service that hides no decision is a network call with extra failure modes.