Skip to content
academia.sh

Lesson 07 / 19

Cohesion

Counting whether the parts inside a module belong together: clustering functions by the module-level names they touch, computing the component count against the number of function pairs that share nothing, splitting a low-cohesion module and repeating the measurement, and showing that the split produces no new coupling edge.

Contents

The coupling measure counted the edges between modules and never looked inside a module. A module might export four names; whether those four names belong together fell outside the measure. Do functions sitting in the same file touch shared data, or are they merely next to each other because they happen to share a file?

Cohesion is the degree to which the parts inside a module belong together, and it is coupling’s counterpart inside a module. Its measurable definition is this: when a module’s functions are clustered by the module-level names they touch, how many separate clusters do they fall into? If they fall into a single cluster, the module is about one thing; if they fall into several, the module is holding several separate things in the same file.

The Module to Measure

The module below holds three shipment-related jobs together: fee calculation, tracking number generation, and address formatting. Because all three can be called by the word “shipment,” keeping them in the same file looks reasonable.

mkdir -p combined split
// combined/shipment-service.mjs — everything shipment-related in a single module
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };
const PROVINCE_NAMES = { "34": "Istanbul", "06": "Ankara", "65": "Van" };
const PREFIX = "GN";
let COUNTER = 0;

export function base(weight) {
  return TIER.find(([max]) => weight <= max)?.[1] ?? 15000;
}

export function zoneFactor(address) {
  return ZONE[address.slice(0, 2)] ?? 160;
}

export function fee(shipment) {
  return Math.round((base(shipment.weight) * zoneFactor(shipment.address)) / 100);
}

export function trackingNumber() {
  COUNTER += 1;
  return `${PREFIX}-${String(COUNTER).padStart(6, "0")}`;
}

export function isTrackingValid(no) {
  return new RegExp(`^${PREFIX}-\\d{6}$`).test(no);
}

export function formatAddress(address) {
  return `${PROVINCE_NAMES[address.slice(0, 2)] ?? "unknown"} ${address}`;
}

The Clustering Measurer

The script extracts the module’s top-level names, finds which names appear in each function’s body, and puts two functions in the same cluster if they either touch a common name or one calls the other. It reports the resulting component count along with the number of function pairs that do and do not touch a shared name.

// cohesion-measure.mjs — clusters a module's functions by the names they touch
import { readFileSync } from "node:fs";

function body(text, open) {
  let i = open;
  let depth = 0;
  do {
    if (text[i] === "{") depth += 1;
    else if (text[i] === "}") depth -= 1;
    i += 1;
  } while (i < text.length && depth > 0);
  return text.slice(open, i);
}

