Skip to content
academia.sh

Lesson 09 / 18

Model–View Families

Producing the same offer screen with controller, presenter, and view model variants, and turning the responsibility distribution into numbers: how many field names the displaying file recognizes, how many files the import closure spans, how many calls and how many bytes cross the boundary, and which files the order and format decisions sit in.

Contents

The two styles covered so far addressed interaction between units of the same kind: both sides of the conversation were units that calculated a fee or announced a tariff, and the only difference was who initiated. The library also has a boundary of a different kind. The clerk at the operations desk does not see how the fee was calculated, only what appears on the screen: the zone name, the tier, the discount, and the amount due must be arranged in a specific form. The two sides of this boundary are not equal — one carries the rule and the data, the other displays it.

This lesson compares three separate distributions of the responsibility, and counts how many field names the displaying file recognizes, how many calls and how many bytes cross the boundary, and which files the order and format decisions sit in. All three variants must produce the same screen; only then does the measurement compare two arrangements of the same job.

Naming and the Boundary

The family names are abbreviated with three letters: model–view–controller, model–view–presenter, and the view model variant. The word presenter here is the side that presents the values to be displayed; it is not related to the waiting-side sense from the Client–Server lesson — it is the same word used in two separate catalogs.

The view model was established in the Server-Side Templating lesson in the Server-Side Fundamentals course: it is the contract between the template and the data source, and the calculation and data access happen not in the template but in the code that builds it. Template evaluation, escaping, and layout templates belong there and are not revisited here. The state management lessons in the Application Architecture: Routing, State and Data course were also on the interface side of the same boundary. What is new here is only the counting of the responsibility distribution.

The quality attribute it connects to is maintainability, and the quality question is: when the screen’s layout or the amount format changes, how many files are edited.

The Common Model

All three variants use the same calculation. The model returns five fields, and none of them carry formatting; numbers stay numbers, codes stay codes.

mkdir -p model mvc mvp vm
// model/price.mjs — pricing context: calculation and field names
export const PROVINCE_NAME = { "34": "Istanbul", "06": "Ankara", "65": "Van" };
const COEFFICIENT = { "34": 1, "06": 1.4, "65": 2.1 };

export function calculatePrice(shipment) {
  const tier = shipment.weight <= 1 ? 1 : shipment.weight <= 5 ? 2 : shipment.weight <= 20 ? 3 : 4;
  const raw = 25 * tier * COEFFICIENT[shipment.province];
  const discount = raw * shipment.contractRate;
  return { province: shipment.province, tier, raw, discount, net: raw - discount };
}

The Controller Variant

In the first variant, the controller takes the input, calls the model, and selects the view. The view receives the model’s result directly: it reads the field names itself and does its own formatting.

// mvc/view.mjs — the view reads the model's field names itself and formats them itself
import { PROVINCE_NAME } from "../model/price.mjs";

export const display = (result) =>
  [
    ["Zone", PROVINCE_NAME[result.province]],
    ["Tier", String(result.tier)],
    ["Base fee", `${result.raw.toFixed(2)} TL`],
    ["Discount", `${result.discount.toFixed(2)} TL`],
    ["Amount due", `${result.net.toFixed(2)} TL`],
  ]
    .map(([label, value]) => `${label.padEnd(11)}: ${value}`)
    .join("\n");

The Presenter Variant

In the second variant, the view is passive: it holds only what is written to it, knows no field name, and performs no calculation. The presenter reads from the model, formats, and writes to the view line by line.

// mvp/view.mjs — passive view: only holds what is written to it, knows no field name
export function view() {
  const lines = new Map();
  return {
    write: (label, value) => lines.set(label, value),
    text: () => [...lines].map(([l, v]) => `${l.padEnd(11)}: ${v}`).join("\n"),
  };
}
// mvp/presenter.mjs — reads from the model, formats, and writes to the passive view line by line
import { calculatePrice, PROVINCE_NAME } from "../model/price.mjs";
import { view } from "./view.mjs";

export function offerScreen(shipment, build = view) {
  const s = calculatePrice(shipment);
  const v = build();
  v.write("Zone", PROVINCE_NAME[s.province]);
  v.write("Tier", String(s.tier));
  v.write("Base fee", `${s.raw.toFixed(2)} TL`);
  v.write("Discount", `${s.discount.toFixed(2)} TL`);
  v.write("Amount due", `${s.net.toFixed(2)} TL`);
  return v.text();
}

The View Model Variant

In the third variant, a data structure sits in between. The view model reads from the model and produces fields ready for display; the view only reads those fields and decides how they are arranged.

