Skip to content
academia.sh

Lesson 06 / 10

Conway's Law

Measuring how communication structure maps onto module structure: overlaying two separate team splits on the same module graph, counting teams per module and the module bonds that cross the team boundary, the distribution of those bonds by communication distance, finding the bonds that stand between teams with no communication path, and the handoff, waiting, and rework counts the same set of work items produces under each split.

Contents

The previous topic chose the shape of the process, bounded the flow, and shortened the defect detection delay. All of those measurements shared one silent input: who the work passed from and to was taken as given data, and where that boundary came from was never asked. This lesson asks that question.

Conway’s law is the term for the claim that a resemblance holds between the communication structure of the organization producing a system and the structure of the system it produces. The term alone gives no measure; the measure is built by laying two graphs side by side — the module graph (which module depends on which module) and the communication graph (which team has a standing channel with which team).

A graph of this same kind was read before for other purposes: a layering rule was checked, boundary violations were counted. That is not the count here. What gets overlaid on the graph is not a rule but a team boundary. The through-line is the regional library network and it is fictional.

Two Graphs, One Codebase

TD1 — the codebase consists of seventeen modules and forty-three dependency bonds. Rationale: overlap only carries meaning in a graph where the bond count is far larger than the team count.

TD2 — the component-aligned split has five teams, each owning one technical component, and four standing channels. TD3 — the stream-aligned split has six teams, each owning one service stream, and nine channels. Rationale: the two splits distribute the same seventeen modules, so the module graph is bit-for-bit identical in both runs; the only thing that changes is where the boundary falls.

TD4 — the workload is twelve items; each item lists its modules in the order the work follows, and the first module is where the request lands. TD5 — a module’s “contributors” is the union of its owner and the teams that open the items touching that module. Rationale: in the organization, code is open to everyone; the team that opens an item writes a change in another team’s module when it must, and asks the owner for a review.

The block below is a model; it was not read from an actual codebase.

// build.mjs — the module graph and the communication graph are laid side by side, overlap measured (model)
import { writeFileSync } from "node:fs";

// TD1 — 17 modules; an "a: b c" line says module a imports modules b and c
const IMPORTS = {
  branchFront: "loanFlow feeFlow reservationFlow sharedFormat",
  staffFront: "membershipFlow loanFlow feeFlow sharedFormat",
  mobileAccess: "loanFlow reservationFlow catalogBridge sharedFormat",
  loanFlow: "loanRule catalogRule storeAccess notificationQueue sharedFormat",
  feeFlow: "feeRule storeAccess notificationQueue sharedFormat",
  membershipFlow: "membershipRule identityBridge storeAccess sharedFormat",
  reservationFlow: "loanRule catalogBridge storeAccess notificationQueue sharedFormat",
  loanRule: "sharedFormat", feeRule: "loanRule sharedFormat",
  catalogRule: "sharedFormat", membershipRule: "sharedFormat",
  storeAccess: "eventLog sharedFormat", catalogBridge: "catalogRule sharedFormat",
  identityBridge: "sharedFormat", notificationQueue: "eventLog sharedFormat",
  eventLog: "sharedFormat", sharedFormat: "",
};
const M = Object.keys(IMPORTS);
const EDGES = Object.entries(IMPORTS)
  .flatMap(([a, s]) => (s ? s.split(" ").map((b) => [a, b]) : []));

// TD2 — component-aligned split: five teams, each owns one technical component
const A = { name: "A (component-aligned)", teams: {
  interface: "branchFront staffFront mobileAccess",
  flow: "loanFlow feeFlow membershipFlow reservationFlow",
  rule: "loanRule feeRule catalogRule membershipRule",
  infra: "storeAccess catalogBridge identityBridge notificationQueue eventLog",
  shared: "sharedFormat" },
  channels: "interface-flow flow-rule flow-infra rule-infra" };

// TD3 — stream-aligned split: six teams, each owns one service stream
const B = { name: "B (stream-aligned)", teams: {
  experience: "branchFront staffFront mobileAccess",
  loan: "loanFlow loanRule reservationFlow storeAccess",
  fee: "feeFlow feeRule",
  membership: "membershipFlow membershipRule identityBridge",
  catalog: "catalogRule catalogBridge",
  platform: "sharedFormat eventLog notificationQueue" },
  channels: "experience-loan experience-fee experience-membership loan-fee loan-catalog " +
    "loan-platform fee-platform membership-platform catalog-platform" };

