Skip to content
academia.sh

Lesson 09 / 30

Composite

Handling tree structures uniformly: comparing, in a consolidated shipment tree, the arrangement where four calculations each repeat the type check in their own body against the arrangement putting the leaf and the node behind the same interface, by check count; measuring the files touched when a new node type and a new operation are added; the choice between transparency and safety.

Contents

The bridge separated two independent axes, but the shipment list it reported on was flat: six shipments, all at the same level. The domain breaks this. A shipment can be a consolidated shipment carrying other shipments inside it, and one of its parts can itself be consolidated. Weight total, fee, delivery time, and part count are now computed over a tree.

The problem is that every calculation asks “is this a single piece or consolidated” in its own body. With four calculations, the same type check is written four times, and a fifth calculation brings a fifth copy. The composite pattern removes the check from the calculations by putting the leaf and the node behind the same interface. The measure is type-check count; the pattern will be tested along two directions of growth — a new node type and a new operation.

Problem: The Tree’s Shape Leaks into Every Calculation

The tree and the tariff are common to both arrangements. A consolidated node has its own packaging weight, a leaf has a tier fee by postal code, and delivery days by zone.

// tree.mjs — the shipment tree both arrangements compute, and the shared tariff
const TIER = [[1, 4990], [5, 8490], [15, 14990], [30, 24990]];
const COEFFICIENT = { 34: 100, "06": 115, 35: 120, 65: 145 };
const DAYS = { 34: 1, "06": 2, 35: 2, 65: 4 };

export const tierFee = (weight, postalCode) => {
  const t = TIER.find(([cap]) => weight <= cap) ?? [0, 24990];
  return Math.round((t[1] * (COEFFICIENT[postalCode.slice(0, 2)] ?? 165)) / 100);
};

export const zoneDays = (postalCode) => DAYS[postalCode.slice(0, 2)] ?? 5;

export const TREE = {
  type: "consolidated", code: "K-1", packaging: 0.5, parts: [
    { type: "package", code: "G-1", weight: 0.8, postalCode: "34100" },
    { type: "consolidated", code: "K-2", packaging: 0.3, parts: [
      { type: "package", code: "G-2", weight: 12, postalCode: "06500" },
      { type: "package", code: "G-3", weight: 3, postalCode: "35400" },
    ] },
    { type: "package", code: "G-4", weight: 18, postalCode: "06800" },
  ],
};

In the first arrangement, four calculations are four separate files, and all four begin with the same check.

// check/weight.mjs — every operation repeats the type check in its own body
export function weight(g) {
  if (g.type === "consolidated") return g.packaging + g.parts.reduce((t, p) => t + weight(p), 0);
  return g.weight;
}
// check/fee.mjs — a consolidated shipment gets a five percent volume discount from three parts on
import { tierFee } from "../tree.mjs";

export function fee(g) {
  if (g.type === "consolidated") {
    const total = g.parts.reduce((t, p) => t + fee(p), 0);
    return g.parts.length >= 3 ? Math.round(total * 0.95) : total;
  }
  return tierFee(g.weight, g.postalCode);
}
// check/duration.mjs — a consolidated shipment takes as long as its slowest part, plus one transfer day
import { zoneDays } from "../tree.mjs";

export function duration(g) {
  if (g.type === "consolidated") return Math.max(...g.parts.map(duration)) + 1;
  return zoneDays(g.postalCode);
}
// check/parts.mjs — the leaf count in the tree
export function partCount(g) {
  if (g.type === "consolidated") return g.parts.reduce((t, p) => t + partCount(p), 0);
  return 1;
}

All four files know two things at once: the calculation itself, and how the tree gets traversed. The second piece of knowledge is identical across all four.

Solution: Leaf and Node on the Same Interface

The composite’s solution is to give the traversal knowledge to the node itself. The shared interface consists of four methods, and both leaf and node satisfy all four.

// composite/package.mjs — leaf: answers all four operations itself
import { tierFee, zoneDays } from "../tree.mjs";

