CouplingGENERALSCALE-SPECIFICCONTESTED

Cohesion

A module is cohesive when its parts change for the same reason. Cohesion and coupling are one question asked twice: what belongs together, and what may know about what.

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

What belongs in this module, given that anything I take out of it becomes a dependency between two modules?

The requirement

A new engineer opens UserService — nine hundred lines, twenty-two public methods — and asks where to put the code for "invite a colleague". Nobody has an answer that is not "wherever fits".

The obvious build

Group by the noun. Everything about users goes in UserService; everything about orders goes in OrderService. It is obvious where to look, new code has an obvious home, and nobody argues about placement.

Why it breaks

The noun is not a reason to change. UserService holds registration (identity), lifetime value (billing analytics), GDPR export (compliance) and CRM sync (growth tooling) — four teams, four release cadences, one file, one merge queue.

How it breaks as requirements change
  • The noun is not a reason to change. UserService holds registration (identity), lifetime value (billing analytics), GDPR export (compliance) and CRM sync (growth tooling) — four teams, four release cadences, one file, one merge queue.
  • A compliance change to the export format triggers a full regression of authentication, because the tests are entangled and nobody is confident about what else lives in there (Divergent Change).
  • The class accumulates the union of everyone's dependencies: a password hasher, a CRM client, an analytics warehouse connection and a PDF renderer. Every consumer of one of those now transitively depends on all of them (Transitive Dependencies).
  • The obvious symptom is merge conflicts; the expensive symptom is that nobody can reason about the file, so every change is made defensively and none of them clean anything up (Local Reasoning).
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
  • The class is called from about forty places, so any split has to be doable incrementally rather than as one commit (Incremental Migration).
  • Four teams touch it: identity, billing, growth and compliance. It is the single most conflict-prone file in the repository.
  • There is no appetite for a restructuring project, so the split has to be justified change by change (The Refactoring Loop).
Invariants
  • A user's email is unique and verified before it is used for authentication — one rule, one owner, whatever the module layout ends up being.
  • Behaviour does not change during the split. A cohesion refactor that alters what the system does is not a refactor (What Refactoring Actually Is).

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Identity owns registration, credentials and verification — a rule set that changes when the authentication model does.
  • Compliance owns data export and erasure, which changes when regulation does and on nobody else's schedule.
  • Growth owns invitations and CRM sync; billing owns lifetime value. Neither needs the other, and neither should be able to break authentication.
  • The shared thing they all reference is much smaller than the current class: an identifier, an email address and a status.
Boundaries
  • The seam falls between reasons to change, not between nouns. Everything that changes when the authentication model changes goes together, whatever it is called.
  • The split is only worth it where the parts genuinely have different change reasons. Splitting cohesive code produces two modules that always change together, which is worse than one (Over-Decomposition).
  • The shared kernel — the UserId, the email value type — is the boundary all four sides depend on, and it must be small enough to be stable (Stable Boundaries).

What this module knows, and every reason it changes

The finding here is not the line count. It is the changesWhen list: five entries, owned by four teams, on four different release cadences, in one file that forty callers depend on.

Run the same check on the module you are about to write. One or two entries is a cohesive module; five is a scheduling problem, a merge queue and a blast radius, and the number of lines is incidental to all three (Single Responsibility, Carefully).

responsibilitiesUserServiceUserService, before the split
Knows
  • Credential hashing and verification rules
  • Which emails are verified
  • How lifetime value is computed from invoices
  • The CRM's field mapping
  • The regulator's export format and erasure deadlines
Does
  • Registers and authenticates users
  • Exports and erases personal data on request
  • Computes lifetime value for the billing dashboard
  • Pushes profile changes to the CRM
  • Sends and redeems colleague invitations
Depends on
  • Password hasher
  • Invoice repository
  • CRM HTTP client
  • Analytics warehouse
  • PDF renderer
  • Mail transport
Changes when — 5 distinct reasons
  • The authentication model changes
  • Data protection regulation changes
  • The billing team redefines lifetime value
  • The CRM vendor changes its schema
  • Growth changes the invitation flow

Five reasons to change across four teams. Every consumer of authentication transitively depends on a PDF renderer and a CRM client, and a compliance deadline can block an identity fix. The split is justified by that list, not by the nine hundred lines — a nine-hundred-line module with one entry here would be fine.

The two halves of one question

Cohesion asks what belongs inside a module; coupling asks what modules may know about each other. They are the same question because moving something across the line changes both: what you gain in cohesion you pay in a new dependency edge.

