---
title: 'Component Cohesion Principles'
source: 'https://academia.sh/en/courses/design-principles/component-cohesion-principles'
course: 'Design Principles'
language: en
updated: '2026-08-23T07:01:16+00:00'
license: 'CC BY-SA 4.0'
---

# Component Cohesion Principles

Extracting the component boundary from files that change together: computing the co-change count from a change log, clustering files by a threshold, and comparing the directory split with the co-change split by component count, republish count, and files bound to a capability.

The three lessons so far discussed the boundary one file at a time: which file the policy sits
in, how many names the contract between two modules consists of, how many files the outside
tool's vocabulary touches. At these scales the unit is the file and the boundary is an import
line.

Once a codebase grows, the unit changes. Files stop getting packaged, versioned, and released
one at a time; they get packaged, versioned, and released in sets, and this set is called a
**component**. The new question is: which files should go into the same component? This
lesson pulls the answer not from a guess, but from the library's change log.

## A Component Is a Unit of Reuse

A component's first criterion is not technical, it is about release: the side using a library
does not take it file by file, it takes it version by version. This is why **the unit of reuse
equals the unit of release** — this is the reuse/release equivalence principle. A set that
cannot be versioned separately cannot be reused separately, because there is no name to tell
the consumer what it is bound to.

This criterion alone does not fix the boundary, but it produces two consequences. First: if a
single file in a component changes, the whole component gets republished, and everyone bound
to the component has to accommodate the new version. Second: whoever binds to a component ends
up bound to every file inside it.

These two consequences turn into two separate principles. The **common closure principle**
asks that files changing at the same time for the same reason be gathered into the same
component; its measure is how many components a single change spreads across. The **common
reuse principle** asks that files not used together stay in separate components; its measure
is how many files a party needing one capability ends up bound to.

The three together determine a component's **cohesion**. In the Coupling and Cohesion topic,
cohesion was measured as how related the names inside a module are to each other; the question
here is the same, only the containing unit is a component instead of a module, and the
evidence for relatedness is change history instead of names.

## The Library's Files and Change Log

The library has grown to twelve files. The following module carries three things: the file
list, the import edges between the files, and the files touched together in each of
twenty-two changes. The change log was kept at the decision level in the first lesson; here it
is at the file level.

