Module Boundaries: Cohesion, Coupling & Layering
Why packaging by technical layer makes every feature change touch every package, what the dependency rule in hexagonal and clean architecture actually enforces, and why a boundary the build does not check is only a diagram.
Parnas asked the right question in 1972 and it has not changed: a system can be decomposed many ways, so what is the criterion? His answer was that a module should hide a decision likely to change, and the measure of a decomposition is how much of the system a likely change touches. Everything below — layers, hexagons, bounded contexts, package-by-feature — is a specific answer to that question, and each is only as good as the changes it actually localizes.
Cohesion and coupling, made concrete #
Cohesion is how much the things inside a module belong together; coupling is how much a module must know about another to work. Both are usually asserted rather than measured, and there are two tests that are not opinions:
- The change-history test. Run
git logand look at which files change in the same commits. Files that keep changing together and live apart are badly placed; files that live together and never change together are a module that wants splitting. This measures the property Parnas actually cared about. - The compile test. How much of the system rebuilds when this module's interface changes? That number is the coupling, in the only unit that matters at scale.
Not all coupling is equal, and the ordering is worth having:
| Kind | Example | Cost |
|---|---|---|
| Data | passing a value | lowest — this is normal |
| Stamp | passing a whole record when two fields are used | the caller is now coupled to fields it does not use |
| Control | passing a flag that selects behaviour (save(order, true)) |
the caller must know the callee's internals |
| Common | shared global/mutable state | any module can break any other, invisibly |
| Content | reaching into another module's internals | worst — no change is safe |
Control coupling is the most common one people do not notice. A boolean parameter that switches behaviour means the caller is making the callee's decision; two named methods say it better and cannot be called wrongly.
Layering by technology is the default mistake #
The conventional package layout groups by technical role:
com.app.controllers ← every controller in the system
com.app.services ← every service
com.app.repositories ← every repository
com.app.models ← every entity
Adding one field to one feature touches four packages, and no package can be understood, tested, or extracted alone. Every package has minimal cohesion by construction: the only thing OrderController and ReportController share is a suffix.
Package by feature inverts it:
com.app.orders ← controller, service, repository, model — package-private
com.app.billing
com.app.reporting
Now a feature change is one directory, most classes can be package-private (a real access-control boundary, not a convention), and extracting billing into its own module or service is a move rather than an archaeology project. Layers still exist inside each feature, which is where they were useful all along.
The dependency rule #
Hexagonal architecture (Cockburn's ports and adapters) and its relatives make one structural claim: dependencies point inward, toward the domain, and the domain depends on nothing.
┌───────── adapters ─────────┐
│ HTTP DB Kafka CLI │
└──────────┬─────────────────┘
│ implement
┌──────────▼─────────────────┐
│ ports (interfaces owned │
│ by the application) │
├────────────────────────────┤
│ domain: entities, rules │ ← no framework, no SQL, no HTTP types
└────────────────────────────┘
The port is declared by the inside and implemented by the outside — dependency inversion applied at module scale. What it buys is specific and testable: the domain compiles and its tests run with no database, no web server and no message broker on the classpath, so the slow, flaky, environment-dependent parts of the test suite are confined to a thin adapter layer.
What it costs is equally specific: a mapping layer between domain types and persistence/transport types, which is real code that does nothing but copy fields. That cost is worth paying when the domain has substantial rules and is not worth paying for a CRUD service whose "domain logic" is validation — there, the honest design is a thin handler over the database, and adding hexagonal ceremony to it is pure overhead.
Bounded contexts #
The boundary that matters most in a large system is not technical. Evans' observation is that the same word means different things to different parts of a business — "order" in fulfilment is a shipment plan, in billing it is an invoice line, in analytics it is a row — and forcing one shared model to serve all of them produces a class nobody can change.
A bounded context draws the boundary where the language changes, gives each side its own model, and makes the translation between them explicit. The practical marker that you have found one: two teams keep arguing about what a field means. That is not a naming problem; it is two models trying to occupy one class.
MVC and friends #
The UI-side version of the same question. What they separate:
| Separates | Fails as | |
|---|---|---|
| MVC | model / rendering / input handling | "fat controller" — business rules in the controller, where they cannot be tested or reused |
| MVP | view is passive, presenter drives it | presenter grows into the same fat controller |
| MVVM | view binds declaratively to a view-model | view-model becomes a second model, duplicating rules |
All three fail the same way, and the fix is the same: the model is the domain, not a DTO. If the controller/presenter/view-model contains business rules rather than translation, the separation is cosmetic.
A boundary the build does not check does not exist #
Architecture diagrams do not compile. If nothing stops com.app.orders from importing com.app.billing.internal, someone will, on a deadline, and the boundary is gone silently.
- Language/build enforcement where available: Java modules and package-private, Rust's
pub(crate), C#'sinternal, Go's lowercase identifiers andinternal/directories, separate build targets with declared dependencies. - Architecture tests where it is not: ArchUnit,
import-linter,depcheck-style rules — layer and package rules asserted as ordinary failing tests. - A dependency cycle between modules is the clearest signal of a wrong boundary. Detect it in CI; a cycle means the two modules are one, and pretending otherwise costs everyone.
Sources
- paperParnas, On the Criteria To Be Used in Decomposing Systems into Modules (CACM, 1972)
- articleCockburn — Hexagonal Architecture (Ports and Adapters)
- bookDomain-Driven Design: Tackling Complexity in the Heart of Software
- bookA Philosophy of Software Design, 2nd Edition
- docsArchUnit — enforcing architecture rules as tests