Skip to content
academia.sh

Lesson 02 / 19

Open–Closed Principle

Measuring a design that is open to extension and closed to modification: a new tariff is added both to the version where the same tariff type is chosen through two separate switches and to the version where tariffs write themselves into a registry, and the number of existing files edited, the number of lines added, and whether the fee module changed are compared.

Contents

The single responsibility principle separated decisions by actor, but each actor still edited an existing file. Adding a new zone to the zone table means editing the object inside zone.mjs. As long as this is duplicating a row in the table, it is cheap. When a request touches the tariff’s type — a second tariff that charges by volume instead of weight — the cost turns out differently.

The open–closed principle separates these two operations: a module should be open to extension with new behavior and closed to modification of its existing text. The claim is measurable: the same requirement is applied to two designs, and the number of existing files edited, the number of lines added, and whether the fee module changed are counted.

The Version Where Type Is Chosen with a Switch

In the first version, the tariff type sits as a field on the shipment and is resolved with switch in two separate places: the fee’s base and the invoice line’s description.

// v1/fee.mjs — tariff type is chosen with two separate switches
export const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;

export function base(shipment) {
  switch (shipment.tariff) {
    case "weight":
      return shipment.weight <= 1 ? 3900 : shipment.weight <= 5 ? 6400 : 11800;
    case "fixed":
      return 4500;
    default:
      throw new RangeError(`unknown tariff: ${shipment.tariff}`);
  }
}

export function description(shipment) {
  switch (shipment.tariff) {
    case "weight":
      return `weight tier (${shipment.weight} kg)`;
    case "fixed":
      return "fixed fee";
    default:
      throw new RangeError(`unknown tariff: ${shipment.tariff}`);
  }
}

export const fee = (s) => Math.round((base(s) * zoneFactor(s.address)) / 100);
// v1/main.mjs — fee lines for sample shipments
import { fee, description } from "./fee.mjs";

export const SAMPLE = [
  { tariff: "weight", weight: 0.8, address: "34100", width: 20, length: 20, height: 15 },
  { tariff: "fixed", weight: 3.0, address: "06500", width: 30, length: 40, height: 50 },
];

for (const s of SAMPLE) console.log(`${fee(s)} cents  ${description(s)}`);
node v1/main.mjs
3900 cents  weight tier (0.8 kg)
5175 cents  fixed fee

In this design, the tariff type is data, and the behavior is the code that resolves the type. The two switch statements each enumerate the same list separately; a third usage site would mean a third list.

The Version Where Type Moves into a Registry

In the second version, a tariff is not a type tag but an object that carries behavior. Objects write themselves into a shared registry; the fee module asks the registry and never knows the type names.

// v2/registry.mjs — tariff registry: new tariffs register themselves in this table
const TARIFFS = new Map();

export function register(tariff) {
  TARIFFS.set(tariff.name, tariff);
}

export function tariff(name) {
  const t = TARIFFS.get(name);
  if (t === undefined) throw new RangeError(`unknown tariff: ${name}`);
  return t;
}
// v2/tariffs/weight.mjs — weight tier tariff
import { register } from "../registry.mjs";

register({
  name: "weight",
  base: (s) => (s.weight <= 1 ? 3900 : s.weight <= 5 ? 6400 : 11800),
  description: (s) => `weight tier (${s.weight} kg)`,
});
// v2/tariffs/fixed.mjs — fixed fee tariff
import { register } from "../registry.mjs";

register({ name: "fixed", base: () => 4500, description: () => "fixed fee" });
// v2/fee.mjs — fee calculation is blind to tariff type; the choice comes from the registry
import { tariff } from "./registry.mjs";

export const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;

export const fee = (s) => Math.round((tariff(s.tariff).base(s) * zoneFactor(s.address)) / 100);
export const description = (s) => tariff(s.tariff).description(s);
// v2/main.mjs — composition root: only this file knows which tariffs are in use
import "./tariffs/weight.mjs";
import "./tariffs/fixed.mjs";
import { fee, description } from "./fee.mjs";

export const SAMPLE = [
  { tariff: "weight", weight: 0.8, address: "34100", width: 20, length: 20, height: 15 },
  { tariff: "fixed", weight: 3.0, address: "06500", width: 30, length: 40, height: 50 },
];

for (const s of SAMPLE) console.log(`${fee(s)} cents  ${description(s)}`);
node v2/main.mjs
3900 cents  weight tier (0.8 kg)
5175 cents  fixed fee

The same output. The difference is not in behavior but in where a new type gets written. The list of tariff names lives in a single place, and that place is the composition root — the module introduced in the Layer Responsibilities lesson, the one that alone wires the parts together.

Measuring the Extension

The new requirement is a volume tariff: the volumetric weight calculation is derived from width, length, and height, and the base fee rises per volumetric kilogram. Both trees are copied and the change is applied to both.

cp -r v1 v1-new
cp -r v2 v2-new
// v1-new/fee.mjs — volume tariff added: both switches were edited
export const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;
const volumetricWeight = (s) => Math.ceil((s.width * s.length * s.height) / 5000);

