Skip to content
academia.sh

Lesson 24 / 25

Full-Stack Frameworks

Frameworks that gather routing, the form model, and dependency resolution into a single contract; how scoped dependency resolution works, the singleton trap, counting the decision surface, and the cost of contract unity.

Contents

All three previous families answered the same question: which part of the document gets written when state changes. Above the component layer sit other questions — which view an address maps to, where the filter panel gets its validation rules, how the measurement service reaches a component — and as the North Slope Measurement Station page grows, all of them need deciding.

In the first three families, these decisions lie outside the framework; each team builds its own combination. The fourth approach supplies the answers as part of the framework itself.

The Roles the Framework Covers

A full-stack framework offers routing, the form model, data fetching, dependency resolution, server rendering, and testing tools alongside the view layer, all under one contract. The parts are not chosen separately — they arrive together, version together, and are designed to recognize each other.

This family’s most distinctive part is dependency injection. A component does not build the service it needs; it asks for it by key, and the framework finds the registered provider and hands back an instance. The interface/implementation distinction from the Programming Fundamentals course gets carried into the runtime here.

Scoped Dependency Resolution

The mechanism has three parts: provider registration, the scope chain, and the rule that determines where instances live.

// injection.mjs — scoped dependency resolution: registration, scope chain, and overriding
const openScope = (name, parent = null) => ({ name, parent, providers: new Map(), instances: new Map() });
const register = (scope, key, create, { perScope = false } = {}) =>
  scope.providers.set(key, { create, perScope });

const trace = [];
function resolve(scope, key, chain = []) {
  if (chain.includes(key)) throw new Error(`circular dependency: ${[...chain, key].join(" → ")}`);
  let owner = scope;
  while (owner && !owner.providers.has(key)) owner = owner.parent;
  if (!owner) throw new Error(`no registered provider: ${key} (requested by: ${scope.name})`);
  const entry = owner.providers.get(key);
  const home = entry.perScope ? scope : owner;      // where the instance will live
  if (home.instances.has(key)) {
    trace.push(`${scope.name}: ${key} ← ${home.name} (existing instance)`);
    return home.instances.get(key);
  }
  trace.push(`${scope.name}: ${key} ← ${owner.name} (new instance, ${entry.perScope ? "per scope" : "singleton"})`);
  const instance = entry.create((a) => resolve(scope, a, [...chain, key]));
  home.instances.set(key, instance);
  return instance;
}

// --- Application registration ---
const root = openScope("root");
let counter = 0;
register(root, "MeasurementSource", () => ({ id: ++counter, fetch: () => [{ name: "Upper Slope", value: -4.2 }] }));
register(root, "UnitFormatter", () => ({ format: (c) => `${c.toFixed(1)} °C` }));
register(root, "MeasurementService", (c) => {
  const source = c("MeasurementSource"), formatter = c("UnitFormatter");
  return { id: ++counter, rows: () => source.fetch().map((o) => `${o.name}: ${formatter.format(o.value)}`) };
});
register(root, "FilterState", () => ({ id: ++counter, threshold: -5 }), { perScope: true });

const tableScope = openScope("measurement table", root);
const panelScope = openScope("filter panel", root);

console.log("--- resolution trace ---");
const t = resolve(tableScope, "MeasurementService");
const p = resolve(panelScope, "MeasurementService");
const ts = resolve(tableScope, "FilterState");
const ps = resolve(panelScope, "FilterState");
for (const s of trace) console.log(" ", s);
console.log("\nsame measurement service instance:", t === p);
console.log("same filter state instance:", ts === ps, `(ids: ${ts.id}, ${ps.id})`);
console.log("table's rows:", JSON.stringify(t.rows()));

// --- Overriding for testing: (a) under the existing root, (b) with a fresh root ---
const childScope = openScope("test (under root)", root);
register(childScope, "MeasurementSource", () => ({ id: 0, fetch: () => [{ name: "Fake", value: 0 }] }));
trace.length = 0;
const childService = resolve(childScope, "MeasurementService");
console.log("\n--- (a) overriding under the existing root ---");
for (const s of trace) console.log(" ", s);
console.log("rows:", JSON.stringify(childService.rows()), "| same instance as root's:", childService === t);

const freshRoot = openScope("fresh root");
for (const [key, entry] of root.providers) freshRoot.providers.set(key, entry);
register(freshRoot, "MeasurementSource", () => ({ id: 0, fetch: () => [{ name: "Fake", value: 0 }] }));
trace.length = 0;
const freshService = resolve(openScope("test (fresh root)", freshRoot), "MeasurementService");
console.log("\n--- (b) overriding with a fresh root ---");
for (const s of trace) console.log(" ", s);
console.log("rows:", JSON.stringify(freshService.rows()), "| same instance as root's:", freshService === t);