```js
// library.mjs — the library's files, import edges, and twenty-two changes
export const FILES = [
  "fee/tariff-table.mjs", "fee/fee-rule.mjs", "fee/discount.mjs",
  "zone/postal-mapping.mjs", "zone/factor.mjs",
  "carrier/selection.mjs", "carrier/carrier-list.mjs",
  "route/transfer-points.mjs", "route/route-building.mjs",
  "delivery/status-flow.mjs", "delivery/timestamp.mjs", "shared/money.mjs",
];

export const IMPORTS = [
  ["fee/fee-rule.mjs", "fee/tariff-table.mjs"],
  ["fee/fee-rule.mjs", "zone/factor.mjs"],
  ["fee/fee-rule.mjs", "shared/money.mjs"],
  ["fee/discount.mjs", "shared/money.mjs"],
  ["fee/tariff-table.mjs", "shared/money.mjs"],
  ["zone/factor.mjs", "zone/postal-mapping.mjs"],
  ["carrier/selection.mjs", "fee/fee-rule.mjs"],
  ["carrier/selection.mjs", "fee/discount.mjs"],
  ["carrier/selection.mjs", "carrier/carrier-list.mjs"],
  ["route/route-building.mjs", "route/transfer-points.mjs"],
  ["route/route-building.mjs", "carrier/carrier-list.mjs"],
  ["delivery/status-flow.mjs", "delivery/timestamp.mjs"],
  ["delivery/status-flow.mjs", "route/route-building.mjs"],
];

export const LOG = [
  ["fee/tariff-table.mjs", "zone/factor.mjs"],
  ["fee/tariff-table.mjs", "zone/postal-mapping.mjs", "zone/factor.mjs"],
  ["fee/fee-rule.mjs", "fee/discount.mjs"],
  ["fee/tariff-table.mjs", "zone/factor.mjs"],
  ["route/transfer-points.mjs", "route/route-building.mjs"],
  ["fee/discount.mjs", "carrier/selection.mjs"],
  ["fee/tariff-table.mjs", "zone/postal-mapping.mjs"],
  ["delivery/status-flow.mjs", "delivery/timestamp.mjs"],
  ["fee/fee-rule.mjs", "fee/discount.mjs", "carrier/selection.mjs"],
  ["fee/tariff-table.mjs", "zone/factor.mjs", "zone/postal-mapping.mjs"],
  ["route/route-building.mjs", "carrier/carrier-list.mjs"],
  ["fee/tariff-table.mjs", "zone/factor.mjs"],
  ["delivery/status-flow.mjs", "delivery/timestamp.mjs"],
  ["fee/discount.mjs", "carrier/selection.mjs", "fee/fee-rule.mjs"],
  ["route/transfer-points.mjs", "route/route-building.mjs", "carrier/carrier-list.mjs"],
  ["fee/tariff-table.mjs", "zone/postal-mapping.mjs", "zone/factor.mjs"],
  ["delivery/status-flow.mjs", "delivery/timestamp.mjs"],
  ["fee/discount.mjs", "carrier/selection.mjs"],
  ["route/transfer-points.mjs", "carrier/carrier-list.mjs", "route/route-building.mjs"],
  ["fee/tariff-table.mjs", "zone/factor.mjs"],
  ["fee/fee-rule.mjs", "fee/discount.mjs"],
  ["shared/money.mjs", "fee/fee-rule.mjs"],
];
```

The directory names suggest a split: `fee`, `zone`, `carrier`, `route`, `delivery`, `shared`.
This split was made by looking at what the files are about. The log may say something else.

## Finding What Changes Together

**Co-change** is the number of times two files were touched together in the same change. The
tool below computes this number for every pair, then binds pairs staying above a threshold into
the same cluster. The binding runs over disjoint sets: every file starts at its own root, and
every pair over the threshold unions the two roots.

```js
// co-change.mjs — clusters files that change together, above a threshold
import { FILES, LOG } from "./library.mjs";

const THRESHOLD = 3;
const pairKey = (a, b) => [a, b].sort().join(" + ");

const pair = new Map();
for (const files of LOG) {
  for (let i = 0; i < files.length; i += 1) {
    for (let j = i + 1; j < files.length; j += 1) {
      const k = pairKey(files[i], files[j]);
      pair.set(k, (pair.get(k) ?? 0) + 1);
    }
  }
}

const root = new Map(FILES.map((d) => [d, d]));
const find = (d) => (root.get(d) === d ? d : find(root.get(d)));
for (const [k, n] of pair) {
  if (n < THRESHOLD) continue;
  const [a, b] = k.split(" + ");
  root.set(find(a), find(b));
}

const cluster = new Map();
for (const d of FILES) {
  const k = find(d);
  cluster.set(k, [...(cluster.get(k) ?? []), d]);
}

console.log("co-change count (threshold >= " + THRESHOLD + ")");
for (const [k, n] of [...pair].sort((a, b) => b[1] - a[1])) {
  console.log(`  ${n >= THRESHOLD ? "*" : " "} ${String(n).padStart(2)}  ${k}`);
}
console.log(`\ncluster count = ${cluster.size}`);
for (const [, members] of cluster) console.log(`  ${members.join(", ")}`);
```

