Skip to content
academia.sh

Lesson 25 / 25

Framework Selection

Where criteria come from, writing weights explicitly, testing the ranking with sensitivity analysis, measuring the decision's reversibility at the line level, and putting the decision in writing.

Contents

All four families now have their characteristics, costs, and trade-offs laid out. One question remains, and its answer lies not in the families’ characteristics but in the project itself.

This lesson is not a comparison table — such a table is incomplete even for the month it is written in, and misleading a year later. What is built instead is a framework that writes criteria explicitly, makes weights visible, and states which assumption the decision rests on. The framework does not change; its inputs get refilled for every project.

Where Criteria Come From

Most selection debates get stuck because different kinds of information end up on the same list. Criteria come from three sources, and each has a different reliability.

Criteria derivable from the family were measured across this topic’s four lessons: update granularity, the code reaching the runtime, the compile-step requirement, manual memoization overhead, fit with dynamic structures, update divisibility, and decision-surface width. These follow from the family’s definition, known without looking at any product’s documentation.

Criteria read from the candidate are measured by looking at a specific framework: documentation quality, breaking-change history, release cadence, issue-closing time, accessibility support. These do not follow from the family and change over time — they must be measured at decision time.

Criteria read from the team belong to neither the family nor the candidate: prior experience, learning budget, ease of hiring, expected project lifespan, who will maintain it. These often weigh heaviest and get discussed least in technical debate.

The model below scores only the first group’s criteria; the second and third groups are treated not as weights but as independent inputs that feed directly into the decision.

Writing Weights Explicitly

// selection.mjs — decision framework: explicit weights and sensitivity analysis
// Scores (0-3) come from characteristics measured in previous lessons; this is not a product evaluation.

const UPDATE_MODEL = {
  "virtual-tree-based": {
    "fine-grained update": 1, "little runtime code": 1, "no compile step required": 3,
    "no manual memoization required": 0, "fits dynamic structures": 3, "update divisibility": 3,
  },
  "runtime dependency tracking": {
    "fine-grained update": 3, "little runtime code": 2, "no compile step required": 2,
    "no manual memoization required": 3, "fits dynamic structures": 3, "update divisibility": 1,
  },
  "compile-time reactive": {
    "fine-grained update": 3, "little runtime code": 3, "no compile step required": 0,
    "no manual memoization required": 3, "fits dynamic structures": 1, "update divisibility": 1,
  },
};

const SCOPE = {
  "narrow-scoped": { "small decision surface": 0, "upgrade divisibility": 3, "gradual exit": 3, "single contract": 1 },
  "integrated": { "small decision surface": 3, "upgrade divisibility": 0, "gradual exit": 0, "single contract": 3 },
};

const PROJECTS = {
  "public station page": {
    "fine-grained update": 1, "little runtime code": 5, "no compile step required": 1,
    "no manual memoization required": 2, "fits dynamic structures": 1, "update divisibility": 1,
    "small decision surface": 2, "upgrade divisibility": 2, "gradual exit": 3, "single contract": 1,
  },
  "internal observability dashboard": {
    "fine-grained update": 5, "little runtime code": 1, "no compile step required": 1,
    "no manual memoization required": 3, "fits dynamic structures": 3, "update divisibility": 2,
    "small decision surface": 4, "upgrade divisibility": 1, "gradual exit": 1, "single contract": 4,
  },
};

const score = (candidates, weight) =>
  Object.entries(candidates).map(([name, traits]) => [
    name,
    Object.entries(traits).reduce((t, [criterion, p]) => t + (weight[criterion] ?? 0) * p, 0),
  ]).sort((a, b) => b[1] - a[1]);

for (const [project, weight] of Object.entries(PROJECTS)) {
  console.log(`\n=== ${project} ===`);
  for (const [title, candidates] of [["update model", UPDATE_MODEL], ["scope", SCOPE]]) {
    const ranking = score(candidates, weight);
    console.log(`  ${title}:`);
    for (const [name, p] of ranking) console.log(`    ${name.padEnd(38)} ${String(p).padStart(4)}`);
    console.log(`    gap (first − second): ${ranking[0][1] - ranking[1][1]}`);
  }
}