export class Package {
  constructor({ code, weight, postalCode }) {
    this.code = code;
    this.kg = weight;
    this.postalCode = postalCode;
  }
  weight() { return this.kg; }
  fee() { return tierFee(this.kg, this.postalCode); }
  duration() { return zoneDays(this.postalCode); }
  partCount() { return 1; }
}
// composite/consolidated.mjs — node: asks its parts through the same interface, does not know their type
export class Consolidated {
  constructor({ code, packaging, parts }) {
    this.code = code;
    this.packaging = packaging;
    this.parts = parts;
  }
  weight() { return this.packaging + this.parts.reduce((t, p) => t + p.weight(), 0); }
  fee() {
    const total = this.parts.reduce((t, p) => t + p.fee(), 0);
    return this.parts.length >= 3 ? Math.round(total * 0.95) : total;
  }
  duration() { return Math.max(...this.parts.map((p) => p.duration())) + 1; }
  partCount() { return this.parts.reduce((t, p) => t + p.partCount(), 0); }
}

The node’s body has no type check for p: a part can be a leaf or another consolidated shipment. The check is pulled into a single place — where the object tree gets built from data.

// composite/main.mjs — the only place the type check remains: builds the object tree from data
import { Package } from "./package.mjs";
import { Consolidated } from "./consolidated.mjs";

const NODE = { consolidated: Consolidated };

export const build = (data) => data.type === "package"
  ? new Package(data)
  : new NODE[data.type]({ ...data, parts: data.parts.map(build) });
// run.mjs — do the two arrangements give the same four results for the same tree
import { TREE } from "./tree.mjs";
import { weight } from "./check/weight.mjs";
import { fee } from "./check/fee.mjs";
import { duration } from "./check/duration.mjs";
import { partCount } from "./check/parts.mjs";
import { build } from "./composite/main.mjs";

const n = build(TREE);
const a = [weight(TREE).toFixed(2), fee(TREE), duration(TREE), partCount(TREE)];
const b = [n.weight().toFixed(2), n.fee(), n.duration(), n.partCount()];
console.log(`check    : weight=${a[0]} kg  fee=${a[1]}  duration=${a[2]} days  parts=${a[3]}`);
console.log(`composite: weight=${b[0]} kg  fee=${b[1]}  duration=${b[2]} days  parts=${b[3]}`);
console.log(`mismatched results = ${a.filter((v, i) => String(v) !== String(b[i])).length} / 4`);
check    : weight=34.60 kg  fee=58098  duration=4 days  parts=4
composite: weight=34.60 kg  fee=58098  duration=4 days  parts=4
mismatched results = 0 / 4

At two levels of depth, with three leaves and one inner node, all four calculations agree. The difference is in how many checks there are.

// measure.mjs — measures the type check count and the number of files aware of the tree's inner shape
import { readdirSync, readFileSync } from "node:fs";

for (const dir of ["check", "composite"]) {
  const files = readdirSync(dir).filter((d) => d.endsWith(".mjs")).sort();
  let check = 0, shape = 0, line = 0;
  for (const d of files) {
    const text = readFileSync(`${dir}/${d}`, "utf8");
    check += (text.match(/type === /g) ?? []).length;
    if (/\bparts\b/.test(text)) shape += 1;
    line += text.split("\n").filter((s) => s.trim() !== "" && !s.trim().startsWith("//")).length;
  }
  console.log(`${dir.padEnd(9)} files=${files.length}  type checks=${check}  ` +
    `files aware of tree shape=${shape}  lines=${line}`);
}
check     files=4  type checks=4  files aware of tree shape=4  lines=21
composite files=3  type checks=1  files aware of tree shape=2  lines=32

The type check drops from 4 to 1; the number of files aware of the tree’s inner shape (the parts field) drops from 4 to 2. The composite arrangement is 11 lines longer — that is the pattern’s upfront cost.

When a New Node Type Is Added

The first direction of growth is a new node type. A pallet departs from a consolidated shipment in three points: a 25 kg tare weight, a fixed 3500-cent pallet fee, and a two-day transfer. A pallet node enters the tree.