// --- Errors are reported explicitly ---
console.log("\n--- errors ---");
register(root, "A", (c) => ({ b: c("B") }));
register(root, "B", (c) => ({ a: c("A") }));
for (const attempt of [() => resolve(tableScope, "Log"), () => resolve(root, "A")]) {
  try { attempt(); } catch (e) { console.log(" ", e.message); }
}
--- resolution trace ---
  measurement table: MeasurementService ← root (new instance, singleton)
  measurement table: MeasurementSource ← root (new instance, singleton)
  measurement table: UnitFormatter ← root (new instance, singleton)
  filter panel: MeasurementService ← root (existing instance)
  measurement table: FilterState ← root (new instance, per scope)
  filter panel: FilterState ← root (new instance, per scope)

same measurement service instance: true
same filter state instance: false (ids: 3, 4)
table's rows: ["Upper Slope: -4.2 °C"]

--- (a) overriding under the existing root ---
  test (under root): MeasurementService ← root (existing instance)
rows: ["Upper Slope: -4.2 °C"] | same instance as root's: true

--- (b) overriding with a fresh root ---
  test (fresh root): MeasurementService ← fresh root (new instance, singleton)
  test (fresh root): MeasurementSource ← fresh root (new instance, singleton)
  test (fresh root): UnitFormatter ← fresh root (new instance, singleton)
rows: ["Fake: 0.0 °C"] | same instance as root's: false

--- errors ---
  no registered provider: Log (requested by: measurement table)
  circular dependency: A → B → A

The first three trace lines show resolution descending: the measurement service’s provider is found in the root scope, and while the service is built, its own dependencies resolve the same way. The component does not know how any of them are built — it only knows the keys.

The fourth line shows singleton behavior: the filter panel requests the same service and gets the root scope’s existing instance, not a new one. In the fifth and sixth lines, because the provider is registered per scope, the two scopes each get their own instance — the registration lives at the root, the instance lives in the requesting scope.

This distinction is a design decision with direct correctness consequences. The measurement source should be shared — two components opening separate connections would be pointless. Filter state should not be shared — if the table’s filter were the panel’s, the two components would overwrite each other’s state.

The Singleton Trap

Sections (a) and (b) show a trap common to every scoped resolution mechanism.

In the test scope, the measurement source is overridden with a fake, but the result does not change. The trace explains why: the measurement service was already built in the root scope, and its instance sits there. The new scope’s request returns that existing instance, which took the real source at build time and never asks again.

Section (b) shows the correct path: a fresh root is built for testing, the provider registrations are copied over, and the overridden key is re-registered there. With no instance built yet, resolution happens from scratch, and the fake source enters the chain.

The rule is this: overriding has to happen before the instance is built. Overriding a dependency in a singleton scope requires rebuilding that entire scope.

Error messages are also a feature of this family: an unregistered key reports which scope asked for what, and a circular dependency reports the entire chain. As indirection grows, the error has to explain itself.

The Decision Surface

Integration’s gain is not in individual features but in the number of decisions. This can be counted.

// decision-surface.mjs — integration's effect on decision and compatibility surface
const ROLES = [
  "view layer", "routing", "form model", "data fetching",
  "dependency resolution", "shared state", "server rendering", "testing tools",
];

// A profile: which package covers each role.
const profiles = {
  "library assembly": ROLES.map((r) => r),                       // every role is its own package
  "core + plugin": ROLES.map((r, i) => (i < 3 ? "core" : r)),
  "batteries-included": ROLES.map(() => "framework"),
};

const pairwise = (m) => (m * (m - 1)) / 2;

console.log("profile                  independent       version        pairwise          reviewed on  roles changed");
console.log("                             package        stream          compat      breaking change        at once");
for (const [name, assignment] of Object.entries(profiles)) {
  const packages = [...new Set(assignment)];
  const m = packages.length;
  // If the package carrying the most roles is dropped, how many roles change at once?
  const largest = Math.max(...packages.map((p) => assignment.filter((a) => a === p).length));
  console.log(
    `${name.padEnd(20)} ${String(m).padStart(15)} ${String(m).padStart(13)} ${String(pairwise(m)).padStart(15)} ` +
    `${String(Math.max(m - 1, 0)).padStart(20)} ${String(largest).padStart(14)}`
  );
}