```
co-change count (threshold >= 3)
  *  7  fee/tariff-table.mjs + zone/factor.mjs
  *  4  fee/tariff-table.mjs + zone/postal-mapping.mjs
  *  4  fee/discount.mjs + fee/fee-rule.mjs
  *  4  carrier/selection.mjs + fee/discount.mjs
  *  3  zone/factor.mjs + zone/postal-mapping.mjs
  *  3  route/route-building.mjs + route/transfer-points.mjs
  *  3  delivery/status-flow.mjs + delivery/timestamp.mjs
  *  3  carrier/carrier-list.mjs + route/route-building.mjs
     2  carrier/selection.mjs + fee/fee-rule.mjs
     2  carrier/carrier-list.mjs + route/transfer-points.mjs
     1  fee/fee-rule.mjs + shared/money.mjs

cluster count = 5
  fee/tariff-table.mjs, zone/postal-mapping.mjs, zone/factor.mjs
  fee/fee-rule.mjs, fee/discount.mjs, carrier/selection.mjs
  carrier/carrier-list.mjs, route/transfer-points.mjs, route/route-building.mjs
  delivery/status-flow.mjs, delivery/timestamp.mjs
  shared/money.mjs
```

Three of the five clusters cross directory boundaries. The highest count, seven, is between the
fee table and the zone factor: when the price list is refreshed, the two get refreshed
together, even though they sit in separate directories. Discount and carrier selection also
changed together four times; the discount rate affects the carrier ranking, so the two are a
single commercial decision.

The threshold being three is a decision, not data. Two files changing together once could be a
coincidence; changing together three times points to a reason. Raise the threshold and the
cluster count rises; lower it and the clusters stick together.

## Measuring the Three Splits

Now three splits are compared with the same measures: the split by directory, the split by
co-change, and the split gathering everything into a single component. There are four
measures — component count, inter-component edge count, the number of components a single
change spreads across, and the number of files a consumer needing the route-building
capability has to bind to.

The last measure is found by following the edges leaving the route component in the component
graph one after another: whoever binds to a component also ends up bound to the components that
component binds to.

```js
// component-measure.mjs — compares three splits with the same four measures
import { FILES, IMPORTS, LOG } from "./library.mjs";

const directorySplit = Object.fromEntries(FILES.map((d) => [d, d.split("/")[0]]));
const CLUSTERS = [
  ["tariff", ["fee/tariff-table.mjs", "zone/postal-mapping.mjs", "zone/factor.mjs"]],
  ["commercial-rule", ["fee/fee-rule.mjs", "fee/discount.mjs", "carrier/selection.mjs"]],
  ["network", ["carrier/carrier-list.mjs", "route/transfer-points.mjs", "route/route-building.mjs"]],
  ["tracking", ["delivery/status-flow.mjs", "delivery/timestamp.mjs"]],
  ["money", ["shared/money.mjs"]],
];
const coChangeSplit = Object.fromEntries(
  CLUSTERS.flatMap(([name, members]) => members.map((d) => [d, name])));
const singleSplit = Object.fromEntries(FILES.map((d) => [d, "library"]));

function closure(split, start) {
  const edges = new Set(IMPORTS
    .filter(([a, b]) => split[a] !== split[b])
    .map(([a, b]) => `${split[a]} -> ${split[b]}`));
  const seen = new Set([split[start]]);
  for (const s of seen) {
    for (const e of edges) {
      const [left, right] = e.split(" -> ");
      if (left === s) seen.add(right);
    }
  }
  return { edgeCount: edges.size, fileCount: FILES.filter((d) => seen.has(split[d])).length };
}

for (const [name, split] of [["directory", directorySplit], ["co-change", coChangeSplit], ["single", singleSplit]]) {
  const components = new Set(Object.values(split));
  const touched = LOG.map((d) => new Set(d.map((f) => split[f])).size);
  const republishes = touched.reduce((a, b) => a + b, 0);
  const spread = touched.filter((n) => n > 1).length;
  const { edgeCount, fileCount } = closure(split, "route/route-building.mjs");
  console.log(`${name.padEnd(11)} components ${components.size}  inter-component edges ${edgeCount}`);
  console.log(`            changes touching more than one component ${spread} / ${LOG.length}`);
  console.log(`            total republishes ${republishes}  average ${(republishes / LOG.length).toFixed(2)}`);
  console.log(`            files bound to build a route ${fileCount} / ${FILES.length}`);
}
```