// TD4 — twelve work items; modules listed in the order the work follows, the first is where the request lands
const ITEMS = [
  ["late fee rate", "feeFlow feeRule branchFront sharedFormat"],
  ["reservation cancellation", "mobileAccess reservationFlow loanRule notificationQueue"],
  ["membership reminder", "membershipFlow membershipRule notificationQueue staffFront"],
  ["catalog record field", "catalogBridge catalogRule sharedFormat mobileAccess"],
  ["loan period extension", "loanFlow loanRule branchFront"],
  ["second identity step", "identityBridge membershipFlow staffFront sharedFormat"],
  ["fee refund record", "feeFlow storeAccess eventLog"],
  ["branch delay report", "staffFront storeAccess loanFlow"],
  ["notification text format", "notificationQueue sharedFormat"],
  ["reservation queue", "mobileAccess reservationFlow branchFront"],
  ["loan rule exception", "loanRule feeRule loanFlow feeFlow"],
  ["event log field", "eventLog sharedFormat storeAccess"],
];

function build({ name, teams, channels }) {
  const owner = {}, T = Object.keys(teams), adj = Object.fromEntries(T.map((e) => [e, []]));
  for (const [e, ms] of Object.entries(teams)) for (const m of ms.split(" ")) owner[m] = e;
  for (const k of channels.split(" ")) {
    const [x, y] = k.split("-");
    adj[x].push(y); adj[y].push(x);
  }
  const d = {};                                  // shortest path in the communication graph; -1 if no path
  for (const s of T) {
    d[s] = Object.fromEntries(T.map((e) => [e, -1]));
    d[s][s] = 0;
    for (let q = [s]; q.length; ) {
      const u = q.shift();
      for (const v of adj[u]) if (d[s][v] < 0) { d[s][v] = d[s][u] + 1; q.push(v); }
    }
  }
  // TD5 — code is open to everyone: the team that opens an item modifies another team's module when it must
  const contributors = Object.fromEntries(M.map((m) => [m, new Set([owner[m]])]));
  for (const [, ms] of ITEMS) for (const m of ms.split(" ")) contributors[m].add(owner[ms.split(" ")[0]]);
  return { name, teams, owner, d, contributors: Object.fromEntries(M.map((m) => [m, [...contributors[m]]])) };
}

const D = [build(A), build(B)];
writeFileSync("network.json", JSON.stringify({ M, EDGES, ITEMS, D }));

const measure = (s) => {
  const outside = EDGES.filter(([a, b]) => s.owner[a] !== s.owner[b]);
  const far = (f) => outside.filter(([a, b]) => f(s.d[s.owner[a]][s.owner[b]])).length;
  const at = (n) => M.filter((m) => s.contributors[m].length >= n).length;
  return {
    "module bonds crossing the team boundary": `${outside.length}/${EDGES.length}`,
    "  between teams at communication distance 1": far((u) => u === 1),
    "  between teams at communication distance 2": far((u) => u === 2),
    "  between teams with NO COMMUNICATION PATH": far((u) => u < 0),
    "module changed by more than one team": at(2),
    "  module changed by three or more teams": at(3),
    "average teams per module":
      (M.reduce((t, m) => t + s.contributors[m].length, 0) / M.length).toFixed(2),
  };
};

const O = D.map(measure);
console.log(`${M.length} modules, ${EDGES.length} bonds, ${ITEMS.length} work items\n`);
console.log(`${"measure".padEnd(42)}${D.map((s) => s.name.padStart(22)).join("")}`);
for (const k of Object.keys(O[0]))
  console.log(`${k.padEnd(42)}${O.map((o) => String(o[k]).padStart(22)).join("")}`);
for (const s of D) {
  const c = {};
  for (const [a, b] of EDGES)
    if (s.d[s.owner[a]][s.owner[b]] < 0) {
      const k = `${s.owner[a]}->${s.owner[b]}`;
      c[k] = (c[k] ?? 0) + 1;
    }
  console.log(`\n${s.name} — changed by three or more teams: ` +
    M.filter((m) => s.contributors[m].length >= 3).map((m) => `${m}(${s.contributors[m].length})`).join(", "));
  console.log(`  module bond standing between teams with no communication path: ` +
    (Object.keys(c).length ? Object.entries(c).map(([k, n]) => `${k} ${n}`).join(", ") : "none"));
}
17 modules, 43 bonds, 12 work items

measure                                    A (component-aligned)    B (stream-aligned)
module bonds crossing the team boundary                    40/43                 32/43
  between teams at communication distance 1                    23                    27
  between teams at communication distance 2                     1                     5
  between teams with NO COMMUNICATION PATH                    16                     0
module changed by more than one team                          15                    12
  module changed by three or more teams                        6                     4
average teams per module                                    2.24                  2.06

A (component-aligned) — changed by three or more teams: staffFront(3), loanFlow(3), loanRule(3), storeAccess(3), notificationQueue(3), sharedFormat(3)
  module bond standing between teams with no communication path: interface->shared 3, flow->shared 4, rule->shared 4, infra->shared 5

