Lesson 06 / 19
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.
Contents
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.
mkdir -p common stamp data
// common/settings.mjs — shared state that every module reads and writes export const SETTING = { zone: 100, vat: 20, _last: 0 };
// 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; }
// 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.
// 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); }
// 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.
// 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);
// 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.
// 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}`);
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.
// 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");
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.