function resolve(path) {
  const text = readFileSync(path, "utf8");
  const data = [...text.matchAll(/^(?:export\s+)?(?:const|let)\s+(\w+)/gm)].map((m) => m[1]);
  const functionNames = [...text.matchAll(/^(?:export\s+)?function\s+(\w+)/gm)].map((m) => m[1]);
  const all = [...data, ...functionNames];
  const functions = [];
  for (const m of text.matchAll(/^(?:export\s+)?function\s+(\w+)\s*\(/gm)) {
    const g = body(text, text.indexOf("{", m.index + m[0].length));
    const refs = all.filter((a) => a !== m[1] && new RegExp(`\\b${a}\\b`).test(g));
    functions.push({ name: m[1], refs: new Set(refs) });
  }
  return { data, functions };
}

const overlaps = (a, b) =>
  [...a.refs].some((x) => b.refs.has(x)) || a.refs.has(b.name) || b.refs.has(a.name);

for (const path of process.argv.slice(2)) {
  const { data, functions } = resolve(path);
  const component = new Map(functions.map((f, i) => [f.name, i]));
  let changed = true;
  while (changed) {
    changed = false;
    for (const a of functions) {
      for (const b of functions) {
        if (a.name !== b.name && overlaps(a, b) && component.get(a.name) !== component.get(b.name)) {
          const smallest = Math.min(component.get(a.name), component.get(b.name));
          component.set(a.name, smallest);
          component.set(b.name, smallest);
          changed = true;
        }
      }
    }
  }
  const clusters = new Map();
  for (const [name, k] of component) clusters.set(k, [...(clusters.get(k) ?? []), name]);

  let shared = 0;
  let unshared = 0;
  for (let i = 0; i < functions.length; i += 1) {
    for (let j = i + 1; j < functions.length; j += 1) {
      if (overlaps(functions[i], functions[j])) shared += 1; else unshared += 1;
    }
  }
  console.log(`${path}  functions=${functions.length} data=${data.length} components=${clusters.size}`);
  let n = 0;
  for (const [, names] of [...clusters].sort((a, b) => a[0] - b[0])) {
    n += 1;
    console.log(`  component ${n}: ${names.join(", ")}`);
  }
  console.log(`  shared pair=${shared} unshared pair=${unshared}` +
    ` lack of cohesion=${Math.max(0, unshared - shared)}`);
}
node cohesion-measure.mjs combined/shipment-service.mjs
combined/shipment-service.mjs  functions=6 data=5 components=3
  component 1: base, zoneFactor, fee
  component 2: trackingNumber, isTrackingValid
  component 3: formatAddress
  shared pair=3 unshared pair=12 lack of cohesion=9

Six functions fell into three separate clusters. Of the fifteen function pairs, only three touch a shared name; the remaining twelve have nothing in common. The lack-of-cohesion count is the difference between these two values, and it came out to nine. Notice that the parts look related only because the module is named “shipment service”; the measurement looks at the code, not the name.

Splitting and Re-measuring

The components give the split boundary directly. The three components are extracted into three modules; the function bodies do not change, only which file they live in.

// split/fee.mjs — first component: fee calculation
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export function base(weight) {
  return TIER.find(([max]) => weight <= max)?.[1] ?? 15000;
}

export function zoneFactor(address) {
  return ZONE[address.slice(0, 2)] ?? 160;
}

export function fee(shipment) {
  return Math.round((base(shipment.weight) * zoneFactor(shipment.address)) / 100);
}
// split/tracking.mjs — second component: tracking number
const PREFIX = "GN";
let COUNTER = 0;

export function trackingNumber() {
  COUNTER += 1;
  return `${PREFIX}-${String(COUNTER).padStart(6, "0")}`;
}

export function isTrackingValid(no) {
  return new RegExp(`^${PREFIX}-\\d{6}$`).test(no);
}
// split/address.mjs — third component: address formatting
const PROVINCE_NAMES = { "34": "Istanbul", "06": "Ankara", "65": "Van" };

export function formatAddress(address) {
  return `${PROVINCE_NAMES[address.slice(0, 2)] ?? "unknown"} ${address}`;
}
node cohesion-measure.mjs split/fee.mjs split/tracking.mjs split/address.mjs
grep -c "^import" split/fee.mjs split/tracking.mjs split/address.mjs || true
split/fee.mjs  functions=3 data=2 components=1
  component 1: base, zoneFactor, fee
  shared pair=2 unshared pair=1 lack of cohesion=0
split/tracking.mjs  functions=2 data=2 components=1
  component 1: trackingNumber, isTrackingValid
  shared pair=1 unshared pair=0 lack of cohesion=0
split/address.mjs  functions=1 data=1 components=1
  component 1: formatAddress
  shared pair=0 unshared pair=0 lack of cohesion=0
split/fee.mjs:0
split/tracking.mjs:0
split/address.mjs:0

In all three modules, the component count is one and lack of cohesion is zero. The last three lines count the cost of the split: the new modules do not import each other at all. Splitting a module with low cohesion did not produce coupling, because the parts being split never touched each other in the first place. Splitting only costs coupling when functions that genuinely touch shared data get separated.

Behavior did not change either.

// same-behavior.mjs — two layouts produce the same results
const SHIPMENT = { weight: 3.0, address: "06500" };
const c = await import("./combined/shipment-service.mjs");
const f = await import("./split/fee.mjs");
const t = await import("./split/tracking.mjs");
const a = await import("./split/address.mjs");

console.log("combined ->", c.fee(SHIPMENT), c.trackingNumber(), c.formatAddress(SHIPMENT.address), c.isTrackingValid("GN-000001"));
console.log("split ->", f.fee(SHIPMENT), t.trackingNumber(), a.formatAddress(SHIPMENT.address), t.isTrackingValid("GN-000001"));
node same-behavior.mjs
combined -> 7360 GN-000001 Ankara 06500 true
split -> 7360 GN-000001 Ankara 06500 true

The Practical Counterpart of Cohesion

So the component count does not stay abstract, a second measure is taken: the size of the file that must be read when a piece of data changes. The tracking number prefix and the zone factors are decisions made by different people; where each one lives is measurable.

// impact-area.mjs — the size of the file that must be read when a data name changes
import { readFileSync } from "node:fs";

const [name, ...files] = process.argv.slice(2);

for (const path of files) {
  const text = readFileSync(path, "utf8");
  if (!new RegExp(`^(?:const|let)\\s+${name}\\b`, "m").test(text)) continue;
  const functions = [...text.matchAll(/^(?:export\s+)?function\s+\w+/gm)].length;
  const lines = text.trim().split("\n").length;
  console.log(`${name.padEnd(10)} ${path.padEnd(30)} functions=${functions} lines=${lines}`);
}
for name in PREFIX ZONE; do
  node impact-area.mjs $name combined/shipment-service.mjs \
    split/fee.mjs split/tracking.mjs split/address.mjs
done
PREFIX     combined/shipment-service.mjs  functions=6 lines=31
PREFIX     split/tracking.mjs             functions=2 lines=12
ZONE       combined/shipment-service.mjs  functions=6 lines=31
ZONE       split/fee.mjs                  functions=3 lines=15

Changing the tracking prefix requires opening a 6-function, 31-line file in the combined layout, and a 2-function, 12-line file in the split layout. This is the real-world counterpart of cohesion: the reading surface of a change stays proportional to the change itself.

Misleading Cohesion Criteria

Similarity of name is not cohesion. All six functions under the name shipment-service were shipment-related, and the measurement still found three components. In the same way, reasons like “they are all helper functions” or “they all do validation” cannot be measured either; in these groupings the functions share no common data, they only do similar work.

There is also a mistake in the opposite direction: driving the component count down to one is not always right. Making every function in a module touch a single shared object drives the component count to one, but it produces the common coupling measured in the previous lesson. The two measures have to be read together: a component count of one inside the module, and a write count of zero between modules.

Summary

  • Cohesion is how many clusters a module’s functions fall into based on the names they touch; it is coupling’s counterpart inside a module.
  • In the combined module, 6 functions split into 3 components; 12 of the 15 function pairs touched no shared name at all, and lack of cohesion measured 9.
  • The components give the split boundary directly: once the three components were extracted into three modules, each module had a component count of 1 and lack of cohesion of 0.
  • The coupling cost of the split came out to zero: the new modules do not import each other at all, because the separated parts were never touching shared data in the first place.
  • The file that has to be read when a piece of data changes shrank from 6 functions and 31 lines to 2 functions and 12 lines.

Next Step

The cohesion measure looked at whether a module touches its own names. Once modules start passing objects to each other, a new question appears: when a function reaches through an object it received into a second object, and from there into a third field, how many separate types does that function have to know the shape of? A chain like shipment.recipient.address.province creates a dependency on the shape of every step in between, and that dependency shows up neither in the import graph nor in the cohesion measure. The next lesson writes a scan that counts chain depth and measures how the number of files touched by a field change drops once the chain gets shorter.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close