mkdir -p new-node-type && cp -r tree.mjs check composite run.mjs measure.mjs new-node-type/
ls new-node-type
check
composite
measure.mjs
run.mjs
tree.mjs
// new-node-type/composite/pallet.mjs — second node type: tare weight, fixed pallet fee, two-day transfer
export class Pallet {
  constructor({ code, parts }) {
    this.code = code;
    this.parts = parts;
  }
  weight() { return 25 + this.parts.reduce((t, p) => t + p.weight(), 0); }
  fee() { return 3500 + this.parts.reduce((t, p) => t + p.fee(), 0); }
  duration() { return Math.max(...this.parts.map((p) => p.duration())) + 2; }
  partCount() { return this.parts.reduce((t, p) => t + p.partCount(), 0); }
}

In the check arrangement, all four calculations each gain a branch. The script below puts the pallet in the tree, edits all four files, writes the new type into the node registry, and counts both arrangements.

cd new-node-type
sed -i.y 's#    { type: "package", code: "G-4", weight: 18, postalCode: "06800" },#    { type: "pallet", code: "P-1", parts: [\n      { type: "package", code: "G-4", weight: 18, postalCode: "06800" },\n      { type: "package", code: "G-5", weight: 6, postalCode: "65100" },\n    ] },#' tree.mjs
sed -i.y 's#^  if (g.type === "consolidated") return g.packaging#  if (g.type === "pallet") return 25 + g.parts.reduce((t, p) => t + weight(p), 0);\n&#' check/weight.mjs
sed -i.y 's#^  if (g.type === "consolidated") {#  if (g.type === "pallet") return 3500 + g.parts.reduce((t, p) => t + fee(p), 0);\n&#' check/fee.mjs
sed -i.y 's#^  if (g.type === "consolidated") return Math.max#  if (g.type === "pallet") return Math.max(...g.parts.map(duration)) + 2;\n&#' check/duration.mjs
sed -i.y 's#^  if (g.type === "consolidated") return g.parts.reduce#  if (g.type === "pallet") return g.parts.reduce((t, p) => t + partCount(p), 0);\n&#' check/parts.mjs
sed -i.y -e 's#^import { Consolidated } from "./consolidated.mjs";#&\nimport { Pallet } from "./pallet.mjs";#' \
         -e 's#^const NODE = { consolidated: Consolidated };#const NODE = { consolidated: Consolidated, pallet: Pallet };#' composite/main.mjs
