Lesson 07 / 10
Team Topologies
Measuring stream-aligned team arrangements: the same module graph and the same set of work items are run through three team arrangements — component-aligned, stream-aligned, and hybrid; cognitive load per team, external demand, duplicated expertise, and the shared component turning into a bottleneck are added alongside the handoff, waiting, and rework counts.
Contents
The previous lesson compared two team splits on the same module graph and counted the stream-aligned split shortening flow time by more than half. One column was missing from that table: the load the teams carry. To finish an item end to end, a stream-aligned team has to know not only the modules it owns but also the external interfaces it depends on.
Cognitive load is the size of what a team has to hold in mind at once to do its work; here it is built from two numbers: the number of modules the team owns, and the number of modules its modules import from outside. The same twelve work items are run through three arrangements — component-aligned, stream-aligned, and hybrid. The through-line is the regional library network and it is fictional.
A Third Arrangement and a Load Measure
TD8 — the previous lesson’s seventeen-module graph, its two splits, and its twelve work items are used unchanged. Rationale: three arrangements can only be compared over the same graph and the same workload.
TD9 — the hybrid arrangement has seven teams: three stream teams, one interface, one rule, one bridge, and one platform team; there are twelve channels. Rationale: the hybrid arrangement is an attempt to give domain knowledge to the streams and technical knowledge to a shared team; because the team count grows, so does the channel count.
TD10 — a team’s cognitive load is the number of modules it owns plus the number of distinct modules its modules import from outside. TD11 — a team’s external demand is the number of items opened by another team that touch that team’s modules. Rationale: load measures what a team knows, external demand measures the work that comes to it from someone else; in one team the two can point in opposite directions.
// layout.mjs — the same module graph and work items run through three team arrangements (model) import { writeFileSync } from "node:fs"; // TD8 — the previous lesson's 17-module graph, its two splits, and its 12 work items are used unchanged 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]) : [])); 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"], ]; const LAYOUTS = [ { name: "component-aligned", teams: { // TD8: the previous lesson's split A 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" }, { name: "stream-aligned", teams: { // TD8: the previous lesson's split B 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" }, { name: "hybrid", teams: { // TD9: stream teams + shared component teams experience: "branchFront staffFront mobileAccess", loan: "loanFlow reservationFlow", fee: "feeFlow", membership: "membershipFlow", rule: "loanRule feeRule catalogRule membershipRule", bridge: "storeAccess catalogBridge identityBridge", platform: "sharedFormat eventLog notificationQueue" }, channels: "experience-loan experience-fee experience-membership loan-rule fee-rule membership-rule " + "loan-bridge membership-bridge rule-bridge rule-platform bridge-platform experience-platform" }, ]; 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 = {}; 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); } } } 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]]); // TD10 — cognitive load: modules owned + externally imported modules (external interface) const load = Object.fromEntries(T.map((e) => [e, { owned: teams[e].split(" ").length, extInterface: new Set(), extTeam: new Set(), extDemand: 0 }])); for (const [a, b] of EDGES) if (owner[a] !== owner[b]) { load[owner[a]].extInterface.add(b); load[owner[a]].extTeam.add(owner[b]); } // TD11 — external demand: an item opened by another team that touches this team's modules for (const [, ms] of ITEMS) { const dizi = ms.split(" "), bas = owner[dizi[0]]; for (const e of new Set(dizi.map((m) => owner[m]))) if (e !== bas) load[e].extDemand += 1; } return { name, channelCount: channels.split(" ").length, teams, owner, d, contributors, load, T }; } const D = LAYOUTS.map(build); writeFileSync("layout.json", JSON.stringify({ M, EDGES, ITEMS, D: D.map((x) => ({ ...x, contributors: Object.fromEntries(M.map((m) => [m, [...x.contributors[m]]])) })) })); const spread = (v) => `${v[0]} / ${v[(v.length - 1) >> 1]} / ${v[v.length - 1]}`; console.log(`${"layout".padEnd(19)}${"teams".padStart(6)}${"channels".padStart(9)}` + `${"modules owned".padStart(22)}${"cognitive load".padStart(22)}`); console.log(`${"".padEnd(34)}${"low/median/high".padStart(22)}${"low/median/high".padStart(22)}`); for (const x of D) { const owned = x.T.map((e) => x.load[e].owned).sort((a, b) => a - b); const ld = x.T.map((e) => x.load[e].owned + x.load[e].extInterface.size).sort((a, b) => a - b); console.log(`${x.name.padEnd(19)}${String(x.T.length).padStart(6)}` + `${String(x.channelCount).padStart(9)}${spread(owned).padStart(22)}${spread(ld).padStart(22)}`); } for (const x of D) { console.log(`\n${x.name} — cognitive load per team`); console.log(`${"team".padEnd(12)}${"owned".padStart(7)}${"ext iface".padStart(11)}` + `${"ext team".padStart(10)}${"load".padStart(6)}${"ext demand".padStart(12)}`); for (const e of x.T) console.log(`${e.padEnd(12)}${String(x.load[e].owned).padStart(7)}` + `${String(x.load[e].extInterface.size).padStart(11)}${String(x.load[e].extTeam.size).padStart(10)}` + `${String(x.load[e].owned + x.load[e].extInterface.size).padStart(6)}` + `${String(x.load[e].extDemand).padStart(12)}`); }
layout teams channels modules owned cognitive load
low/median/high low/median/high
component-aligned 5 4 1 / 4 / 5 1 / 7 / 13
stream-aligned 6 9 2 / 3 / 4 3 / 5 / 9
hybrid 7 12 1 / 3 / 4 3 / 5 / 9
component-aligned — cognitive load per team
team owned ext iface ext team load ext demand
interface 3 6 3 9 5
flow 4 9 3 13 5
rule 4 1 1 5 5
infra 5 2 2 7 4
shared 1 0 0 1 5
stream-aligned — cognitive load per team
team owned ext iface ext team load ext demand
experience 3 6 5 9 5
loan 4 5 2 9 5
fee 2 4 2 6 1
membership 3 2 2 5 0
catalog 2 1 1 3 0
platform 3 0 0 3 6
hybrid — cognitive load per team
team owned ext iface ext team load ext demand
experience 3 6 5 9 5
loan 2 6 3 8 4
fee 1 4 3 5 1
membership 1 4 3 5 1
rule 4 1 1 5 5
bridge 3 3 2 6 3
platform 3 0 0 3 6
How the Load Is Distributed
The first table lays out the component-aligned arrangement’s problem: load ranges from a low of 1
to a high of 13. The flow team owns four modules but depends on nine external interfaces; the
shared team owns a single module and depends on no external interface at all. In the same
organization, one team has to know thirteen things and another has to know one. In the
stream-aligned and hybrid arrangements the range narrows to 3–9.
How the balancing happens shows up in the second table. The flow team’s nine external interfaces
split across three teams: loan depends on five, fee on four, membership on two. The load did
not vanish, it split — and the split works, because each stream team also owns the rule for its
own domain: the loan team keeps loanRule in-house, and that module no longer counts as an
external interface.
The external demand column reads in the opposite direction. In the stream-aligned arrangement, the
platform team’s cognitive load is three — one of the lowest values — but its external demand is
six. The team with the lightest load is the team the work passes through the most.
Flow Numbers and Duplicated Expertise
The second run compares the three arrangements in terms of flow and adds two new numbers.
TD12 — a module’s duplicated expertise is the number of teams that import it without owning it. Rationale: every team that imports a module has to learn its interface; the more teams the same knowledge is repeated in, the more duplicated the expertise.
TD13 — the bottleneck is the team receiving the most external demand; the rework items touching that team’s modules are counted separately. Rationale: where the cost accumulates can only be seen by reading these two numbers together.
// flow2.mjs — flow numbers across three layouts, and duplicated expertise import { readFileSync } from "node:fs"; const { M, EDGES, ITEMS, D } = JSON.parse(readFileSync("layout.json", "utf8")); const WAIT = [0, 1, 3, 5], NONE = 8; // TD6's wait table is used unchanged const run = (x) => ITEMS.map(([name, mods]) => { const ms = mods.split(" "), order = [...new Set(ms.map((m) => x.owner[m]))]; let wait = 0, longest = 0, broken = 0; for (let j = 1; j < order.length; j++) { const u = x.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 = []; if (broken) reasons.push("late"); if (ms.some((m) => x.contributors[m].length >= 3)) reasons.push("bound"); if (order.length >= 4) reasons.push("info"); const work = ms.length + reasons.length * 2, waitT = wait + reasons.length * longest; return { name, team: order.length, hnd: order.length - 1, work, wait: waitT, flow: work + waitT, reasons }; }); const R = D.map((x) => ({ name: x.name, k: run(x) })); const T = (r, f) => r.k.reduce((t, y) => t + f(y), 0); const n = (r, c) => r.k.filter((y) => y.reasons.includes(c)).length; console.log(`${"layout".padEnd(19)}${"hnd.".padStart(6)}${"wait".padStart(6)}` + `${"flow".padStart(6)}${"wait share".padStart(13)}${"rework".padStart(8)}` + `${"late".padStart(6)}${"bound".padStart(7)}${"info".padStart(6)}${"1-team".padStart(8)}`); for (const r of R) console.log(`${r.name.padEnd(19)}${String(T(r, (y) => y.hnd)).padStart(6)}` + `${String(T(r, (y) => y.wait)).padStart(6)}${String(T(r, (y) => y.flow)).padStart(6)}` + `${`${(100 * T(r, (y) => y.wait) / T(r, (y) => y.flow)).toFixed(1)}%`.padStart(13)}` + `${String(T(r, (y) => y.reasons.length)).padStart(8)}${String(n(r, "late")).padStart(6)}` + `${String(n(r, "bound")).padStart(7)}${String(n(r, "info")).padStart(6)}` + `${String(r.k.filter((y) => y.team === 1).length).padStart(8)}`); // TD12 — duplicated expertise: the number of teams that import a module without owning it const dependent = (x, m) => new Set(EDGES.filter(([a, b]) => b === m && x.owner[a] !== x.owner[m]) .map(([a]) => x.owner[a])).size; const relevant = M.filter((m) => D.some((x) => dependent(x, m) >= 2)); console.log(`\n${"module".padEnd(18)}${D.map((x) => x.name.padStart(19)).join("")} owner (three layouts)`); for (const m of relevant) console.log(`${m.padEnd(18)}${D.map((x) => String(dependent(x, m)).padStart(19)).join("")}` + ` ${D.map((x) => x.owner[m]).join(" / ")}`); console.log(`${"TOTAL team-module".padEnd(18)}` + `${D.map((x) => String(M.reduce((t, m) => t + dependent(x, m), 0)).padStart(19)).join("")}`); // TD13 — bottleneck: the team with the most external demand and the rework items touching its modules console.log(`\n${"layout".padEnd(19)}${"team with most external demand".padStart(31)}` + `${"ext demand".padStart(11)}${"owned".padStart(7)}${"rework via its modules".padStart(24)}`); D.forEach((x, i) => { const e = x.T.reduce((a, b) => (x.load[a].extDemand >= x.load[b].extDemand ? a : b)); const hit = R[i].k.filter((y, j) => y.reasons.length && ITEMS[j][1].split(" ").some((m) => x.owner[m] === e)).length; console.log(`${x.name.padEnd(19)}${e.padStart(31)}${String(x.load[e].extDemand).padStart(11)}` + `${String(x.load[e].owned).padStart(7)}${String(hit).padStart(24)}`); });
layout hnd. wait flow wait share rework late bound info 1-team component-aligned 24 203 292 69.5% 24 8 11 5 0 stream-aligned 17 55 126 43.7% 15 4 11 0 1 hybrid 25 78 163 47.9% 22 5 12 5 1 module component-aligned stream-aligned hybrid owner (three layouts) catalogRule 2 1 2 rule / catalog / rule storeAccess 1 2 3 infra / loan / bridge catalogBridge 2 2 2 infra / catalog / bridge notificationQueue 1 2 2 infra / platform / platform sharedFormat 4 5 6 shared / platform / platform TOTAL team-module 18 18 24 layout team with most external demand ext demand owned rework via its modules component-aligned interface 5 3 7 stream-aligned platform 6 3 8 hybrid platform 6 3 8
Where the Stream-Aligned Arrangement Loses
In the flow numbers, the stream-aligned arrangement leads all three: the fewest handoffs (17), the
least waiting (55 rounds), the shortest flow (126 rounds), the least rework (15). The hybrid
arrangement does the opposite of what one would expect — with twenty-five handoffs it is the highest
of the three arrangements, exceeding the component-aligned arrangement’s twenty-four as well. The
reason is structural: the hybrid arrangement splits the flow, but it also gives the rule, the
bridge, and the platform to separate teams, so an item crosses both the stream boundary and the
layer boundary. It is also highest in the bound reason (12).
Where the stream-aligned arrangement loses is in the second table. The total team-module
dependency pair count is 18 in the component-aligned arrangement and 18 in the stream-aligned
arrangement as well — the stream-aligned arrangement does not reduce duplicated expertise at all.
Row by row, though, the distribution shifts: storeAccess is imported from outside by two teams
instead of one, notificationQueue by two instead of one, sharedFormat by five teams instead of
four. The number of teams that have to know the shared format module has grown, because every
stream team runs into that module’s interface while finishing its own item end to end. In the
hybrid arrangement the total climbs to 24 and sharedFormat reaches six teams: the more an
arrangement is split, the more duplicated expertise grows.
The third table names what the shared component is. In the stream-aligned and hybrid arrangements
the bottleneck is the platform team: it owns three modules, receives six external demands, and
eight of the items entering rework touch one of its modules. The shared component is not
ownerless — it has an owner — but it produces no item of its own stream; it only makes someone
else’s item wait. In the component-aligned arrangement the same role is played by the interface
team (five external demands, seven rework items), but there the bottleneck’s load is also high (9);
in the stream-aligned arrangement it is three. As the arrangement changes, the bottleneck’s
location changes; its existence does not.
The stream-aligned arrangement improves the flow numbers and the load balance together; it does this without reducing duplicated expertise, and even increases it in the shared modules. The hybrid arrangement balances the load by the same measure, but pushes handoffs and duplicated expertise to their worst point.
Summary
- The same graph and the same 12 items were run through three arrangements: component-aligned (5 teams, 4 channels), stream-aligned (6 teams, 9 channels), and hybrid (7 teams, 12 channels).
- In the component-aligned arrangement, cognitive load spreads from 1 to 13; the
flowteam owns 4 modules while depending on 9 external interfaces. In the stream-aligned and hybrid arrangements, the range narrows to 3–9. - In the flow numbers, the stream-aligned arrangement leads: 17 handoffs, 55 rounds of waiting, 126 rounds of flow, 15 rework items. The hybrid arrangement, with 25 handoffs, is the highest of the three, exceeding the component-aligned arrangement’s 24 as well.
- The stream-aligned arrangement gains nothing in duplicated expertise: the total team-module dependency pair count is 18 in both the component-aligned and the stream-aligned arrangements, and 24 in the hybrid arrangement.
- The number of teams importing
sharedFormatfrom outside is 4, 5, and 6 across the three arrangements in order; the more the arrangement is split, the more teams have to know the shared format. - In the stream-aligned and hybrid arrangements the bottleneck is the
platformteam: its cognitive load is 3, its external demand is 6, and 8 of the items entering rework touch its modules. The bottleneck’s location changes; its existence does not.
Next Step
These two lessons measured where the team boundary falls and ran a quarter’s worth of workload through three arrangements. In every run, the modules a work item would touch were known from the start. In reality this information does not exist when an item begins: how long an item will take can only be estimated, and the estimate narrows as work proceeds. The next lesson measures estimation — a set of estimates is compared against the durations that actually occurred, the margin of error and the stage-by-stage narrowing of uncertainty are counted, and it is shown why a single-item estimate and a total estimate behave differently.
To keep your progress and take notes, Log in
My notes
Log in to take notes.