---
title: 'Striking Balance'
source: 'https://academia.sh/en/courses/architect-role/striking-balance'
course: "The Architect's Role"
language: en
updated: '2026-08-23T07:01:03+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

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

```js
// 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-center` the win under equal weight
  and `branch-replica` under written weight; when weight is not written down, equal weight gets
  chosen by default.
- In the 1771-vector sensitivity sweep, `branch-replica` won in a 17.7% region and `single-center`
  in 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-module` is 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.