export function base(shipment) {
  switch (shipment.tariff) {
    case "weight":
      return shipment.weight <= 1 ? 3900 : shipment.weight <= 5 ? 6400 : 11800;
    case "fixed":
      return 4500;
    case "volume":
      return 2600 + volumetricWeight(shipment) * 700;
    default:
      throw new RangeError(`unknown tariff: ${shipment.tariff}`);
  }
}

export function description(shipment) {
  switch (shipment.tariff) {
    case "weight":
      return `weight tier (${shipment.weight} kg)`;
    case "fixed":
      return "fixed fee";
    case "volume":
      return `volume tariff (${volumetricWeight(shipment)} volumetric kg)`;
    default:
      throw new RangeError(`unknown tariff: ${shipment.tariff}`);
  }
}

export const fee = (s) => Math.round((base(s) * zoneFactor(s.address)) / 100);
// v2-new/tariffs/volume.mjs — volume tariff: the fee module was not edited
import { register } from "../registry.mjs";

const volumetricWeight = (s) => Math.ceil((s.width * s.length * s.height) / 5000);

register({
  name: "volume",
  base: (s) => 2600 + volumetricWeight(s) * 700,
  description: (s) => `volume tariff (${volumetricWeight(s)} volumetric kg)`,
});

The remaining two edits in both trees belong to the sample data and the registration list; the script below applies them, runs both versions, and counts the difference. The in-place edit is given a backup extension; GNU and BSD sed behave the same this way.

SAMPLE='  { tariff: "volume", weight: 3.0, address: "06500", width: 30, length: 40, height: 50 },'
sed -i.y "s#^];#$SAMPLE\n];#" v1-new/main.mjs v2-new/main.mjs
sed -i.y 's#^import "./tariffs/fixed.mjs";#&\nimport "./tariffs/volume.mjs";#' v2-new/main.mjs
rm -f v1-new/*.y v2-new/*.y
node v1-new/main.mjs
node v2-new/main.mjs

measure() {
  echo "$1 -> $2"
  echo "  existing files edited = $(diff -rq $1 $2 | grep -c '^Files ')"
  echo "  new files             = $(diff -rq $1 $2 | grep -c '^Only in ')"
  echo "  lines added           = $(diff -rN $1 $2 | grep '^>' | grep -cvE '^> *(//|$)')"
  echo "  fee module changed    = $(diff -qI '^//' $1/fee.mjs $2/fee.mjs > /dev/null && echo no || echo yes)"
}
measure v1 v1-new
measure v2 v2-new
3900 cents  weight tier (0.8 kg)
5175 cents  fixed fee
12650 cents  volume tariff (12 volumetric kg)
3900 cents  weight tier (0.8 kg)
5175 cents  fixed fee
12650 cents  volume tariff (12 volumetric kg)
v1 -> v1-new
  existing files edited = 2
  new files             = 0
  lines added           = 6
  fee module changed    = yes
v2 -> v2-new
  existing files edited = 1
  new files             = 1
  lines added           = 9
  fee module changed    = no

The results are the same; the distribution of cost is not. The second version added more lines (9 to 6), because the new tariff stands as a complete object in its own file. Against that, the number of existing files edited dropped from two to one, and that one file is the composition root. The decisive line is the last one: the module that computes the fee changed in the first version and did not change in the second.

The comparison separates two kinds of cost. An added line is cheap: new text cannot break an existing behavior. An edited line is expensive: the base and description functions’ two tariffs that previously worked correctly must be reviewed again when the third tariff is added. The principle’s measure is not “how many lines were written” but “how many lines had to be re-verified.”

The Limit of Closedness

Closedness cannot be universal. The second version is closed to a new tariff type, but it is not closed to the tariff contract itself. Adding a third method to the tariff object — a maximum volumetric weight limit, for instance — requires editing every tariff file in the registry; the cost grows directly proportional to the number of tariffs.

The choice therefore rests on a prediction of which axis will change. If the number of tariffs is growing, the second version wins; if the tariff contract is moving and the number of types stays at two or three, switch is cheaper, because the registry apparatus is itself a cost. The principle does not say to build an abstraction everywhere; it says to measure which axis change arrives on and close against that axis. The source of the measure is not prediction, either, but past extension requests.

Summary

  • The open–closed principle distinguishes extension (adding new text) from modification (editing existing text), and it bounds the second.
  • When the same requirement was applied to both designs, the switch version edited 2 existing files and changed the fee module; the registry version edited 1 file, added 1 new file, and did not touch the fee module.
  • The registry version added more lines (9 to 6); the principle’s measure is not the number of lines written but the number of lines forced to be re-verified.
  • Closedness holds on a single axis: the registry version is closed to a new tariff type, not to a change in the tariff contract.
  • Which axis to close against is determined not by prediction but by past extension requests.

Next Step

In the registry version, the fee module worked without asking which tariff it received, because every tariff satisfies the same contract. This rests on the assumption that the contract is genuinely satisfied. If a new tariff defines the base method but returns a negative value, or works only for certain shipments and throws for the rest, the fee module breaks without changing. The next lesson defines what a subtype contract means, writes a tariff that breaks the contract, and runs a contract test that operates through the supertype with two tariffs to show it breaks in the violating version.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close