// Same calculation as role count changes: the compatibility surface grows quadratically.
console.log("\nseparate packages  pairwise compat relationships");
for (const m of [1, 2, 4, 6, 8, 12]) console.log(`${String(m).padStart(17)} ${String(pairwise(m)).padStart(24)}`);

// How removable a role is from the framework: how tightly the framework binds that role.
const COUPLING = { "no contract": 0, "via interface": 1, "direct type dependency": 2, "code generation": 3 };
const sample = [
  ["routing", "code generation"],
  ["form model", "direct type dependency"],
  ["data fetching", "via interface"],
  ["shared state", "no contract"],
];
console.log("\nrole               coupling form              exit score");
let total = 0;
for (const [role, form] of sample) {
  total += COUPLING[form];
  console.log(`${role.padEnd(18)} ${form.padEnd(26)} ${String(COUPLING[form]).padStart(17)}`);
}
console.log(`total exit cost (0 = free, ${sample.length * 3} = fully bound): ${total}`);
profile                  independent       version        pairwise          reviewed on  roles changed
                             package        stream          compat      breaking change        at once
library assembly                   8             8              28                    7              1
core + plugin                      6             6              15                    5              3
batteries-included                 1             1               0                    0              8

separate packages  pairwise compat relationships
                1                        0
                2                        1
                4                        6
                6                       15
                8                       28
               12                       66

role               coupling form              exit score
routing            code generation                            3
form model         direct type dependency                     2
data fetching      via interface                              1
shared state       no contract                                0
total exit cost (0 = free, 12 = fully bound): 6

Covering eight roles with separate packages means eight decisions, eight independent version streams, and 28 pairwise compatibility relationships. The relationship count grows with the square of the package count — 66 at twelve packages. Each relationship is a claim that two packages’ specific versions work together, and the team has to verify that claim.

In the batteries-included profile, this number is zero: compatibility among the parts is the framework’s responsibility, and the team follows a single version stream. A new team member learns one contract, not the combination of eight libraries.

The last column shows the cost. In library assembly, dropping one package changes only one role, and roles can migrate separately, at separate times. In the batteries-included profile, dropping the framework changes eight roles at once — the decision stops being reversible.

The Cost of Contract Unity

The last table shows how to measure this cost: how removable a role is depends on how the framework binds it. A role bound only through an interface can be replaced by another implementation satisfying that interface; a role bound through code generation leans on the framework down to the shape of the source text.

Measuring the application’s exit cost turns the framework choice from a preference into an investment decision. The measure is which parts can be written independently of the framework: validation rules, formatting, the state machines inside headless components, and pure presentational components do not know the framework and carry over as-is in a migration.

The second cost is version management: a single version stream means a framework upgrade touches every layer at once. A breaking change can span routing, the form, and dependency resolution together, and the upgrade cannot be split into small steps. Separate packages let upgrades be sequenced, but in exchange the team carries 28 compatibility relationships.

The third cost is flexibility: if the framework’s answer for a role does not fit the application’s needs, changing it means working against the framework’s assumptions. In an integrated framework, stepping outside the beaten path costs markedly more than staying on it.

The Family’s Profile

This family is not an update model; it is a scope decision — it can contain a virtual tree, runtime dependency tracking, or compile-time analysis inside it. Its distinguishing feature is that the roles above the component layer are part of the framework.

The gain is fewer decisions and compatibility relationships, a single contract shared across the team, and a narrower surface for a new member to learn. The cost is a single version stream, upgrades that cannot be split, and an exit cost paid all at once.

Summary

  • A full-stack framework gathers roles like routing, forms, data fetching, dependency resolution, and testing into a single contract, alongside the view layer.
  • In dependency injection, a component does not build a service, it asks for one by key; the scope chain finds the provider, and the registration form determines where the instance lives.
  • Overriding a singleton-registered dependency has no effect once the instance is built; for testing, the scope is built from scratch.
  • The decision surface is countable: eight roles in separate packages produce eight version streams and 28 pairwise compatibility relationships; in an integrated framework, this number drops to zero.
  • In exchange, the exit cost is paid all at once: dropping the framework changes eight roles simultaneously.
  • How removable a role is depends on its coupling form; a role bound through an interface is replaceable, a role bound through code generation leans on the framework down to the source text.

Next Step

All four families now have their characteristics, costs, and trade-offs laid out. One question remains, and its answer lies not in the families but in the project itself: which features does this application actually need? The next lesson builds not a comparison table but a decision framework: it ties criteria like team size and experience, interaction density, the rendering strategy requirement, ecosystem depth, and maintenance cost to explicit weights, computes which criterion has to flip the decision, and measures how much of the decision stays reversible.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close