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