That is why "high cohesion and low coupling" is not a goal you can maximise. It is a pair of dials wired together, and the thing being optimised is neither dial but the cost of the changes you will actually be asked for (Changeability Is the Goal).

  • The last row is the one teams reach after being burned by the first, and it is not an improvement — it is the same total cost with different symptoms.
  • Nothing in the table says the god object is always wrong. It has the cheapest cross-cutting changes of any row, which is exactly why it grew (When Design Does Not Pay).
  • The decision needs one input the table cannot supply: what proportion of your changes are single-reason. Your commit history answers that (Finding Seams).
GroupingCohesion of each moduleCoupling introducedWhat a single-reason change costsWhat a cross-cutting change costs
One UserServiceLow — five reasons to changeNone between the parts; heavy dependency fan-in from forty callersOne file, full regression, merge conflict with two teamsOne file. Genuinely cheap, and this is the honest case *for* the god object.
Split by noun: User, UserProfile, UserSettingsStill low — the reasons to change did not moveThree modules that always change togetherNow two or three files instead of oneWorse than before: same reasons, more files, plus wiring (Over-Decomposition).
Split by layer: controller / service / repositoryLow — each layer holds five reasons, sliced differentlyEvery feature crosses all threeThree files in three folders, still a full regressionThree files, and the layering added no containment (Package by Layer).
Split by reason to change: Identity, DataSubjectRequests, Invitations, BillingAnalyticsHigh — one or two reasons eachThree explicit, directed calls into IdentityOne module and its tests. No cross-team conflict.Three modules and a coordinated test — more expensive than before, and this is the cost of the split.
Split further: one module per operationHighest possible, and meaninglessEvery feature touches five modules plus wiringOne tiny module — but finding it takes longer than the editFive modules, five interfaces, and a call graph nobody can hold in their head (Module Granularity).

Pricing the split against a real change

Cohesion arguments go in circles until someone prices a specific change. Here is one that arrived in a real shape: a regulator shortens the deadline for data-subject export requests, which changes the format and adds an audit record.

Note that the split does not make everything cheaper, and the section above already named the change it makes worse. What it does is move the expensive case from "the changes we get every week" to "the changes we get twice a year" (Change Amplification).

Export format and deadline change for data-subject requests
The change

Personal data exports must be delivered in a new machine-readable format within 72 hours, with an audit record of who requested and who fulfilled.

One `UserService` holding five responsibilities
UserService
testsuser_service_test (covers auth, billing analytics, CRM sync and export together)
1 module · 1 test file

One file — which sounds cheap and is not. The test suite covers authentication, so it all runs and it is all in the review. Identity and growth changes are in the same merge queue, and a rollback of the compliance change reverts theirs too.

Split by reason to change
DataSubjectRequests
testsdata_subject_requests_testidentity_contract_test
1 module · 2 test files

One module owned by one team, deployable and revertible on its own. Authentication is not in the blast radius, so the review is compliance people reading compliance code.

what it cost The feature that spans modules — "invite a colleague, create their account, start their trial" — went from one file to three modules with an integration test, and the wiring and fixtures are permanent overhead. The split also cost a migration of forty call sites, done incrementally over weeks, during which both the old and new entry points existed and either could be called (Incremental Migration).

How to build it

Most important first.

  • Look at the commit history rather than the code. The files and methods that change in the same commits are the actual cohesive units, and that evidence beats any judgement about what "belongs" with users (Finding Seams).
  • Name each candidate module by its reason to change — Identity, DataSubjectRequests, Invitations — so the name itself rejects unrelated code (Naming and Domain Language).
  • Move one responsibility at a time, keeping the old entry points delegating until callers are migrated (Expand and Contract).
  • Let the new coupling be explicit: Invitations calls Identity to create the account. One directed, visible call is a much cheaper relationship than sharing a file (Dependency Direction).
  • Stop when the remaining module has one reason to change. Continuing past that point trades a cohesion problem for a coupling problem, at par (Module Granularity).

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
  • Before: a GDPR export change costs one file, a full regression of everything else in that file, a merge conflict with two other teams, and a deployment that carries identity changes nobody reviewed together.
  • After: the same change costs one module and its tests. Authentication is not in the blast radius, which means the review is smaller and the rollback is safer.
  • The new cost: "invite a colleague and give them the trial plan" now spans Invitations, Identity and Billing, so a feature that used to be one file is three modules and a coordinated test. Cross-cutting features get *more* expensive after a split — that is the honest trade, and it is why the split must follow real change reasons rather than taste.
  • The next change after that is where it pays: each subsequent single-reason change stays inside one module, and there are far more of those than there are cross-cutting features.