// vm/view-model.mjs — separate data structure producing view-ready fields from the model
import { calculatePrice, PROVINCE_NAME } from "../model/price.mjs";

export function viewModel(shipment) {
  const s = calculatePrice(shipment);
  return {
    zoneName: PROVINCE_NAME[s.province],
    tierText: String(s.tier),
    baseFee: `${s.raw.toFixed(2)} TL`,
    discountAmount: `${s.discount.toFixed(2)} TL`,
    amountDue: `${s.net.toFixed(2)} TL`,
  };
}
// vm/view.mjs — reads only the view model's fields, performs no calculation
const ORDER = [
  ["Zone", "zoneName"],
  ["Tier", "tierText"],
  ["Base fee", "baseFee"],
  ["Discount", "discountAmount"],
  ["Amount due", "amountDue"],
];

export const display = (vm) => ORDER.map(([l, a]) => `${l.padEnd(11)}: ${vm[a]}`).join("\n");

The remaining two files are one line each: the controller, and the screen that builds the view model. Both take the view as a parameter, so that what crosses the boundary can be counted during measurement.

cat > mvc/controller.mjs <<'END'
// mvc/controller.mjs — takes the input, calls the model, selects the view
import { calculatePrice } from "../model/price.mjs";
import { display } from "./view.mjs";

export const offerScreen = (shipment, draw = display) => draw(calculatePrice(shipment));
END
cat > vm/screen.mjs <<'END'
// vm/screen.mjs — builds the view model and hands it to the view
import { viewModel } from "./view-model.mjs";
import { display } from "./view.mjs";

export const offerScreen = (shipment, draw = display) => draw(viewModel(shipment));
END

Measurement

The script first runs all three variants against the same shipment and verifies that their outputs are identical. It then counts how many model field names and how many presentation names appear in each family’s view file, extracts the view’s import closure, and totals the byte count by recording the calls that cross the boundary. The last table looks for two decisions: the file that determines the order of the rows, and the file that determines the amount format.

// responsibility.mjs — the three families producing the same screen, data crossing the boundary, and the field names the view knows
import { readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
import { offerScreen as mvc } from "./mvc/controller.mjs";
import { offerScreen as mvp } from "./mvp/presenter.mjs";
import { offerScreen as vmScreen } from "./vm/screen.mjs";
import { display as displayMvc } from "./mvc/view.mjs";
import { display as displayVm } from "./vm/view.mjs";

const SHIPMENT = { province: "06", weight: 12, contractRate: 0.15 };
const FIELD = ["province", "tier", "raw", "discount", "net"];
const PRESENTATION = ["zoneName", "tierText", "baseFee", "discountAmount", "amountDue"];

const closure = (root) => {
  const seen = new Set([root]), stack = [root];
  while (stack.length > 0) {
    const d = stack.pop();
    for (const [, y] of readFileSync(d, "utf8").matchAll(/from "(\.[^"]+)"/g)) {
      const h = normalize(join(dirname(d), y));
      if (seen.has(h) === false) { seen.add(h); stack.push(h); }
    }
  }
  return seen.size;
};

const countNames = (path, names) => {
  const text = readFileSync(path, "utf8");
  return names.filter((a) => new RegExp(`[.\\["']${a}\\b`).test(text)).length;
};

const recordCalls = (original) => {
  const log = [];
  return [(...a) => { log.push(a); return original(...a); }, log];
};

function runMvp() {
  const log = [];
  const build = () => {
    const lines = new Map();
    return {
      write: (l, v) => { log.push([l, v]); lines.set(l, v); },
      text: () => { log.push([]); return [...lines].map(([l, v]) => `${l.padEnd(11)}: ${v}`).join("\n"); },
    };
  };
  return [mvp(SHIPMENT, build), log];
}

const [drawMvc, logMvc] = recordCalls(displayMvc);
const [drawVm, logVm] = recordCalls(displayVm);
const [mvpOutput, logMvp] = runMvp();
const output = [mvc(SHIPMENT, drawMvc), mvpOutput, vmScreen(SHIPMENT, drawVm)];

console.log(output[0]);
console.log(`all three families' output the same = ${new Set(output).size === 1}`);

const FAMILY = [
  ["MVC", "mvc/view.mjs", ["mvc/view.mjs", "mvc/controller.mjs"], logMvc],
  ["MVP", "mvp/view.mjs", ["mvp/view.mjs", "mvp/presenter.mjs"], logMvp],
  ["VM ", "vm/view.mjs", ["vm/view.mjs", "vm/view-model.mjs", "vm/screen.mjs"], logVm],
];

