---
title: "Conway's Law"
source: 'https://academia.sh/en/courses/process-and-team/conway-law'
course: 'Process, Team and Delivery'
language: en
updated: '2026-08-23T07:01:07+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```js
// 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.

```js
// 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.
