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 log and 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:

text
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:

text
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.

text
      ┌───────── 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#'s internal, Go's lowercase identifiers and internal/ 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

  1. paperParnas, On the Criteria To Be Used in Decomposing Systems into Modules (CACM, 1972)
  2. articleCockburn — Hexagonal Architecture (Ports and Adapters)
  3. bookDomain-Driven Design: Tackling Complexity in the Heart of Software
  4. bookA Philosophy of Software Design, 2nd Edition
  5. docsArchUnit — enforcing architecture rules as tests

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close