Skip to content
academia.sh

Lesson 17 / 19

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.

Contents

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.

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

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close