Software Architecture · Modularity
Package Encapsulation and the Leak of Internal Types
How languages draw the visibility line around a package, which constructs quietly widen it, and why an exported type in a public signature defeats the boundary it was meant to protect.
Encapsulation is often described as bundling data with the operations on it. The description is misleading for module design, because the interesting part is not the bundling but the permission: which other modules the compiler will allow to name this one’s internals. A package boundary that nothing enforces is a naming convention, and naming conventions lose to deadlines.
Every mainstream language has a visibility mechanism, and each draws the line at a different granularity. Java’s default access is package-private, with public opening a declaration to the world and the module system adding a second gate at the package level. C# has internal scoped to the assembly plus InternalsVisibleTo for named exceptions. Rust has pub, pub(crate), pub(super), and pub(in path), with privacy checked per item. Go has no keyword at all: an identifier is exported if it begins with an uppercase letter, and a directory named internal is importable only from within its parent tree. TypeScript has module scope plus the exports map in package.json, which can hide entire subpaths from consumers.
The mismatch that causes trouble is between the unit the language enforces and the unit the design intends. A design might call for a “billing” module consisting of three packages, but Java’s package-private visibility cannot hide one of those packages from a sibling in the same module while exposing another. The enforcement unit is the package, so the design must either be expressed as one package per module or accept that the compiler will not check the rest.
What a public signature drags with it
The decisive rule is that anything appearing in a public declaration’s signature is itself public, whether or not its own declaration says so. A public method returning an internal type has published that type; callers can bind the result, call its methods, and depend on its shape. Languages differ on whether they reject the construct or merely permit the inconsistency.
Rust diagnoses it. A private type in the interface of a reachable public item is reported by the private_interfaces lint, which is warn-by-default in current releases, and the diagnostic names the offending type. The rule forces the designer to either make the type public deliberately or wrap it in something public. Java permits the equivalent only if the type is accessible; a public method cannot return a package-private type from another package, but it can return a package-private type declared in the same package, and callers outside the package can hold the value via Object or an interface without being able to name its class.
pub mod billing {
struct Ledger { entries: Vec<i64> } // private to `billing`
// private_interfaces: type `Ledger` is more private than the item `open`
pub fn open() -> Ledger {
Ledger { entries: Vec::new() }
}
}
The fix is not to make Ledger public. It is to return a public handle or a public trait object whose methods express what callers may do, keeping the record layout inside. That refactor is the ordinary work of module design: the boundary is only real if the types crossing it were chosen for the crossing.
The same rule applies to type parameters, associated types, thrown exceptions, and constant values. A public generic function whose bound mentions a private trait publishes the trait. A public function whose declared error type is a private enum publishes the enum’s variants, because callers must match on them to handle failures. A public constant whose value is derived from an internal table publishes the table’s contents one integer at a time.
Six ways boundaries leak despite visibility rules
Barrel files and wildcard re-exports. A index.ts that does export * from './internal/ledger' overrides every internal decision in one line. The declaration is public because the barrel said so. The same pattern appears in Python __init__.py files that import implementation names into the package namespace, and in Java packages where a single PublicApi class exposes static factory methods returning implementation classes.
Deep imports. A package whose exports map publishes only the root can still be reached by a path that bypasses resolution, or by a consumer that reaches into the installed directory directly. Node’s exports field closes the ordinary path, and the remaining bypasses — relative paths into node_modules, bundler aliases — are exactly the cases where the boundary is being circumvented deliberately and should be treated as a design disagreement rather than a build defect.
Test-only access. Tests need to construct internals, so the module opens them. Java’s module system has opens for reflective access and C# has InternalsVisibleTo; both expand the boundary to a named consumer. The expansion is usually acceptable, because the test is part of the module’s own development, but the exception list grows: first the unit tests, then an integration harness, then a debugging tool, and the “internal” surface is effectively public to everyone who asked.
Serialization formats. A JSON field name in a DTO is a published identifier even if the class is internal. Renaming the field is a breaking change for every consumer that sends or reads it, which means the wire format is the contract and the class is an implementation detail that happens to be visible. The module boundary that matters is at the format, not at the class.
Public extension points. A public abstract class with a protected constructor and abstract methods invites subclassing. Its internal calls become part of the contract, as the fragile base class problem describes, and the module can no longer change its own method-call sequence without breaking third-party subclasses. Interfaces with only public methods have the same issue in weaker form: adding a method breaks every implementer outside the module unless the language supplies default implementations.
Reflection and dynamic dispatch. Any language with reflection can reach private members given enough determination, and frameworks do it routinely to inject dependencies or map fields. Strong encapsulation at the language level limits the accident rate; it does not prevent deliberate access. The practical defence is to treat reflective access as a dependency edge in review, because it is one.
The narrower boundary: what a package is for
A package that hides a representation is useful. A package that exists only to group classes by technical kind — all controllers, all repositories, all models — hides nothing and enforces nothing, because every package must be public for the others to use it. The visibility mechanism then has no work to do, and the design is a set of conventions with a compiler that will not object when they are broken.
The alternative is to make each package own a decision and expose only the operations on that decision. In a well-shaped package, most declarations are package-private and a small number are public. The ratio is a usable review signal: a package where 90 percent of declarations are public is a folder, not a module. It may still be the right structure, but the encapsulation claim is empty.
Where the language’s unit is too coarse, the options are to use a stronger mechanism or to compensate with tooling. Java’s module system adds package-level exports on top of package-private declarations, which allows a module to expose three of its twelve packages. Go’s internal directory rule approximates a module boundary using the filesystem. Rust’s pub(in path) scopes visibility to an ancestor module. Where no mechanism exists, an import-direction check in continuous integration substitutes for the compiler, with the drawback that it rejects combinations rather than individual names.
Encapsulation has a cost that is paid in tests
Hiding internals makes unit tests harder to write, because a test that lives outside the module can only use the public surface. There are two honest responses. The first is to put white-box tests inside the module, in the same package or as a child module, so they can see internals without widening the public surface for everyone else. The second is to accept that the public surface is the testable surface and design it so that behaviour is reachable: if an important branch cannot be triggered through the interface, the interface is missing an operation or the module is doing too much.
The dishonest response is to widen visibility until tests pass. It solves the immediate problem and leaves the boundary permanently open, with the tests now coupled to representation as tightly as the code they exercise. The contract frame is the corrective: tests written against behaviour survive representation changes, and tests written against internals do not.
From source visibility to binary visibility
Source-level visibility is the first gate, and it is not the last. Once a module is compiled and shipped, the exported symbol table becomes the boundary, and it is wider than the language’s visibility rules suggest: private members appear in debug information, inline functions appear in every translation unit that uses them, and template instantiations are emitted wherever they are needed. The mechanisms that control that surface — symbol visibility attributes, version scripts, module export lists — are a separate layer from the one discussed here, and they matter as soon as the module leaves the source tree. That transition is the subject of ABI boundaries.
References
- The Java Language Specification, Java SE 17 — Oracle
- JSR 376: Java Platform Module System — Java Community Process
- The Rust Reference — Visibility and Privacy — The Rust Project
- Node.js Documentation — Packages — OpenJS Foundation