---
title: 'Types of Coupling'
source: 'https://academia.sh/en/courses/design-principles/types-of-coupling'
course: 'Design Principles'
language: en
updated: '2026-08-23T07:01:17+00:00'
license: 'CC BY-SA 4.0'
---

# Types of Coupling

Counting the strength of the bond between two modules: writing a script that measures the number of shared names, the number of parameters passed, and the state modules change in common, then comparing the measurements across three designs that produce the same invoice line.

The SOLID principles said who a unit should depend on, but they did not measure the bond
itself. The difference between "loosely coupled" and "tightly coupled" should be countable,
not read off a design's tone. This topic establishes two measures; the first belongs to the
bond **between** modules.

**Coupling** is the strength of the bond between two modules. That strength has three
sources: the **number of names** one module takes from another, the **number of parameters**
passed in calls, and the **state the two modules change in common**. The types of coupling
fall out of these three measures combined, and the classic ranking runs from strongest to
weakest: content, common, control, stamp, data.

| Type | Symptom |
|---|---|
| Content coupling | One module reaches into another's internal name |
| Common coupling | Two modules share the same mutable state |
| Control coupling | A call passes a flag that tells the callee which branch to take |
| Stamp coupling | A whole record is passed, including fields that go unused |
| Data coupling | Only the number or string actually needed is passed |

Strong coupling is limited not because it is bad, but because it **enlarges the surface that
spreads a change**. The three designs below produce the same invoice line; the only
difference is the width of that surface.

## Same Output, Three Designs

The first design works through a shared settings object. The calc module both returns the
result and writes it to shared state; the invoice module reads the result from there and
from the calc module's internal name.

```sh
mkdir -p common stamp data
```

```js
// common/settings.mjs — shared state that every module reads and writes
export const SETTING = { zone: 100, vat: 20, _last: 0 };
```

```js
// common/calc.mjs — returns the result, but writes to shared state first
import { SETTING } from "./settings.mjs";

export const _TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export function calculate(shipment) {
  SETTING.zone = ZONE[shipment.address.slice(0, 2)] ?? 160;
  const base = _TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 15000;
  SETTING._last = Math.round((base * SETTING.zone * (100 + SETTING.vat)) / 10000);
  return SETTING._last;
}
```

```js
// common/invoice.mjs — reads the result from shared state and the calc module's internal name
import { SETTING } from "./settings.mjs";
import { calculate, _TIER } from "./calc.mjs";

export function line(shipment) {
  calculate(shipment);
  return `${SETTING._last} cents (${_TIER.length} tiers)`;
}
```

The second design has no shared state. Instead, the whole shipment record is passed, along
with a flag that tells calc which branch to take.

```js
// stamp/calc.mjs — no shared state; the whole record and a flag are passed
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export const tierCount = () => TIER.length;

export function calculate(shipment, addVat) {
  const zone = ZONE[shipment.address.slice(0, 2)] ?? 160;
  const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 15000;
  const raw = (base * zone) / 100;
  return Math.round(addVat ? (raw * 120) / 100 : raw);
}
```

```js
// stamp/invoice.mjs — gives calc the whole shipment and the flag that picks the behavior
import { calculate, tierCount } from "./calc.mjs";

export const line = (shipment) => `${calculate(shipment, true)} cents (${tierCount()} tiers)`;
```

In the third design, the calculation steps are split into separate functions, and each one
takes only the number it needs. The flag turns into two separate function names.

```js
// data/calc.mjs — each step takes only the numbers it needs
const TIER = [[1, 3900], [5, 6400], [20, 11800]];

export const tierCount = () => TIER.length;
export const base = (weight) => TIER.find(([max]) => weight <= max)?.[1] ?? 15000;
export const zoned = (cents, factor) => (cents * factor) / 100;
export const withVat = (cents) => Math.round((cents * 120) / 100);
```