rm -f *.y check/*.y composite/*.y
node run.mjs
node measure.mjs
for d in check composite; do
  echo "$d: edited files=$(diff -rq "../$d" "$d" | grep -c '^Files ')  new files=$(diff -rq "../$d" "$d" | grep -c '^Only in ')  added lines=$(diff -rN "../$d" "$d" | grep '^>' | grep -cvE '^> *(//|$)')"
done
check    : weight=65.60 kg  fee=82072  duration=7 days  parts=5
composite: weight=65.60 kg  fee=82072  duration=7 days  parts=5
mismatched results = 0 / 4
check     files=4  type checks=8  files aware of tree shape=4  lines=25
composite files=4  type checks=1  files aware of tree shape=3  lines=43
check: edited files=4  new files=0  added lines=4
composite: edited files=1  new files=1  added lines=12

Four existing files were edited in the check arrangement and the type check went from 4 to 8; in the composite arrangement one file was edited (the node registry) and the check count stayed at 1. The four edited bodies also carried the logic of two types that already worked correctly; the 12 added lines, by contrast, sit in a new file. The check arrangement’s line cost is low (4 versus 12) because each file gained a single line — but all four of those single lines entered an existing condition chain.

One detail shows the check arrangement’s hidden risk: parts.mjs’s pallet branch is identical to its consolidated branch. Rewriting the check in this file as g.type !== "package" looks shorter, but a third node type would make that file silently answer wrong. The composite arrangement has no such shortcut, because the type itself gives the answer.

When a New Operation Is Added

The second direction of growth is the reverse: a new operation, the heaviest leaf’s code. Since the tree does not change, the base version is copied fresh.

mkdir -p new-operation && cp -r tree.mjs check composite measure.mjs new-operation/
ls new-operation/check
duration.mjs
fee.mjs
parts.mjs
weight.mjs
// new-operation/check/heaviest.mjs — fifth operation: the heaviest leaf; a type check once more
export function heaviestPart(g) {
  if (g.type === "consolidated") {
    return g.parts.map(heaviestPart).reduce((a, b) => (b[1] > a[1] ? b : a));
  }
  return [g.code, g.weight];
}
cd new-operation
sed -i.y 's#^  partCount() { return 1; }#&\n  heaviestPart() { return [this.code, this.kg]; }#' composite/package.mjs
sed -i.y 's#^  partCount() { return this.parts.reduce((t, p) => t + p.partCount(), 0); }#&\n  heaviestPart() {\n    return this.parts.map((p) => p.heaviestPart()).reduce((a, b) => (b[1] > a[1] ? b : a));\n  }#' composite/consolidated.mjs
rm -f composite/*.y
cat > run2.mjs <<'RUN'
// new-operation/run2.mjs — does the fifth operation find the same leaf in both arrangements
import { TREE } from "./tree.mjs";
import { heaviestPart } from "./check/heaviest.mjs";
import { build } from "./composite/main.mjs";

console.log(`check    : heaviest = ${heaviestPart(TREE).join(" ")}`);
console.log(`composite: heaviest = ${build(TREE).heaviestPart().join(" ")}`);
RUN
node run2.mjs
for d in check composite; do
  echo "$d: edited files=$(diff -rq "../$d" "$d" | grep -c '^Files ')  new files=$(diff -rq "../$d" "$d" | grep -c '^Only in ')  added lines=$(diff -rN "../$d" "$d" | grep '^>' | grep -cvE '^> *(//|$)')"
done
check    : heaviest = G-4 18
composite: heaviest = G-4 18
check: edited files=0  new files=1  added lines=6
composite: edited files=2  new files=0  added lines=4

The direction reversed: the new operation touched no existing file in the check arrangement, while both classes were edited in the composite arrangement. With three node types, three classes would have been edited. This is the expression problem defined in the Mixing Paradigms lesson of the Programming Paradigms course: the composite makes adding a new type cheaper (4 edited files → 1), and adding a new operation more expensive (0 edited files → 2). Choosing the pattern rests on which axis grows more often, and the source of that measure is the change log.

Cost and When Not to Apply It

The composite has three costs. The first is the upfront line cost: 21 lines against 32, and two new types. The second is the cost paid on the operation axis — every new operation touches every node type’s body. The third is the interface’s scope. The shared interface here consists of four queries; a node operation like add was not put into it. Had it been, the leaf would either silently ignore it or throw: a choice between transparency (leaf and node indistinguishable) and safety (no meaningless method on the leaf), and safety was chosen here. The consequence is that code changing the tree must know the node type.

The pattern does not pay off in two situations. If depth is fixed at one — the shipment list is always a single level — there is no recursion and no type check; the composite only adds two classes. If the operation count grows much faster than the node-type count, the expensive side of the expression problem is hit; in that case keeping the calculations separate from a single traversal routine over the tree edits fewer files.

Summary

  • The problem the composite solves is every calculation over a tree repeating the “leaf or node” check in its own body; the solution is putting leaf and node behind the same interface.
  • For four calculations, the type check dropped from 4 to 1, and the files aware of the tree’s inner shape from 4 to 2; the cost is 11 lines and two new types.
  • The new node type (pallet) edited 4 existing files in the check arrangement and pushed the check count to 8; in the composite arrangement 1 file was edited, 1 new file was added, the check count stayed at 1, and all four calculations agreed across both arrangements.
  • The new operation (heaviest leaf) showed the reverse: 0 existing files edited in the check arrangement, 2 in the composite arrangement — the two sides of the expression problem.
  • No tree-changing method was put into the shared interface, so the leaf carries no meaningless method; safety was chosen over transparency. The pattern does not pay off when depth is fixed or growth on the operation axis dominates.

Next Step

In the composite, a node asks its parts through the same interface as itself; what gets wrapped is a collection, and wrapping builds the tree. The next question uses the same wrapping technique on a single object, for a different purpose: a shipment’s fee needs a fuel surcharge, insurance, a contracted-customer discount, and tax applied in sequence. These four additions are not wanted on every shipment, they toggle independently, and their order changes the result. If every combination of options becomes a subclass, the class count grows as a power of two in the option count. The next lesson measures this count for four options, writes the decorator pattern that wraps the fee in the same interface and layers new behavior onto it, and counts its cost in calls to trace and order sensitivity.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close