B (stream-aligned) — changed by three or more teams: branchFront(3), storeAccess(4), notificationQueue(3), sharedFormat(4)
  module bond standing between teams with no communication path: none

Where the Overlap Breaks Down

The sharpest row in the table sits in the middle. In the component-aligned split, sixteen module bonds stand between two teams that have no connection at all in the communication graph — more than a third of the forty-three bonds; in the stream-aligned split the number is zero.

The last output lines name where these sixteen bonds come from: all of them sit between the shared team and the other four teams. shared owns a single module — sharedFormat — and has no communication channel at all; requests reach it through a written queue. In the module graph, though, sharedFormat is imported by fifteen modules. The codebase’s most depended-on module sits in the hands of the team nobody talks to. When the two graphs do not overlap, the module graph wins: the dependency keeps standing in the code, and the missing channel shows itself as waiting for every item that crosses that bond.

The stream-aligned split brings this number to zero, but grows another one. In split A no module exceeds three teams; in B, storeAccess and sharedFormat are changed by four teams, because stream-aligned teams reach directly into shared modules to finish their items end to end. B’s average is better (2.06 against 2.24), but its worst module is worse.

The Same Work’s Flow Under Two Splits

To see the cost, the same twelve items are run through both splits.

TD6 — an item works one round for every module it touches; each handoff between two teams waits 1 round if the communication distance is 1, 3 if the distance is 2, 5 if the distance is 3, and 8 if there is no path. Rationale: two teams with a direct channel speak in the same round; every step that falls to an intermediary adds one more queue’s wait.

TD7 — three rework reasons are counted: late (a late-learned constraint) if any handoff has a distance greater than 1, bound (a wrong boundary) if the item touches a module changed by three or more teams, info (missing information) if the item spreads across four or more teams; each rework adds 2 rounds of work and charges the longest wait once more. Rationale: the three reasons point to three separate structural flaws — a missing channel, unclear ownership, spreading scope.

// flow.mjs — the same twelve work items are run through both splits: handoffs, waiting, rework
import { readFileSync } from "node:fs";
const { ITEMS, D } = JSON.parse(readFileSync("network.json", "utf8"));

const WAIT = [0, 1, 3, 5], NONE = 8;       // TD6: wait rounds by communication distance
const run = (s) => ITEMS.map(([name, mods]) => {
  const ms = mods.split(" ");
  const order = [...new Set(ms.map((m) => s.owner[m]))];   // teams, in the order the work follows
  let wait = 0, longest = 0, broken = 0;
  for (let j = 1; j < order.length; j++) {
    const u = s.d[order[j - 1]][order[j]], b = u < 0 ? NONE : WAIT[u];
    wait += b; longest = Math.max(longest, b);
    if (u < 0 || u >= 2) broken += 1;
  }
  const reasons = [];                                              // TD7: three rework reasons
  if (broken) reasons.push("late");                                // late-learned constraint
  if (ms.some((m) => s.contributors[m].length >= 3)) reasons.push("bound");  // wrong boundary
  if (order.length >= 4) reasons.push("info");                     // missing information
  const work = ms.length + reasons.length * 2;      // each rework adds 2 rounds of work
  const waitT = wait + reasons.length * longest;     // and charges the longest wait once more
  return { name, team: order.length, hnd: order.length - 1, work, wait: waitT, flow: work + waitT, reasons };
});

const R = D.map((s) => ({ name: s.name, k: run(s) }));
const HEAD = ["team", "hnd", "wait", "flow"];
const cell = (r) => [r.team, r.hnd, r.wait, r.flow].map((x) => String(x).padStart(6)).join("") +
  `  ${(r.reasons.join("+") || "-").padEnd(17)}`;
console.log(`${"work item".padEnd(28)}${R.map((r) => r.name.padEnd(43)).join("")}`.trimEnd());
console.log(`${"".padEnd(28)}${R.map(() =>
  HEAD.map((h) => h.padStart(6)).join("") + `  ${"reason".padEnd(17)}`).join("")}`.trimEnd());
for (let i = 0; i < ITEMS.length; i++)
  console.log(`${R[0].k[i].name.padEnd(28)}${R.map((r) => cell(r.k[i])).join("")}`.trimEnd());

const summary = (r) => {
  const T = (f) => r.k.reduce((t, x) => t + f(x), 0);
  const n = (c) => r.k.filter((x) => x.reasons.includes(c)).length;
  return {
    "handoffs": T((x) => x.hnd), "wait rounds": T((x) => x.wait),
    "flow time (rounds)": T((x) => x.flow),
    "wait share of flow time": `${(100 * T((x) => x.wait) / T((x) => x.flow)).toFixed(1)}%`,
    "rework (items / total)":
      `${r.k.filter((x) => x.reasons.length).length} / ${T((x) => x.reasons.length)}`,
    "  reason: late-learned constraint": n("late"),
    "  reason: wrong boundary": n("bound"), "  reason: missing information": n("info"),
  };
};

