Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close