// Sensitivity: at what weight for one criterion does the top of the ranking flip?
console.log("\n=== sensitivity: update model, internal observability dashboard ===");
const baseline = { ...PROJECTS["internal observability dashboard"] };
const winner = (a) => score(UPDATE_MODEL, a)[0][0];
const baselineWinner = winner(baseline);
console.log(`top rank at baseline weights: ${baselineWinner}`);
console.log("criterion                            baseline  flip weight  new top rank");
for (const criterion of Object.keys(UPDATE_MODEL["virtual-tree-based"])) {
  let flip = null, next = null;
  for (let w = 0; w <= 20; w++) {
    const trial = { ...baseline, [criterion]: w };
    if (winner(trial) !== baselineWinner) { flip = w; next = winner(trial); break; }
  }
  console.log(
    `${criterion.padEnd(36)} ${String(baseline[criterion]).padStart(5)} ` +
    `${(flip === null ? "no flip" : String(flip)).padStart(15)}  ${next ?? "-"}`
  );
}
=== public station page ===
  update model:
    compile-time reactive                    26
    runtime dependency tracking              25
    virtual-tree-based                       15
    gap (first − second): 1
  scope:
    narrow-scoped                            16
    integrated                                9
    gap (first − second): 7

=== internal observability dashboard ===
  update model:
    runtime dependency tracking              39
    compile-time reactive                    32
    virtual-tree-based                       24
    gap (first − second): 7
  scope:
    integrated                               24
    narrow-scoped                            10
    gap (first − second): 14

=== sensitivity: update model, internal observability dashboard ===
top rank at baseline weights: runtime dependency tracking
criterion                            baseline  flip weight  new top rank
fine-grained update                      5         no flip  -
little runtime code                      1               9  compile-time reactive
no compile step required                 1              16  virtual-tree-based
no manual memoization required           3         no flip  -
fits dynamic structures                  3         no flip  -
update divisibility                      2              10  virtual-tree-based

The same score table produces different rankings for the two projects — the difference comes from the weights, not the families: on the public page, downloaded-code leanness carries five times the weight; on the observability dashboard, fine-grained update does.

On the scope axis, the ranking splits more sharply: long lifespan and gradual exit matter most on the public page, so narrow scope leads; a small decision surface and a single contract dominate on the internal dashboard, so the integrated profile leads. The two axes are independent and decided separately.

What Matters Is the Gap, Not the Ranking

In the first project, the gap between first and second place is 1 point — the model gives no real answer for that project. The scores rest on estimated weights, and a one-point gap disappears with the smallest change to them. The correct reading is not “first place won” but “these two options are not separated by these criteria.” The decision is left to criteria outside the model — those read from the candidate and the team.

In the second project, the gap is 7 points, and the sensitivity table shows how solid that is: raising the heaviest criterion’s weight to twenty does not change first place, but raising little-runtime-code’s weight from 1 to 9 flips the ranking. This states which assumption the decision rests on — if “downloaded code is secondary for this project” falls, so does the decision.

This is sensitivity analysis’s real function. It does not produce a number; it puts the decision’s breaking point in writing. Once the breaking point is known, whether the decision needs revisiting when project conditions change stops being debatable.

The Decision’s Reversibility

The final criterion for the choice is what happens if the choice turns out wrong. This too can be counted.

// migration.mjs — measuring how much of a decision stays reversible
// Coupling: 0 = independent of the framework, 1 = via interface, 2 = directly bound.
const SEPARATED = [
  { name: "threshold and date validation rules", lines: 180, coupling: 0 },
  { name: "measurement formatting and unit conversion", lines: 120, coupling: 0 },
  { name: "option-list state machine", lines: 260, coupling: 0 },
  { name: "accessibility attribute derivation", lines: 90, coupling: 0 },
  { name: "data access layer", lines: 310, coupling: 1 },
  { name: "presentational components (markup)", lines: 940, coupling: 1 },
  { name: "containers and hooks", lines: 380, coupling: 2 },
  { name: "routing and form binding", lines: 360, coupling: 2 },
];

// The same application, without the separations: logic lives in the component body.
const UNSEPARATED = [
  { name: "components (logic in the body)", lines: 2210, coupling: 2 },
  { name: "data access layer", lines: 310, coupling: 1 },
  { name: "routing and form binding", lines: 360, coupling: 2 },
];

function measure(name, modules) {
  const total = modules.reduce((t, m) => t + m.lines, 0);
  const group = (level) => modules.filter((m) => m.coupling === level).reduce((t, m) => t + m.lines, 0);
  const independent = group(0), viaInterface = group(1), direct = group(2);
  // Rewritten during migration: all of what is directly bound, half of what is bound via interface.
  const rewritten = direct + Math.round(viaInterface / 2);
  console.log(`\n--- ${name} ---`);
  console.log("module                                    lines  coupling");
  for (const m of modules) console.log(`${m.name.padEnd(42)} ${String(m.lines).padStart(6)}  ${m.coupling}`);
  console.log(`total ${total} lines | independent ${independent} (%${((independent / total) * 100).toFixed(1)})` +
    ` | via interface ${viaInterface} | directly bound ${direct}`);
  console.log(`rewritten during migration: ${rewritten} lines (%${((rewritten / total) * 100).toFixed(1)})`);
  return rewritten;
}

