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

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

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.

```sh
mkdir -p combined split
```

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

```js
// 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)}`);
}
```

```sh
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.

```js
// 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);
}
```

```js
// 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);
}
```

```js
// 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}`;
}
```

```sh
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.

```js
// 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"));
```

```sh
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.

```js
// 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}`);
}
```

```sh
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.