```
directory   components 6  inter-component edges 5
            changes touching more than one component 16 / 22
            total republishes 38  average 1.73
            files bound to build a route 10 / 12
co-change   components 5  inter-component edges 5
            changes touching more than one component 1 / 22
            total republishes 23  average 1.05
            files bound to build a route 3 / 12
single      components 1  inter-component edges 0
            changes touching more than one component 0 / 22
            total republishes 22  average 1.00
            files bound to build a route 12 / 12
```

Under the directory split, sixteen of twenty-two changes spread across more than one component;
under the co-change split, one does. Total republishes drop from 38 to 23. The two splits'
component counts and inter-component edge counts are nearly the same — the gain does not come
from the component count, it comes from where the boundary passes.

The route-building measure is sharper. Under the directory split, the route component is bound
to the carrier component, the carrier component's selection file is bound to fee, fee to zone
and to money; as a result, a consumer wanting only to build a route ends up bound to ten of the
twelve files. Under the co-change split, the carrier list sits in the same component as the
route files, so this chain does not even get built: three files are enough.

## The Tension Between the Three Principles

The third row shows the principles pushing against each other. The single-component split is
flawless on the common closure measure — no change spreads across more than one component,
because there is only one component. The same split gives the worst result on the common reuse
measure: a party needing just one capability ends up bound to all twelve files.

The tension runs in three directions. Common closure wants to **grow** components, because
everything that changes together should be inside. Common reuse wants to **shrink** them,
because nothing unused should be inside. Reuse/release equivalence puts a floor under both:
whatever the split is, a set too entangled to be released separately does not count as a
component.

The three cannot be maximized at once. Which one dominates depends on what stage the library is
at: if the component is not yet published externally, what makes development hard is changes
spreading, and common closure takes the lead; if the component is published to many consumers,
unneeded dependencies get expensive, and common reuse takes the lead.

## The Limit of the Measure

Co-change gives a signal, it does not make the decision. It misleads in three situations.

First, changes touching every file — a mass rename or a formatting fix raises every pair's
count at once and glues the clusters together. Such changes have to be filtered out of the log
before the measurement means anything.

Second, files with little history: if none of a file's pairs cross the threshold, it stays
alone in its own cluster. In the output above, `shared/money.mjs` is in this state — it changed
together with the fee rule only once and stayed in its own cluster. The result is correct here,
because currency handling really does change for a separate reason; but the same pattern comes
out for every newly added file too, and there it carries no information.

Third, the log narrates the past, not the future. The library opening into a new domain redraws
the clusters. The component boundary is not something drawn once and left alone; it is
something recomputed as the log grows.

## Summary

- A component is a set of files packaged and released together; the unit of reuse equals the
  unit of release.
- The common closure principle gathers what changes together into the same component, the
  common reuse principle separates what is not used together; the two pull in opposite
  directions.
- Once the co-change counts of twenty-two changes were computed, five clusters came out and
  three crossed directory boundaries; the highest count was between the fee table and the zone
  factor.
- Under the directory split, 16 of 22 changes spread across more than one component; under the
  co-change split, 1 did; total republishes dropped from 38 to 23.
- The number of files bound to build a route dropped from 10 to 3; under the split gathering
  everything into one component, the same number was 12, and the tension between the two
  principles became visible.

## Next Step

This lesson's measures looked **inside** components: which files should stand together. The
component graph's edges were counted, but their direction was not questioned. Is there a
criterion for which direction an edge should point? A stable component bound to a frequently
changing one loses its own stability. The next lesson computes a stability measure for each
component from its incoming and outgoing link counts, counts whether links flow in the stable
direction, and shows why a cycle between components in the same graph makes a release order
impossible.