const Z = R.map(summary);
console.log(`\n${"total".padEnd(40)}${R.map((r) => r.name.padStart(22)).join("")}`);
for (const k of Object.keys(Z[0]))
  console.log(`${k.padEnd(40)}${Z.map((z) => String(z[k]).padStart(22)).join("")}`);
work item                   A (component-aligned)                      B (stream-aligned)
                              team   hnd  wait  flow  reason             team   hnd  wait  flow  reason
late fee rate                    4     3    36    46  late+bound+info       3     2    10    18  late+bound
reservation cancellation         4     3     5    13  bound+info            3     2     3     9  bound
membership reminder              4     3    14    24  late+bound+info       3     2    10    18  late+bound
catalog record field             4     3    41    51  late+bound+info       3     2    10    18  late+bound
loan period extension            3     2    10    17  late+bound            2     1     2     7  bound
second identity step             4     3    34    44  late+bound+info       3     2    10    18  late+bound
fee refund record                2     1     2     7  bound                 3     2     3     8  bound
branch delay report              3     2    10    17  late+bound            2     1     2     7  bound
notification text format         2     1    24    30  late+bound            1     0     0     4  bound
reservation queue                2     1     1     4  -                     2     1     2     7  bound
loan rule exception              2     1     2     8  bound                 2     1     1     5  -
event log field                  2     1    24    31  late+bound            2     1     2     7  bound

total                                    A (component-aligned)    B (stream-aligned)
handoffs                                                    24                    17
wait rounds                                                203                    55
flow time (rounds)                                         292                   126
wait share of flow time                                  69.5%                 43.7%
rework (items / total)                                 11 / 24               11 / 15
  reason: late-learned constraint                            8                     4
  reason: wrong boundary                                    11                    11
  reason: missing information                                5                     0

What Did Not Change When the Split Changed

In the totals table, three numbers drop sharply and one number does not move at all.

The drops: handoffs fall from 24 to 17, waiting from 203 rounds to 55 rounds, flow time from 292 to 126; the wait share drops from 69.5% to 43.7%. What matters is the disproportion: handoffs fall only 29% while waiting falls 73%. Most of the cost is not in the number of handoffs but in the distance to the team being handed off to. In split A, “notification text format” touches only two teams and contains a single handoff, but because that handoff goes to the shared team, the item reaches thirty rounds — more than double “reservation cancellation,” which spreads across four teams.

The number that does not move is the bound reason: in both splits, eleven items enter rework for this reason. In A, six modules are changed by three teams; in B that number drops to four, but two modules climb to four teams, and those two (storeAccess, sharedFormat) show up in most of the items. Moving the team boundary does not remove the shared module; it only changes who is waiting. By contrast, the info reason drops from 5 to 0, and late falls from 8 to 4 but does not reach zero; the remaining four come from the two-step distance between platform and experience.

The two observations together are this lesson’s conclusion. Changing the communication structure can shorten flow time by more than half without touching the module graph at all; but a shared dependency cannot be resolved through communication structure alone. There are two directions for building overlap, and a single split decision uses only one of them.

Summary

  • Two splits were overlaid on the same 17-module, 43-bond graph: component-aligned with 5 teams (4 channels) and stream-aligned with 6 teams (9 channels); the module graph did not change between the two runs.
  • In the component-aligned split, 40/43 bonds cross the team boundary, and 16 of those stand between teams with no path at all in the communication graph; in the stream-aligned split the bonds crossing the boundary drop to 32, and no team pair is left without a path.
  • All sixteen disconnected bonds sit around a single module: sharedFormat, imported by 15 modules, sits in the hands of a one-module team with no channel at all.
  • When the same 12 work items are run through both splits, handoffs fall from 24 to 17, waiting from 203 rounds to 55 rounds, flow time from 292 to 126; the wait share drops from 69.5% to 43.7%.
  • Waiting falls 73%, handoffs only 29%: the cost is not in the number of handoffs but in the counterpart team’s distance in the communication graph.
  • The bound rework reason shows up in 11 items in both splits; the stream-aligned split raises the most shared module from 3 teams to 4. Contention over a shared module cannot be removed just by moving the team boundary.

Next Step

This lesson’s second split was called “stream-aligned,” but it was used with only a single property: each team owns one service stream. The numbers showed this property shortening flow time by more than half — and left one thing entirely unmeasured: how much load those teams carry. The next lesson counts that load: the same set of work items is run through three separate team arrangements, and cognitive load per team — the number of modules it owns and the number of external interfaces it depends on — is added alongside the handoff, waiting, and rework counts. Where the stream-aligned arrangement wins was seen in this lesson’s table; where it loses will be seen there.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close