Module Public Surface
also called Module Exported Surface, Module Entry Points
The set of types a module lets other modules reach, which decides how much of it can be rewritten or extracted without touching anyone else's code.
A monolith has eight modules, an import rule in CI, and a build that has never failed on a boundary violation. The team reads that as discipline. The real reason is that every class is public, and the rule only checks which module an import came from, not what it reached inside.
Then billing renames an internal class and three other modules stop compiling, with 14 call sites to fix. A year later the plan to extract billing stalls: the extraction is not one interface, it is 90 types other modules reference directly, several of them persistence entities carrying the schema with them.
The import rule described the dependency graph. It never constrained the surface, and the surface is what the cost is proportional to.
Why it matters
The promise of a modular monolith is that a module's interior is yours: rewrite it, re-model its tables, lift it out, with no cross-team campaign. That promise holds exactly as far as the public surface and nowhere further.
The arithmetic is blunt. Extraction and internal rewrites cost roughly the exported types times the call sites each has. Twelve exported types with five callers each is about 60 edits, 5 days with a facade holding the seam. Ninety exported types is 450 edits across teams you do not control, a programme rather than a refactoring. Module size barely enters it. As a rule of thumb, if more than roughly 10% of a module's types are exported, assume it has no interior left.
Implementation patterns
- Let the language enforce it where it can. Java's module system, added in Java 9 (September 2017), makes a public type invisible outside its module unless its package is exported. Kotlin and C# have internal; Go has lowercase identifiers.
- Where the language cannot, make the convention mechanical: one
apipackage per module holding everything exported, and an import linter that fails on any import from another module outside itsapipackage, however public the target. - Export data, not entities. An exported persistence object drags the session, the lazy relations and the schema across the boundary. A value type costs a mapping function and buys back the interior.
- Make surface growth the reviewable event. A CI step printing the diff of each module's exported surface turns "one more public class" into a decision somebody agreed to, and gives you the count to track.
Industry example
Java 9's module system is the case worth studying: it retrofitted this idea onto a platform where public meant globally reachable, and the ecosystem-wide migration pain is the clearest evidence of what an unbounded surface costs once code depends on it.
The archetypal failing version is a monolith in a language with no visibility enforcement, where modules are directories and the boundary is a wiki page. Tests are the worst offenders: setup reaches into another module's internals to build fixtures, so extraction day finds the interior coupled to a thousand tests.
Failure scenarios
- Vacuous enforcement. A rule that has never failed is usually enforcing nothing, which the build cannot tell you.
- Growth by convenience. A reader needs one field, an accessor goes public "temporarily", and the surface ratchets.
- The facade that returns the entity, so the boundary is cosmetic and callers depend on your schema.
Trade-offs
| Choose | Gains | Pays |
|---|---|---|
| Small deliberate surface | Interior freedom, cheap extraction, renames stay local | Mapping layers and duplication; a caller wanting a join asks for a purpose-built method |
| Wide surface | No boilerplate, callers get what they want | No interior, so every internal change is a cross-module change and extraction never gets scheduled |
When not to use it
A two-module application owned by one team does not need this discipline, because the boundary is a convention everyone can still see and the mapping layer has no beneficiary.
The rule also inverts for a module whose job is shared vocabulary. A types or domain-events package exists to be depended on, so shrinking its surface defeats the purpose: version it, treat changes as breaking, and apply the discipline to its consumers instead. What flips the default is a second team owning a module, or the first extraction appearing on a roadmap.
Interview question
Q: Your monolith enforces module imports in CI and has never failed a build, yet a planned extraction has slipped twice. Where do you look, and what do you change next sprint?
What a strong answer covers: that an import rule constrains direction and not surface, so a module where everything is public has no interior; measuring exported types and inbound references to size the work; extraction cost as types times call sites; the repairs (an api package plus a linter, value types instead of entities, fixtures built through the public surface); and the couplings no import rule sees, shared tables and migrations.
Quick check
Quiz: Two modules have 20,000 lines each. One exports 12 types, the other 90. Which is extractable and why? — The one exporting 12, because extraction cost tracks exported types times their call sites, not module size.
Flashcard: Why can a CI import rule pass for years while modules stay tightly coupled? — It checks which module an import came from, not what it reached. If every type is public, every import is legal.