Lesson 06 / 11
Striking Balance
The practice of choosing between conflicting quality attributes: turning the alternative-attribute table into a data structure, scoring the same table with two weight sets, counting how the winning alternative changes as weight changes through a sensitivity sweep, and tying whether each weight's source is written down to the decision's defensibility.
Contents
The previous topic defined what the role is and how an already-made decision gets communicated to the team. What remained open is the decision itself: which quality attribute gets chosen when two cannot be satisfied at once, what the choice rests on, and what is left once the choice is questioned.
That quality attributes can conflict, and that this conflict is a fact, was measured in the Introduction to System Design course; the tension between availability and consistency was shown there with numbers. It is not repeated here. This lesson’s question is not whether the tension exists, but how the decision gets made once the tension is given. That is also the quantity measured: how much a decision holds onto which assumption.
The Decision’s Object: The Alternative–Attribute Table
The through-line is the software system of a regional library network. It is a fictional model; no real institution, team, or product is described. The model has fourteen branches, a central IT unit with its own budget, a third-party catalog system, and in-house loan services.
The decision is this: how should branches access loan records? Four alternatives and four attributes are defined (AP1; scores range 1–5, higher is better, and the values are the model’s):
single-center— a single authoritative database; every branch connects to the center for every transaction.branch-cache— the center is authoritative, each branch holds a read-only cache; writes go to the center.branch-replica— each branch holds a full replica, closes its transaction locally, and synchronizes afterward.external-module— uses the third-party catalog system’s own loan module.
As long as the decision is a preference held in someone’s head, it cannot be measured. The only thing that makes it measurable is turning it into a data structure.
// balance/table.mjs — the alternative-attribute table as a data structure; this is a fictional model export const ATTRIBUTE = ["outage-resilience", "record-freshness", "modifiability", "operating-cost"]; // Scores range 1-5, higher is better (AP1). Sources are written out in the lesson. export const ALTERNATIVE = [ { name: "single-center", score: [1, 5, 4, 5] }, { name: "branch-cache", score: [3, 4, 3, 4] }, { name: "branch-replica", score: [5, 2, 2, 2] }, { name: "external-module", score: [4, 3, 1, 3] }, ]; export function score(weight) { return ALTERNATIVE.map((a) => ({ name: a.name, total: a.score.reduce((t, p, i) => t + p * weight[i], 0), })).sort((x, y) => y.total - x.total); } export const winner = (weight) => score(weight)[0].name;
No row in the table dominates every column. That is exactly what a trade-off is: an alternative that raises one column lowers another. What determines the winner is no longer the scores, but the weights given to the columns.
The Same Table, Two Weight Sets
Two implementation forms are run on the same input. The first never asks the weight question and counts all four attributes equally — in practice this is the most common form, because when weights are not written down, equal weight is accepted by default. The second writes the weights explicitly.
// balance/score.mjs — same table with two weight sets: does the winning alternative change import { ATTRIBUTE, ALTERNATIVE, score } from "./table.mjs"; const SET = { "equal-weight": [0.25, 0.25, 0.25, 0.25], "written-weight": [0.50, 0.20, 0.15, 0.15], }; console.log(`attribute : ${ATTRIBUTE.join(" ")}`); for (const a of ALTERNATIVE) console.log(`${a.name.padEnd(15)}: ${a.score.join(" ")}`); console.log(""); for (const [name, w] of Object.entries(SET)) { const s = score(w); console.log(`${name.padEnd(15)} w=[${w.join(" ")}]`); console.log(` ${s.map((x) => `${x.name}=${x.total.toFixed(2)}`).join(" ")}`); console.log(` winner: ${s[0].name} (margin over second ${(s[0].total - s[1].total).toFixed(2)})`); }
attribute : outage-resilience record-freshness modifiability operating-cost single-center : 1 5 4 5 branch-cache : 3 4 3 4 branch-replica : 5 2 2 2 external-module: 4 3 1 3 equal-weight w=[0.25 0.25 0.25 0.25] single-center=3.75 branch-cache=3.50 branch-replica=2.75 external-module=2.75 winner: single-center (margin over second 0.25) written-weight w=[0.5 0.2 0.15 0.15] branch-replica=3.50 branch-cache=3.35 external-module=3.20 single-center=2.85 winner: branch-replica (margin over second 0.15)
Same score table, same four alternatives; the winner became branch-replica instead of
single-center, and single-center fell from first to last. What determines the decision is not
the alternatives’ measured attributes, but the weights. When weights are not written down, the
decision still gets made — equal weight is a weight set too, only nobody said they chose it.
The smallness of the margin column in both sets is also information: 0.25 and 0.15. The winning alternative does not leave the runner-up half a point behind. That calls for asking how solidly the decision stands.
Sensitivity Sweep
The result that comes out of a single weight set is not a decision, it is a sample. How resilient the decision is can only be seen by sweeping the weight space. The sweep generates a grid with a 0.05 step such that the four weights sum to 1, and counts the winner at every point.
// balance/sweep.mjs — sensitivity sweep: does the winning alternative change as weight changes import { ATTRIBUTE, ALTERNATIVE, score, winner } from "./table.mjs"; const STEP = 0.05, N = ATTRIBUTE.length, EPS = 1e-9; const grid = []; (function generate(remaining, accumulated) { if (accumulated.length === N - 1) return grid.push([...accumulated, remaining]); for (let p = 0; p <= remaining + EPS; p += STEP) generate(remaining - p, [...accumulated, p]); })(1, []); const decisive = new Map(ALTERNATIVE.map((a) => [a.name, 0])); let tie = 0; for (const w of grid) { const s = score(w); if (s[0].total - s[1].total < EPS) { tie += 1; continue; } decisive.set(s[0].name, decisive.get(s[0].name) + 1); } console.log(`grid: step ${STEP}, ${grid.length} weight vectors`); for (const [name, k] of [...decisive].sort((a, b) => b[1] - a[1])) console.log(` ${name.padEnd(15)} first alone in first place: ${String(k).padStart(4)} vectors (${(100 * k / grid.length).toFixed(1)}%)`); console.log(` (${tie} vectors where the top two alternatives tied were not counted)`); const SELECTED = [0.50, 0.20, 0.15, 0.15]; const selectedWinner = winner(SELECTED); const l1 = (a, b) => a.reduce((t, v, i) => t + Math.abs(v - b[i]), 0); let nearest = { d: Infinity, w: null, name: null }; for (const w of grid) { const k = winner(w); if (k === selectedWinner) continue; const d = l1(w, SELECTED); if (d < nearest.d) nearest = { d, w, name: k }; } console.log(`\nselected weight [${SELECTED.join(" ")}] -> ${selectedWinner}`); console.log(`breaking distance (L1): ${nearest.d.toFixed(2)}`); console.log(` nearest different vector [${nearest.w.map((x) => x.toFixed(2)).join(" ")}] -> ${nearest.name}`); console.log("\nperturbing a single weight by 0.10 (the other three weights are rebalanced to preserve their ratio):"); for (let i = 0; i < N; i++) { const row = []; for (const delta of [-0.10, +0.10]) { const next = SELECTED.slice(); next[i] = Math.max(0, Math.min(1, next[i] + delta)); const share = (1 - next[i]) / (1 - SELECTED[i]); for (let j = 0; j < N; j++) if (j !== i) next[j] = SELECTED[j] * share; row.push(`${delta > 0 ? "+" : ""}${delta.toFixed(2)} -> ${winner(next)}`); } const changed = row.some((s) => !s.endsWith(selectedWinner)); console.log(` ${ATTRIBUTE[i].padEnd(20)} ${row.join(" , ")}${changed ? " <- winner changed" : ""}`); }
grid: step 0.05, 1771 weight vectors single-center first alone in first place: 1211 vectors (68.4%) branch-replica first alone in first place: 314 vectors (17.7%) branch-cache first alone in first place: 220 vectors (12.4%) external-module first alone in first place: 0 vectors (0.0%) (26 vectors where the top two alternatives tied were not counted) selected weight [0.5 0.2 0.15 0.15] -> branch-replica breaking distance (L1): 0.10 nearest different vector [0.45 0.20 0.15 0.20] -> branch-cache perturbing a single weight by 0.10 (the other three weights are rebalanced to preserve their ratio): outage-resilience -0.10 -> branch-cache , +0.10 -> branch-replica <- winner changed record-freshness -0.10 -> branch-replica , +0.10 -> branch-cache <- winner changed modifiability -0.10 -> branch-replica , +0.10 -> branch-replica operating-cost -0.10 -> branch-replica , +0.10 -> branch-cache <- winner changed
The sweep produces three separate numbers, and each says something different about the decision.
Region won. The selected alternative, branch-replica, is first in 314 of the 1771 vectors.
The decision is defensible in 17.7% of the weight space. single-center holds the largest region
at 68.4%; that is, the selected alternative is not the one that is “generally best,” but the one
that is best under the written weights. The gap between these two sentences is the whole of the
decision’s justification.
Breaking distance. The L1 distance from the selected vector to the nearest point where the
winner changes is 0.10; when the operating-cost weight rises from 0.15 to 0.20, the winner
becomes branch-cache. A 0.10 perturbation on three of the four weights changes the winner. This
is not a flaw, it is the decision’s real sensitivity; the problem only starts when the decision is
made without knowing this sensitivity.
The alternative that wins under no weight. external-module is not first alone in any of the
1771 vectors. This result is independent of the weight debate: no matter which weight set gets
agreed on, this alternative will not be chosen, because branch-cache and branch-replica
together dominate it in every direction. The alternative most likely to be argued over in the
meeting is the one that can be eliminated without ever entering the debate.
The Weight’s Source
Everything measured so far assumed the weights were correct. Where do the weights themselves come from? The threshold’s source measure established in the Introduction to System Design course’s What Is System Design lesson asks exactly this: when a number is written down, the reason it is that number has to be written down too. A weight is also a threshold; a weight with no written source leaves the same gap as a threshold with no written source.
The following run adds a source field to each weight and intersects two things: whether the weight’s source is written down, and whether that weight can change the winner. The three records shown as sources are the model’s input (AP2); the fourth weight has no record behind it.
// balance/source.mjs — is each weight's source written down, and does breaking sit on that weight import { ATTRIBUTE, winner } from "./table.mjs"; const WEIGHT = [ { value: 0.50, source: "branch network downtime log (AP2)" }, { value: 0.20, source: "the written return grace period in the loan rules (AP2)" }, { value: 0.15, source: "annual rule-change count log (AP2)" }, { value: 0.15, source: null }, ]; function perturb(w, i, delta) { const next = w.slice(); next[i] = Math.max(0, Math.min(1, next[i] + delta)); const share = (1 - next[i]) / (1 - w[i]); for (let j = 0; j < w.length; j++) if (j !== i) next[j] = w[j] * share; return next; } function check(weight) { const w = weight.map((a) => a.value); const base = winner(w); const row = weight.map((a, i) => ({ name: ATTRIBUTE[i], sourced: a.source !== null, fragile: [-0.10, 0.10].some((d) => winner(perturb(w, i, d)) !== base), })); return { base, row, exposed: row.filter((s) => s.fragile && !s.sourced).length }; } for (const [name, weight] of [["initial state", WEIGHT], ["after the cost weight is measured", WEIGHT.map((a, i) => i === 3 ? { value: 0.25, source: "the IT unit's budget cap and the annual cost per replica (AP3)" } : i === 0 ? { value: 0.40, source: a.source } : a)]]) { const d = check(weight); console.log(`${name}: w=[${weight.map((a) => a.value.toFixed(2)).join(" ")}] -> ${d.base}`); for (const s of d.row) console.log(` ${s.name.padEnd(20)} source: ${(s.sourced ? "written" : "none").padEnd(7)}` + ` fragile: ${s.fragile ? "yes" : "no"}`); console.log(` weight that can change the winner but has no written source: ${d.exposed}\n`); }
initial state: w=[0.50 0.20 0.15 0.15] -> branch-replica outage-resilience source: written fragile: yes record-freshness source: written fragile: yes modifiability source: written fragile: no operating-cost source: none fragile: yes weight that can change the winner but has no written source: 1 after the cost weight is measured: w=[0.40 0.20 0.15 0.25] -> branch-cache outage-resilience source: written fragile: yes record-freshness source: written fragile: no modifiability source: written fragile: no operating-cost source: written fragile: no weight that can change the winner but has no written source: 0
In the first run there is exactly one number the decision cannot carry: the operating-cost weight
both can change the winner and has no written source. The decision was made toward
branch-replica, but one leg of its justification is empty. That the modifiability weight’s
source is written down does not strengthen the decision at all — perturbing that weight by 0.10
does not change the winner. The effort of writing the justification was spent where the decision
does not lean, not where it does.
The second run is the result of closing the missing source: once the IT unit’s budget cap and the
annual cost per replica are measured (AP3), the cost weight comes out to 0.25 instead of 0.15,
and the outage-resilience weight drops from 0.50 to 0.40. The winner becomes branch-cache, and
the count of fragile weights with no written source drops from 1 to 0. The first decision was not
wrong; it was not yet measured. The difference between the two runs shows that the architect’s job
is not to argue about weights, but to find the weights’ source.
The measure that comes out of this is: the count of weights that can change the winner but have no written source. If this count is not zero, the decision is not defensible — meaning it is not known whether it is right or wrong. If the count is zero, the decision stays open to debate, but the debate no longer runs on the weight itself; it runs on the record the weight rests on, and that record is something measurable.
Summary
- Striking balance is not declaring a preference; it is turning the alternative-attribute table into a data structure and running weighted scoring; no alternative in the table dominates every column.
- The same four alternatives and the same scores gave
single-centerthe win under equal weight andbranch-replicaunder written weight; when weight is not written down, equal weight gets chosen by default. - In the 1771-vector sensitivity sweep,
branch-replicawon in a 17.7% region andsingle-centerin a 68.4% region; the breaking distance is 0.10, and a 0.10 perturbation on three of the four weights changes the winner. external-moduleis not first alone in any of the 1771 vectors; an alternative that can be eliminated without agreeing on weights is eliminated before the debate.- The decision’s defensibility is measured by a single number: the count of weights that can
change the winner but have no written source. It was 1 in the first run and 0 after the cost
weight was measured, with the winner becoming
branch-cache.
Next Step
The balance-striking measure gave which alternative the decision went to, but it did not ask
how heavy the selected alternative itself is. When branch-cache is selected, a cache layer, a
synchronization path, and an invalidation rule appear; each is a new piece the system will carry.
The next question is: which of these pieces comes from the work itself, and which comes only from
how the solution is built? Every simplification made without separating the two either deletes
something needed or deletes nothing at all. The next lesson splits complexity into two components
and separates the share simplification can remove from the share it cannot, by counting them.
To keep your progress and take notes, Log in
My notes
Log in to take notes.