const a = measure("separated", SEPARATED);
const b = measure("not separated", UNSEPARATED);
console.log(`\nwhat the separations gained in migration: ${b - a} lines`);
--- separated ---
module                                    lines  coupling
threshold and date validation rules           180  0
measurement formatting and unit conversion    120  0
option-list state machine                     260  0
accessibility attribute derivation             90  0
data access layer                             310  1
presentational components (markup)            940  1
containers and hooks                          380  2
routing and form binding                      360  2
total 2640 lines | independent 650 (%24.6) | via interface 1250 | directly bound 740
rewritten during migration: 1365 lines (%51.7)

--- not separated ---
module                                    lines  coupling
components (logic in the body)               2210  2
data access layer                             310  1
routing and form binding                      360  2
total 2880 lines | independent 0 (%0.0) | via interface 310 | directly bound 2570
rewritten during migration: 2725 lines (%94.6)

what the separations gained in migration: 1360 lines

The same application, with two different internal layouts. With the separations made, a quarter of the code is entirely independent of the framework, and the portion to rewrite in a migration drops by half. Without them, independent code is zero and nearly the whole application gets rewritten.

The four modules that stay independent are not a coincidence: validation rules, formatting, the state machine, and accessibility attribute derivation — all products of the previous topic’s separations: presentation from container, behavior into a headless core, logic into hooks. Their value is not limited to readability; they are also what keeps the framework decision reversible.

This leads to a conclusion that softens the selection debate. When the independent-code ratio is kept high, the framework decision is a choice, not a fate. The weight of the decision is inversely proportional to its reversibility.

The Decision in Writing

The decision framework’s output is not a framework name; it is a record. The record has to carry five things.

Context: what the application is, its expected lifespan, team size and experience. Criteria and weights: which criterion outweighs which, and why, with numbers. The candidates evaluated and why each was eliminated. The assumptions the decision rests on, and the breaking points from sensitivity analysis. The review condition: which event brings the decision back up for reconsideration.

The most useful part of this record is the last one. The sentence “this will be revisited if this condition changes” makes the decision both defensible and changeable; a decision without a record loses its rationale over time and persists only as habit.

Summary

  • Criteria come from three sources: those derivable from the family, those read from the candidate, and those read from the team. Only the first follows from the family’s definition.
  • When weights are written explicitly, the same score table produces different rankings for different projects; the difference comes from the weights, not the families.
  • The update model and scope are two independent axes and are decided separately.
  • What matters is not the ranking itself but the gap between first and second; a one-point gap means “not separated,” and sensitivity analysis puts the decision’s breaking point in writing.
  • The decision’s reversibility can be measured; with the separations made, the portion to be rewritten in a migration dropped from 94.6% to 51.7%.
  • The output is not a framework name but a written decision record that includes the review condition.

Course Wrap-Up

This course began with the scaling problems of manual DOM management and built a one-way flow from state to view. The component was defined as a three-channel contract of props, output events, and local state, carried all the way through with lifecycle, side effects, derived values, context, and error boundaries.

In the second half, components were composed together. The choice between wrapping and slot-based composition was justified by counting prop drilling and option explosion. Logic moved into hooks and composables, families of components that work together were built, and data, behavior, and view split into three layers. The component’s outer surface — prop spreading, default merging, control ownership — was designed as a contract.

The final topic covered the update mechanisms underneath these components. The same change in the same two-hundred-row measurement table produced work ranging from sixteen hundred comparisons down to a single write, depending on the family. The four families’ characteristics, costs, and decision surfaces were laid out with numbers and turned into a decision framework that never names a single product.

There is a gap where the course leaves off. Everything so far lived inside a single component tree: state is born in a component, flows down through it, and gets consumed there. But the station application is not a single screen — the measurement table is at one address, the archive view at another; the filter preference has to persist across two screens; measurement data comes from the server, and its browser copy does not know when to refresh. None of this is solved by component composition; it belongs to the layer above components.

The next course — Application Architecture: Routing, State and Data — builds this layer: how an address maps to a view, how nested layouts share a shell, why local, shared, and server state are managed separately, caching and invalidating server data, sharing form state and validation schemas, and the options and risks of browser authentication. This course’s composition and surface-design habits hold there too — the owner of state, the boundary of the contract, and the reversibility of the decision are the same questions, only at greater scale.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close