What the recommended approach costs
  • Every split adds an interface, a wiring point and a test fixture. On a small team with one product these costs are immediate and the benefits are years out (The Cost of Change).
  • Cross-cutting features get harder, and there is no design that makes both single-responsibility changes and cross-cutting ones cheap. You are choosing which kind to optimise for.
  • Splitting by change reason produces module names that do not match the domain nouns people say out loud, so navigation gets slightly harder for everyone who was not in the discussion (Ubiquitous Language).

What can go wrong

Failure modes
  • The split is made by noun again, one level down — UserService, UserHelper, UserManager — which relabels the problem and adds indirection (The Utility Dumping Ground).
  • A shared UserContext object is introduced so the new modules can talk, and it becomes the old class with a different name and mutable state (Shared-State Coupling).
  • The split is done cleanly and one module retains a back-reference to another for convenience, creating a cycle that makes both untestable in isolation (Dependency Cycles).
  • The mitigation fails on its own: four small modules with four sets of interfaces, wiring and test setup can cost more in ceremony than the merge conflicts they removed, especially on a team of five (When Design Does Not Pay).
Dependencies, and their direction
  • Splitting converts intra-module cohesion into inter-module coupling. That exchange is the whole decision and it is worth making only when the parts change separately.
  • Invitations and DataSubjectRequests both depend on Identity; Identity depends on neither, so an authentication change cannot be broken by a compliance one.
  • The dependency each split *removes* is the interesting one: after the move, billing analytics no longer transitively depends on the password hasher.
Misreads
  • "Cohesion means small modules." A four-thousand-line module where everything changes for the same reason is more cohesive than three hundred-line ones that always change together. Size is a symptom, not the measure (Long Functions).
  • "High cohesion, low coupling — maximise both." They trade against each other: pulling something out of a module raises the module's cohesion and creates a new coupling. The goal is the total cost of likely changes, not either metric (Kinds of Coupling).
  • "Group by layer instead, then." Layers group by technical kind, so a single compliance change still touches the controller, the service and the repository — the cohesion problem is unchanged and now spans three folders (Package by Layer).
  • "The class is 900 lines, so split it." Split it because four teams change it for four unrelated reasons. If one team changes it for one reason, the line count is not the finding (What a Code Smell Is).
Smells this explains
  • god-object
  • divergent-change
  • utility-dumping-ground

Testing it, and how it ages

What to test, and at which boundary
  • Test each module against its own rules with no other module present. If Invitations tests need a password hasher, the split is not finished (What a Unit Is).
  • Keep a small number of tests across the seam for the flows that genuinely span modules — invitation to active account — because that is where a split introduces new risk (Where a Test Must Be Real).
  • Characterize the old behaviour before moving anything; a cohesion refactor without a safety net is a rewrite (Characterization Tests).
  • Watch the test setup as a signal: a module whose tests need eight fixtures is telling you its dependencies are the union of several responsibilities (Testing as Design Feedback).
How this design ages
  • Cohesion decays by default. Every new feature has to go somewhere, and the largest existing module is always the path of least resistance (God Object).
  • The reliable signal that a module has lost cohesion is not size, it is the number of distinct teams in its commit history over six months.
  • A well-split module eventually grows its own internal structure, and that is success. The failure mode to watch for instead is two modules that never change independently, which should be merged back (Over-Decomposition).

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.

  • GENERALThat things which change together should live together follows from the cost of finding and coordinating edits, so it holds for classes, files, packages, services and repositories alike — only the coordination cost of getting it wrong changes with the grain.
  • SCALE-SPECIFICOn a team of three, low cohesion costs little because everyone knows the whole file and there is no cross-team merge queue; the same module at forty engineers is the main source of conflict and review latency. The threshold is roughly where two independent teams start editing the same unit weekly.
  • CONTESTEDThe strongest opposing view is that "reason to change" is unfalsifiable in advance — you can narrate any grouping as one reason or five — and that in practice teams should optimise for discoverability instead, keeping code where a reader will look for it even when that means grouping by noun. That position is right that reason-to-change is a judgement rather than a measurement; the counter is that it is a judgement you can *check* against commit history, which turns it into evidence rather than taste.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Architecturemodular-monolith
Domains that do not exist yet
  • System Design — the same reason-to-change criterion decides service boundaries, where getting it wrong costs a distributed transaction rather than a merge conflict.
  • Testing & Reliability Engineering — test setup size is one of the earliest observable signals of lost cohesion, usually visible a year before the merge conflicts start.