Which Files Changed Together? Reading Cohesion Out of Version History
Ask two engineers whether OrderPricing is cohesive and you will get opinions. Ask git and you will get data. git log --name-only --pretty=format: --since=1.year | sort | uniq -c | sort -rn over a module shows which files are edited in the same commits — and high cohesion groups code that changes together. When a pricing rule changes, the commit touches PricingRules, DiscountTable, and PricingTest and nothing else, the module is cohesive: one reason to change, one owner, one review. When the same commit routinely reaches from catalog/ into shipping/ into checkout/ to deliver one feature, low cohesion has already made three teams coordinate for one edit.
The academic phrasing — functional cohesion is best, coincidental cohesion is worst — is less useful than the operational question: what are the reasons this module's files are edited? A module with one answer ("pricing rules changed") is doing one job. A module with five answers ("shipping rules, tax tables, catalog display, export format, and a validator someone parked here in 2021") is a folder, not a module. Folders accumulate by adjacency; modules are cut by change.
Two concrete smells distinguish the cases:
| Observation | What it means |
|---|---|
common/ or utils/ has the highest fan-in in the repo |
Cohesion inverted — unrelated duties share a name instead of a reason |
| A feature branch must merge changes in 4+ modules | Boundaries cut across the real change axis; the seams are in the wrong place |
| One module's tests always need another module's fixtures | The two are one cohesive unit pretending to be two |
| Deleting a module requires editing 12 others | Efferent coupling is the cohesion debt coming due |
A concrete exercise makes the method stick: take the last ten merged pull requests and write down the set of modules each touched. If most features touch the same small set of modules, your boundaries are drawn along the feature axis and reviews stay local. If the sets are scattered — one PR hits catalog and shipping and billing, the next hits pricing and notifications and checkout — the modules were cut by layer or by noun rather than by change, and every feature becomes a cross-team negotiation dressed up as a code review.
Repair follows the same evidence. If history says pricing and promotions co-change in 80% of feature commits, merge them — you have empirically discovered a missing context, and two halves of one cohesive unit are paying interface tax to talk to themselves. If shipping is edited for five unrelated reasons, split it along the change clusters: zones, labels, tracking, each becoming its own module with a narrow API. The reverse move matters too: an entity and everything that enforces its invariants should live together, which is the aggregate boundary of Bounded Contexts in Domain-Driven Design showing up as a measurable property rather than a diagram.
The trap in advanced systems work is optimizing cohesion in isolation. A perfectly cohesive module that nothing else can use is a well-organized orphan; cohesion says whether the inside is coherent, coupling (next chapter) says what the outside pays. Interviewers at senior level ask for both: give the history-command trick, name common/ as the anti-pattern everyone has, and finish with the repair — merge what co-changes, split what changes for unrelated reasons — before you say a word about metrics.
Afferent, Efferent, Instability: The Three Numbers That Describe a Module's Debt
Cohesion describes the inside of a boundary; coupling describes the edges. Robert Martin's metrics make the edges countable, and three numbers are enough to run a design review.
Afferent coupling (Ca) counts classes outside the module that depend on it — its fan-in. High Ca means the module is load-bearing: many dependents, so every change ripples, and the API must be treated as a public contract. Efferent coupling (Ce) counts distinct modules it depends on — its fan-out. High Ce means the module cannot be understood or tested alone; it drags half the system into its classpath. Instability I = Ce / (Ca + Ce), a value from 0 (Ce = 0 — the module depends on nothing outside itself) to 1 (Ca = 0 — nothing in the system depends on it).
The design rule that gives the numbers teeth: stable components (low I) should not depend on unstable ones (high I). Concretely — if twelve modules depend on pricing (Ca = 12, Ce = 2, I ≈ 0.14, very stable), then pricing importing experimental-ml-scoring (Ca = 0, Ce = 5, I = 1.0) inverts the dependency: the stable thing now breaks when the unstable thing changes. The fix is the Dependency Inversion Principle — pricing declares the interface it needs, the unstable module implements it — which is the same consumer-owned-interface move you saw in ports and adapters, now justified with a ratio.
A worked reading from a real build:
module Ca Ce I = Ce/(Ca+Ce) reading
orders 14 3 0.18 stable, load-bearing — protect its API
pricing 5 6 0.55 balanced, but watch fan-out growth
scoring 0 7 1.00 unstable leaf — must not be depended upon
common 9 1 0.10 suspiciously stable for a junk drawer
common at Ca = 9 with a name no one can explain is the tell from the cohesion chapter: utilities become stable by fan-in, then nobody dares refactor them, and the junk drawer is financially locked in.
Ce deserves a second look during reviews because its growth is silent. Adding one import of shipping from catalog does not fail anything; it just raises catalog's Ce and its test surface. The architectural question at review time is therefore not "is this import legal?" but "does this new dependency make this module harder to test alone, and is the direction stable-to-unstable?" Direction is what separates coupling analysis from a linting exercise: an unstable module depending on a stable one (scoring importing orders) is perfectly healthy — leaves may depend on trunks, never the reverse.
Tooling makes all three numbers real: jQAssistant or ArchUnit export the dependency graph for Java, dependency-cruiser computes it for JavaScript and fails on cycles, pydeps or import-linter for Python. Feed the graph into the CI report from the last chapter and the numbers become trends. One habit makes them useful in reviews: print the three numbers for every module the PR touches, before and after. A reviewer who sees catalog move from Ce 4 to Ce 7 has a concrete question — "what did we just tie this module to, and can it still be tested alone?" — instead of a vague unease about imports. The interview payoff: "how do you know your boundaries are sound?" answered with Ca, Ce, I, direction of dependence, and one example where a ratio changed a decision — that answer is rare because most candidates stop at "low coupling, high cohesion" as adjectives rather than measurements.
The Cycle That Killed Independent Deployment: Finding and Cutting Circular Dependencies
orders calls billing to invoice, billing calls customers to resolve the payer, customers imports a validation helper that lives in orders. Three modules, one cycle — and the consequences are precise. No module in the cycle can be compiled or unit-tested without the other two on the classpath. A change to customers forces a joint release of all three, because orders cannot deploy against a customers version that temporarily lacks the helper. The cycle also lies about ownership: the diagram said three teams, the dependency graph said one.
Cyclic dependencies between modules block independent testing and deployment, and they are common because each edge was individually reasonable. The cure starts with detection, which is one command per toolchain:
dependency-cruiser --output-type dot src > deps.dot # fails on cycles with --validate
cycles: npx madge --circular --extensions ts src
jQAssistant: MATCH (a)-[:DEPENDS_ON]->(b)-[:DEPENDS_ON*1..]->(a) RETURN a,b
Run it in CI as a gate, not a report: the first commit that introduces a cycle gets reverted by the build with the offending import named. Once you can see cycles, cutting them is one of exactly three moves, and choosing correctly is the architecture skill.
| Move | When it fits | Example |
|---|---|---|
| Extract downward — move the shared type to a lower module both may depend on | The cycle runs through a value object or enum parked in the wrong place | Move Money, OrderStatus into a kernel module; orders, billing import it |
| Invert — the caller declares an interface, the callee implements it | One side needs a behavior from the other (orders needs billing's invoice call) | orders.spi.InvoiceIssuer, implemented by billing, wired at composition root |
| Break with events — replace the call with a published event | The caller needs no answer, only the effect happened | orders publishes OrderPlaced; billing invoices asynchronously |
The event move deserves scrutiny because it is often chosen for the wrong reason: it does eliminate the compile-time edge, but it introduces eventual consistency and a delivery contract. If PlaceOrder must fail when invoicing fails, the synchronous inverted interface is honest; the event is a lie that becomes an incident. Say which you need based on whether the answer matters to the caller, not based on which looks more modern.
After the graph is acyclic, keep it that way with a trend. A CI job that writes Ca/Ce/cycle-count per module to a JSON artifact and plots the deltas per pull request changes the review conversation: "this PR adds 3 efferent couplings to checkout" is an objection a reviewer can act on, while "the design is getting worse" is not. The verification after any cut is the same three checks: the cycle query returns zero rows, each former member compiles and tests against a subset of the others, and — the check teams skip — the joint-deploy rule is gone from the release process, because a cut that leaves all three services still versioned together merely relocated the cycle to the pipeline. Architects track coupling metrics over time to detect boundaries eroding under deadlines because erosion is a rate, not an event — no single import ruined anything; forty imports across sixty days did, and only the trend line shows the slope. This measurement habit is what you carry into the next phase: microservice boundaries magnify every one of these numbers into network calls, so getting fluent here on one deployable is the cheapest place to learn it.
Quiz
1. A module has Ca = 16, Ce = 4. What is its instability and the design obligation that follows?
2. git history shows `pricing` and `promotions` files are edited together in 80% of feature commits, and each constantly needs the other's fixtures in tests. What is the best repair?
3. orders must fail the user's checkout when billing rejects the invoice — and today orders→billing→customers→orders forms a cycle. Which cut is correct?
4. Why does tracking coupling metrics over time matter more than a one-time measurement?
Flashcards
Question
How do you measure cohesion empirically instead of by opinion?
Click to reveal answer
Answer
Mine version history for co-change: list files per commit for a year and cluster them. Files edited together belong together (merge modules that co-change), files edited for unrelated reasons in one module mean split it. Opinion argues; git history shows which seams match the real change axis.
Question
Define Ca, Ce, and I, and state the dependency-direction rule they support.
Click to reveal answer
Answer
Ca = afferent coupling: outside classes depending on the module (fan-in → API contract pressure). Ce = efferent coupling: distinct modules it depends on (fan-out → test/classpath drag). I = Ce/(Ca+Ce) ∈ [0,1] = instability. Rule: stable components (low I) must not depend on unstable ones (high I) — invert volatile dependencies behind stable-owned interfaces.
Question
Name the three legal moves to break a module dependency cycle and when each fits.
Click to reveal answer
Answer
Extract downward: shared value type/enum parked wrong → move to a kernel module both import. Invert: caller declares the interface it needs, callee implements it — when the caller needs an answer. Events: replace call with a published event — only when the caller needs no synchronous answer; it trades the cycle for eventual consistency.
Question
Why does a cycle break independent testing and deployment, concretely?
Click to reveal answer
Answer
Every module in the cycle needs the others on its classpath to compile and test, so no member can build or deploy alone — a change anywhere forces a joint release of all members. Ownership becomes fictional too: the org chart says three teams, the dependency graph says one.
Question
What is the healthiest interpretation of a utility module with high fan-in (e.g., common/ at Ca = 9)?
Click to reveal answer
Answer
It is a junk drawer locked in by its own fan-in: unrelated duties made 'stable' by import count, now too scary to refactor. Diagnose with cohesion (what reasons edit its files?), then extract real domain types to a deliberate kernel and delete the rest — high Ca on an unexplainable module is debt, not importance.
Revision Notes
Key Takeaways
- 1. Cohesion is measurable: co-change clusters in git history show which modules are artificially split and which are junk drawers pretending to be modules
- 2. Ca/Ce/I convert 'low coupling' from an adjective into a reading — and the direction rule (stable must not depend on unstable) is where the numbers change decisions
- 3. A module cycle means one team in disguise: no member compiles, tests, or deploys alone, so the CI gate pays for itself at the first revert
- 4. There are exactly three cycle cuts — extract downward, invert behind a consumer-owned interface, break with events — and events are wrong when the caller needs a synchronous answer
- 5. Boundary erosion is a rate under deadline pressure; per-PR metric deltas are the only way to see the slope before a release blocks on it
Interview Tips
- • 'How do you measure coupling?' — Ca, Ce, I with a worked example (orders: Ca 14, Ce 3, I 0.18) and the stable-depends-on-unstable rule; most candidates only say 'low coupling, high cohesion'
- • 'Give an example of a boundary you fixed' — narrate with evidence: history showed co-change → merged two modules, or a cycle found by dependency-cruiser → extracted Money to kernel and inverted the invoice call
- • 'What do you do when a cycle appears in review?' — detect in CI, pick the cut by semantics: needs an answer → invert; no answer → event; shared type → move downward
- • 'How do you keep boundaries from eroding?' — trends not snapshots: per-PR Ca/Ce/cycle deltas in CI, because forty individually reasonable imports are what ruined the last design
Cheat Sheet
Boundaries & Coupling Cheat Sheet
Cohesion test (empirical): git log --name-only → co-change clusters.
files edited together → merge the two modules (one cohesive unit)
one module edited for unrelated reasons → split by change cluster
entity + its invariants → live together (aggregate boundary)
Smells: common/ tops fan-in · feature branch touches 4+ modules · module A's tests need module B's fixtures.
Metrics:
| Metric | Counts | Read as |
|---|---|---|
| Ca (afferent) | outside classes depending on module | fan-in → treat API as contract |
| Ce (efferent) | modules it depends on | fan-out → test/classpath drag |
| I = Ce/(Ca+Ce) | 0..1 | instability; 0 = leaf-of-dependency, 1 = nothing depends on it |
Direction rule: stable (low I) must not depend on unstable (high I) → invert behind consumer-owned interface. Leaves may depend on trunks; never reverse.
Cycle gate (CI, fail not report): dependency-cruiser --circular · madge --circular · jQAssistant path query.
Cut with: extract shared type downward · invert (consumer declares interface) · events (only when no answer needed — else you lied about consistency).
Trend: write Ca/Ce/cycle-count per module to CI artifact each PR; erosion is a rate — review deltas, not snapshots.