console.log("\nfamily  field name  presentation name  closure  boundary calls  bytes crossed");
for (const [name, viewPath, , log] of FAMILY) {
  const bytes = log.reduce((t, a) => t + JSON.stringify(a).length, 0);
  console.log(
    `${name}   ${String(countNames(viewPath, FIELD)).padStart(6)}${String(countNames(viewPath, PRESENTATION)).padStart(15)}` +
      `${String(closure(viewPath)).padStart(9)}${String(log.length).padStart(15)}${String(bytes).padStart(12)}`,
  );
}

console.log("\nfamily  order-determining file       format-determining file      files touched");
for (const [name, , files] of FAMILY) {
  const find = (pattern) => files.find((d) => readFileSync(d, "utf8").includes(pattern)) ?? "-";
  const order = find('"Amount due"'), format = find("toFixed");
  console.log(`${name}   ${order.padEnd(24)}${format.padEnd(26)}${new Set([order, format]).size}`);
}
node responsibility.mjs
Zone       : Ankara
Tier       : 3
Base fee   : 105.00 TL
Discount   : 15.75 TL
Amount due : 89.25 TL
all three families' output the same = true

family  field name  presentation name  closure  boundary calls  bytes crossed
MVC        5              0        2              1          67
MVP        0              0        1              6         103
VM         0              5        1              1         111

family  order-determining file       format-determining file      files touched
MVC   mvc/view.mjs            mvc/view.mjs              1
MVP   mvp/presenter.mjs       mvp/presenter.mjs         1
VM    vm/view.mjs             vm/view-model.mjs         2

Reading the Numbers

All three variants produced the same five rows, so the measurement is comparing the same job.

The first column gives the obligation to know. In the controller variant, the view recognizes five model field names; in the other two, zero. The second column shows where that went: in the view model variant, the view recognizes five presentation names, so the knowledge did not disappear — it was translated from model names to presentation names. In the presenter variant, the view recognizes no name at all; it holds the written label and value exactly as given.

The third column is the concrete result of this. The import closure of the controller variant’s view file is 2: loading the view means loading the model too. The other two views have a closure of 1; both can be loaded and tested without the model.

The fourth and fifth columns measure what crosses the boundary and expose the trade-off. In the controller variant, a single call crossed the boundary and carried 67 bytes — raw numbers are short. In the presenter variant, six calls crossed and carried 103 bytes; each line is a separate call, and formatted strings are longer than a raw number. In the view model variant, a single call carried 111 bytes: five formatted fields together. The variant with the least data crossing the boundary is the variant where the view knows the most about the model. A cheap boundary and an uninformed view do not coexist in the same arrangement.

Where the Decisions Sit

The last table answers the maintainability question. The screen’s row order and the amount format are two separate decisions, and which file they sit in changes by family.

In the controller and presenter variants, both decisions sit in the same file, so a request that changes both touches a single file. In the view model variant, order sits in the view and format sits in the view model; the same request touches two files. This is the cost of the separation, and its payoff can be read in the same table: a request that changes only the order touches the view file in the view model variant, and that file does not know the model at all and can be tested on its own. In the controller variant, the same request requires editing the file that recognizes all five model field names.

The choice of criterion depends on the kind of request. If what changes often is the order, the separation pays off; if what changes often is which fields are displayed, editing two files together is the cost.

Summary

  • All three variants produced the same five-row screen; the difference is not in function but in the responsibility distribution between the model and the view.
  • The model field names the view recognizes are 5 in the controller variant, 0 in the other two; in the view model variant this knowledge was translated into 5 presentation names, and in the presenter variant no name at all remained in the view.
  • The view file’s import closure is 2 in the controller variant, 1 in the other two; the second and third views can be loaded without the model.
  • The data crossing the boundary grew in the opposite direction: 1 call and 67 bytes in the controller variant, 6 calls and 103 bytes in the presenter variant, 1 call and 111 bytes in the view model variant.
  • The order and format decisions sit in a single file in the first two variants and in two separate files in the view model variant; a request that changes both touches 2 files instead of 1.

Next Step

In all three variants, the data crossed the boundary in a single leap: the model’s result was handed over in one call, and formatting happened in one place. The pricing context’s actual workflow is not like that. When the daily shipment list is processed, each record passes through several steps in sequence: field names are normalized, the zone is derived, the tariff is applied, the discount is subtracted, the amount is rounded. Today these five steps sit inside a single body. When a new step — a fuel surcharge — is requested, that body must be edited, and none of the steps can be tested on its own. The next lesson splits the steps into separate units and measures the chain: the number of files edited when a new step is added, the number of names each step recognizes, the number of fields crossing the boundary between steps, and the number of records held in memory at once when records are streamed one by one.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close