Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close