Lesson 13 / 19
Hollywood Principle
Reversing the direction between caller and callee: comparing an arrangement where three separate callers write the step order against one where the steps register themselves with a skeleton, counting how many files know the order, and measuring the files edited and lines touched when a new step is added.
Contents
In the previous lesson’s abstract layout, the composition root decided which price list to use, but it still called the clients itself. The order and count of the steps lived in the caller’s body. Adding a new step to this flow required editing the caller’s body every time.
The Hollywood principle reverses the direction: “do not call us, we will call you.” Parts do not call the high-level flow; the high-level flow gives the parts a point to be called from. Inversion of control, introduced in the Asynchronous JavaScript and the Runtime course, is the general name for this principle; what is measured here is a specific form of it — which module the step order is written in.
Arrangement Where the Caller Writes the Order
The fee calculation consists of three steps: the base fee, the zone factor, the contracted customer discount. Three separate callers write this order in their own body.
mkdir -p caller skeleton
// records.mjs — sample shipments used by both arrangements export const RECORDS = [ { code: "GN-1", weight: 0.8, address: "34100", contracted: false }, { code: "GN-2", weight: 3.0, address: "06500", contracted: true }, { code: "GN-3", weight: 12.0, address: "65200", contracted: true }, ];
// caller/steps.mjs — steps; they do not know the order, they do not know the caller either const TIER = [[1, 3900], [5, 6400], [20, 11800]]; const ZONE = { "34": 100, "06": 115, "65": 140 }; export const base = (cents, s) => TIER.find(([max]) => s.weight <= max)?.[1] ?? 15000; export const zone = (cents, s) => Math.round((cents * (ZONE[s.address.slice(0, 2)] ?? 160)) / 100); export const discount = (cents, s) => Math.round((cents * (s.contracted ? 90 : 100)) / 100);
// caller/invoice.mjs — the caller writes the order import { base, zone, discount } from "./steps.mjs"; export function invoice(shipment) { let cents = base(0, shipment); cents = zone(cents, shipment); cents = discount(cents, shipment); return `${shipment.code} ${cents} cents`; }
// caller/preview.mjs — writes the same order a second time import { base, zone, discount } from "./steps.mjs"; export function preview(shipment) { let cents = base(0, shipment); cents = zone(cents, shipment); cents = discount(cents, shipment); return `~${cents}`; }
// caller/batch.mjs — writes the same order a third time import { base, zone, discount } from "./steps.mjs"; export function total(shipments) { let grandTotal = 0; for (const s of shipments) { let cents = base(0, s); cents = zone(cents, s); cents = discount(cents, s); grandTotal += cents; } return `TOTAL ${grandTotal} cents`; }
// caller/setup.mjs — composition root import { RECORDS } from "../records.mjs"; import { invoice } from "./invoice.mjs"; import { preview } from "./preview.mjs"; import { total } from "./batch.mjs"; console.log(invoice(RECORDS[1]), preview(RECORDS[1]), total(RECORDS));
Arrangement Where the Skeleton Knows the Order
In the second arrangement, the order lives in a skeleton. The steps do not call the skeleton; they register themselves with it and are called when their turn comes.
// skeleton/pipeline.mjs — skeleton: calls the steps in order, does not know their names in advance const STEPS = []; export const addStep = (name, fn) => STEPS.push({ name, fn }); export const stepNames = () => STEPS.map((s) => s.name); export function run(shipment) { let cents = 0; for (const s of STEPS) cents = s.fn(cents, shipment); return cents; }
// skeleton/steps.mjs — steps register themselves with the skeleton; they do not call it import { addStep } from "./pipeline.mjs"; const TIER = [[1, 3900], [5, 6400], [20, 11800]]; const ZONE = { "34": 100, "06": 115, "65": 140 }; addStep("base", (cents, s) => TIER.find(([max]) => s.weight <= max)?.[1] ?? 15000); addStep("zone", (cents, s) => Math.round((cents * (ZONE[s.address.slice(0, 2)] ?? 160)) / 100)); addStep("discount", (cents, s) => Math.round((cents * (s.contracted ? 90 : 100)) / 100));
// skeleton/invoice.mjs — does not know the order, calls the skeleton import { run } from "./pipeline.mjs"; export const invoice = (shipment) => `${shipment.code} ${run(shipment)} cents`;
// skeleton/preview.mjs — does not know the order import { run } from "./pipeline.mjs"; export const preview = (shipment) => `~${run(shipment)}`;
// skeleton/batch.mjs — does not know the order import { run } from "./pipeline.mjs"; export const total = (shipments) => `TOTAL ${shipments.reduce((t, s) => t + run(s), 0)} cents`;
// skeleton/setup.mjs — composition root: loads the steps, then calls the clients import "./steps.mjs"; import { RECORDS } from "../records.mjs"; import { stepNames } from "./pipeline.mjs"; import { invoice } from "./invoice.mjs"; import { preview } from "./preview.mjs"; import { total } from "./batch.mjs"; console.log(invoice(RECORDS[1]), preview(RECORDS[1]), total(RECORDS)); console.log("steps:", stepNames().join(" -> "));
node caller/setup.mjs node skeleton/setup.mjs
GN-2 6624 cents ~6624 TOTAL 25392 cents GN-2 6624 cents ~6624 TOTAL 25392 cents steps: base -> zone -> discount
The same result. The difference is what shows up in the stepNames() line: in the second
arrangement, the step list is data that can be queried at run time; in the first, it is a
control flow scattered across three separate bodies.
The Cost of a New Step
An insurance premium step is being added: the amount rises by two percent after the discount.
In the first arrangement, a new function is written and added to three callers’ bodies; in
the second, a single registration line is enough. A backup extension is given for the
in-place edit; GNU and BSD sed behave the same way in this form.
cp -r caller caller-new cp -r skeleton skeleton-new cat >> caller-new/steps.mjs <<'EOF' export const insurance = (cents) => Math.round((cents * 102) / 100); EOF sed -i.y 's/base, zone, discount/base, zone, discount, insurance/' caller-new/*.mjs sed -i.y 's/^ cents = discount(cents, shipment);/&\n cents = insurance(cents);/' caller-new/*.mjs sed -i.y 's/^ cents = discount(cents, s);/&\n cents = insurance(cents);/' caller-new/*.mjs rm -f caller-new/*.y cat >> skeleton-new/steps.mjs <<'EOF' addStep("insurance", (cents) => Math.round((cents * 102) / 100)); EOF node caller-new/setup.mjs node skeleton-new/setup.mjs for k in caller skeleton; do echo "$k: files edited = $(diff -rq $k $k-new | grep -c '^Files')" \ " lines touched = $(diff -rU0 $k $k-new | grep -cE '^[+-][^+-]')" \ " files that know the order = $(grep -l 'cents = ' $k/*.mjs | wc -l | tr -d ' ')" done
GN-2 6756 cents ~6756 TOTAL 25899 cents GN-2 6756 cents ~6756 TOTAL 25899 cents steps: base -> zone -> discount -> insurance caller: files edited = 4 lines touched = 10 files that know the order = 3 skeleton: files edited = 1 lines touched = 1 files that know the order = 1
Both arrangements produce the same new result. The cost is four files and ten lines against one file and one line. The last column gives the reason: the first arrangement had three files that knew the order, the second had one.
The measure says something different from what the open–closed principle measures. There, the cost of adding a new type was counted; here, what is counted is the cost of adding a new step to an existing flow. Both point in the same direction: the change lands on data, not on text.
Reversing the Direction
The difference between the two arrangements also shows up in the import graph. In the first
arrangement, invoice.mjs imports the steps and calls them; control flows top-down. In the
second, steps.mjs imports the skeleton and registers with it; the call direction is built
bottom-up, while the execution direction stays top-down. The principle carries this name
because the relationship between caller and callee is reversed.
This reversal is not the same thing as dependency inversion. There, the question was which side the abstraction is defined on; here, the question is which side the control flow is written on. The two principles are used together: the skeleton defines the steps’ contract (dependency inversion) and also holds the order in which the steps are called (Hollywood principle).
The Cost of the Skeleton
The reversal’s cost is paid in readability. In the first arrangement, reading a single body
is enough to see the flow; in the second, the order depends on the run-time arrangement of the
registration calls, and that arrangement comes from the composition root’s import order. A
query like stepNames() is therefore necessary: once the order becomes data, that data has to
be readable.
The second cost is in debugging. When a step produces a wrong result, the call chain shows up directly in the stack trace in the first arrangement; in the second, the skeleton’s loop gets in the way. The criterion is again a number: if the number of files that know the order is greater than one, and requests to add a step to the flow keep recurring, the skeleton wins; if the flow is fixed and there is a single caller, the skeleton is only a layer of indirection.
Summary
- The Hollywood principle reverses the direction between caller and callee: parts do not call the flow, the flow gives parts a point to be called from.
- The measure is how many files the step order is written in: 3 files knew the order in the caller arrangement, 1 in the skeleton arrangement.
- When a new step was added to the flow, the caller arrangement had 4 files and 10 lines edited, the skeleton arrangement had 1 file and 1 line.
- In the skeleton arrangement, the step list is data that can be queried at run time; in the caller arrangement, it is a control flow scattered across three separate bodies.
- The cost is in readability and debugging; with a single caller and a fixed flow, the skeleton only adds a layer of indirection.
Next Step
Every measure up to this point has been about pairs of modules: the strength of the bond between two modules, how related the names inside one module are, how many objects a call passes through, the file set a change touches. Once a codebase grows, the real question changes shape — it is no longer which module binds to which, but where the boundary gets drawn. The answer to that question is hidden in how often decisions change — which decision is a stable policy that stands for years, and which is a detail that moves every time the price list is renewed. The next topic builds this distinction and measures both sides of the boundary by how often they change.
To keep your progress and take notes, Log in
My notes
Log in to take notes.