EncapsulationGENERALLIFETIME-SPECIFICCONTESTED

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.

The question

Which decisions should a module hide, given that hiding state is not the same as hiding a decision?

The requirement

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.

The obvious build

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.

Why it breaks

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.

How it breaks as requirements change
  • 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.
  • When storage moves to object storage, getInvoiceRowSize has no meaning and saveInvoiceBlob has 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.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • Right now invoices are rendered to PDF at issue time and stored as bytea in 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.
Invariants
  • 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.

Responsibilities
  • InvoiceArchive owns 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.
Boundaries
  • 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).
EncapsulationInformation hiding
HidesState — the fields, the collection, the representationDecisions — where it is stored, in what format, via which provider, whether it is cached
Bought byOperations that every mutation must pass throughA module boundary drawn around what changes together
The question it answersWhere does this rule live so nothing can bypass it?When this decision changes, how many modules change with it?
Fails asA getter that returns the live collectionA clean interface written in the vocabulary of the implementation
NoticedIn review, immediately — a public field is visibleYears later, during a migration, by the person doing the migration
Example of doing one and not the othergetInvoiceBlob / saveInvoiceBlob — private fields, and the storage decision in the method namesA 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.

The same module, before and after the decision was hidden
1// Encapsulated. Hides nothing.
2interface Invoices {
3 getInvoiceBlob(id: string): Buffer
4 saveInvoiceBlob(id: string, bytes: Buffer): void
5 getInvoiceRowSize(id: string): number
6 invalidateInvoiceCache(id: string): void // added when a cache appeared
7}
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 is

The 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).

Move invoice storage from Postgres to object storage
The change

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.

`getInvoiceBlob` / `saveInvoiceBlob` — storage decision in the interface
InvoiceServiceBillingEmailerAdminInvoiceViewFinanceExportJobCustomerPortalApiDunningWorker
testsinvoice_testemailer_testadmin_testexport_testportal_testdunning_test
6 modules · 6 test files

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.

`InvoiceArchive` — async interface, own error type, storage never named outside
InvoiceArchive
testsarchive_contract_testarchive_postgres_testarchive_objectstore_test
1 module · 3 test files

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.

what it cost Every caller has been paying an unnecessary 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.

Cost of the next change
  • Moving invoices to object storage costs one module, its tests, and a dual-read migration window. Under the getInvoiceBlob interface 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.
What the recommended approach costs
  • 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

Failure modes
  • The interface hides storage and leaks it through types: fetch returns a pg.Result, or the error type is DatabaseError. 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).
Dependencies, and their direction
  • Six modules depend on InvoiceArchive; InvoiceArchive depends 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).
Misreads
  • "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/setBlob is 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 findByCriteria and save has 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, render modules does, and why such a decomposition survives no requirement change at all.
Smells this explains
  • leaky-abstractions
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • 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).
How this design ages
  • 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.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — what a module boundary can actually enforce differs enormously by language: an ML signature, a Rust crate's pub graph 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.