```js
// data/invoice.mjs — passes calc only numbers, no flag
import { base, zoned, withVat, tierCount } from "./calc.mjs";

const ZONE = { "34": 100, "06": 115, "65": 140 };

export function line(shipment) {
  const factor = ZONE[shipment.address.slice(0, 2)] ?? 160;
  return `${withVat(zoned(base(shipment.weight), factor))} cents (${tierCount()} tiers)`;
}
```

## The Measurer

The script below reads the modules in a directory, produces one edge per import statement,
and computes that edge's three measures: the number of names taken, the highest number of
parameters passed in calls, and the number of writes made to an imported name. It derives
the type from these measures; if an edge carries more than one type, the strongest one
becomes the label.

```js
// coupling-measure.mjs — the three measures and type of the bond between two modules
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const IMPORT = /import\s*\{([^}]*)\}\s*from\s*["']\.\/([^"']+)["']/g;
// Objects passed whole, as records. If one of these is an argument in a call, it is stamp coupling.
const RECORDS = ["shipment", "tariff", "customer"];

function callArguments(text, name) {
  const calls = [];
  for (const m of text.matchAll(new RegExp(`\\b${name}\\s*\\(`, "g"))) {
    let i = m.index + m[0].length;
    let depth = 1;
    let piece = "";
    const pieces = [];
    while (i < text.length && depth > 0) {
      const c = text[i];
      if ("([{".includes(c)) depth += 1;
      else if (")]}".includes(c)) depth -= 1;
      if (depth === 0) break;
      if (c === "," && depth === 1) { pieces.push(piece.trim()); piece = ""; } else piece += c;
      i += 1;
    }
    if (piece.trim() !== "") pieces.push(piece.trim());
    calls.push(pieces);
  }
  return calls;
}

const root = process.argv[2];
const files = readdirSync(root).filter((a) => a.endsWith(".mjs")).sort();
const texts = new Map(files.map((d) => [d, readFileSync(join(root, d), "utf8")]));

// First collect which imported names are written to across the whole tree: a written name is shared state.
const written = new Set();
for (const [, text] of texts) {
  for (const m of text.matchAll(/\b([A-Za-z_]\w*)\.\w+\s*=[^=]/g)) written.add(m[1]);
}

let totalNames = 0;
let totalParams = 0;
let totalWrites = 0;

for (const d of files) {
  const text = texts.get(d);
  for (const m of text.matchAll(IMPORT)) {
    const names = m[1].split(",").map((s) => s.trim()).filter(Boolean);
    const target = m[2];
    let params = 0;
    let writes = 0;
    const types = new Set();
    for (const name of names) {
      const calls = callArguments(text, name);
      const args = calls.flat();
      params += calls.length === 0 ? 0 : Math.max(...calls.map((c) => c.length));
      writes += [...text.matchAll(new RegExp(`\\b${name}\\.\\w+\\s*=[^=]`, "g"))].length;
      if (name.startsWith("_")) types.add("content");
      if (written.has(name)) types.add("common");
      if (args.some((a) => a === "true" || a === "false")) types.add("control");
      if (args.some((a) => RECORDS.includes(a))) types.add("stamp");
    }
    if (types.size === 0) types.add("data");
    const order = ["content", "common", "control", "stamp", "data"];
    const strongest = order.find((t) => types.has(t));
    totalNames += names.length;
    totalParams += params;
    totalWrites += writes;
    console.log(`  ${d} -> ${target}`.padEnd(30) +
      `names=${names.length} params=${params} writes=${writes}  type=${strongest}` +
      (types.size > 1 ? ` (${[...order.filter((t) => types.has(t))].join(", ")})` : ""));
  }
}
console.log(`${root.padEnd(6)} total: names=${totalNames} params=${totalParams} writes=${totalWrites}`);
```

```sh
node coupling-measure.mjs common
node coupling-measure.mjs stamp
node coupling-measure.mjs data
```

```
  calc.mjs -> settings.mjs    names=1 params=0 writes=2  type=common
  invoice.mjs -> settings.mjs names=1 params=0 writes=0  type=common
  invoice.mjs -> calc.mjs     names=2 params=1 writes=0  type=content (content, stamp)
common total: names=4 params=1 writes=2
  invoice.mjs -> calc.mjs     names=2 params=2 writes=0  type=control (control, stamp)
stamp  total: names=2 params=2 writes=0
  invoice.mjs -> calc.mjs     names=4 params=4 writes=0  type=data
data   total: names=4 params=4 writes=0
```

All three designs produce the same line, but their measures differ. The first design has
three edges, two of which look at shared state, and two writes were counted. The second
design is left with a single edge; the name and parameter counts are low, but its type is
control coupling. The third design has zero writes and its type is data coupling, yet its
name count rises to four and its parameter count also rises to four.

The last line reveals an important side of the measure: **type and magnitude are separate
things**. The third design has the weakest coupling type, yet it is also the design that
shares the most names. Reducing coupling does not mean "pass fewer names"; it means changing
what gets passed.

## The Spread of a Change

The measure's real-world counterpart shows up in a touch that comes from an unrelated place.
The script below prints all three designs' lines, then touches only one field of the shared
settings object and prints the lines again.

```js
// remote-touch.mjs — how a single line in an unrelated module affects three designs
import { SETTING } from "./common/settings.mjs";
import { line as commonLine } from "./common/invoice.mjs";
import { line as stampLine } from "./stamp/invoice.mjs";
import { line as dataLine } from "./data/invoice.mjs";

const SHIPMENT = { weight: 3.0, address: "06500" };
const report = (label) =>
  console.log(`${label.padEnd(8)} common=${commonLine(SHIPMENT)}  stamp=${stampLine(SHIPMENT)}  data=${dataLine(SHIPMENT)}`);

report("before");
SETTING.vat = 0;
report("after");
```

```sh
node remote-touch.mjs
```

```
before   common=8832 cents (3 tiers)  stamp=8832 cents (3 tiers)  data=8832 cents (3 tiers)
after    common=7360 cents (3 tiers)  stamp=8832 cents (3 tiers)  data=8832 cents (3 tiers)
```

Nothing touched the calc module, the invoice module, or the tier table. The only thing that
changed was one field of the shared object, and the first design's output dropped from 8832
to 7360. This is the measurable definition of common coupling: a module's behavior can be
changed by a module that never imported it at all.

## Concrete Symptoms of the Types

The symptom of control coupling is a flag parameter. Anyone reading the call
`calculate(shipment, true)` has to ask what `true` means; the answer sits in the callee's
body. Once the flag is removed, the information moves into the call itself: `withVat(...)`
and `zoned(...)` are separate names, and which one gets called is readable at the call site.

The symptom of stamp coupling is that the callee uses only part of the record's fields.
`calculate(shipment, true)` uses the shipment's weight and address fields; the rest of the
record — value, delivery address, volume — enters the call and goes unused. This means every
new field added to the shipment record enlarges the calc module's input surface.

The symptom of content coupling is the naming convention: the name `_TIER` starts with an
underscore, which says it should not be exported, yet the invoice module imports it anyway.
The measurer caught this; when the tier table's shape changes, the invoice module will have
to change with it.

## Summary

- Coupling is the strength of the bond between two modules, and it is counted with three
  measures: the number of shared names, the number of parameters passed, and the state
  changed in common.
- The types run from strongest to weakest as content, common, control, stamp, and data
  coupling; an edge can carry more than one, and the label is the strongest.
- The three designs that produce the same invoice line measured, respectively, content,
  control, and data coupling; the write count came out to 2, 0, and 0.
- Type and magnitude are separate measures: the design with the weakest type was also the
  design that shared the most names (4) and the most parameters (4).
- A touch to a single field of the shared object changed only the commonly coupled design's
  output, from 8832 to 7360; the other two were unaffected.

## Next Step

The coupling measure counted the edges between modules and never looked inside a module. The
third design's calc module exports 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? The next lesson groups a
module's functions by the data they touch, splits a module with low relatedness, repeats the
measurement, and counts how the split affects the coupling measure.
