Skip to content
academia.sh

Lesson 12 / 14

Composition

Building large behavior from small functions: splitting the fee calculation into a four-step pipeline, counting how many tests the same defect drops across two designs, and measuring how many cents the result shifts when two steps swap places.

Contents

The previous lesson turned rules into functions and applied them in sequence with reduce. The intermediate results were never named anywhere: the rule list passed through as an array, and the core called them one after another. As the number of rules grows, the real question becomes the order itself — whether the discount runs before or after the surcharge, and where the minimum-fee floor is applied, changes the result.

This lesson builds that sequential application as a pipeline. Fee calculation is split into four named steps, each step is tested on its own, then the same defect is introduced into both designs and how many tests drop is counted. The final measurement belongs to order: when two steps swap places, how many cents a shipment’s fee shifts by.

Two Separate Compositions

Composition appears in this course in two separate senses, and the two should not be confused. Object composition is one object holding another as a member and delegating work to it; it is the alternative set against inheritance in this course’s object-oriented topic. This lesson’s function composition is two functions forming a single function by making one’s output the other’s input. What they share is that large behavior is built from small parts; what differs is what is being combined — one is objects, the other is functions.

Splitting the Steps

Fee calculation consists of four decisions: selecting the weight tier, applying the zone factor, the contract discount, and the minimum-fee floor. Each decision is taken into its own function. The steps share a common shape: all of them take a record and return a record in the same shape. The record is not changed; each step produces a new record with the spread syntax.

// step.mjs — tariff data and the pipeline's steps; each step takes a record, returns a record
export const TARIFF = {
  minFee: 4990,
  tiers: [
    { maxWeightGrams: 1000, fee: 4990 },
    { maxWeightGrams: 5000, fee: 6490 },
    { maxWeightGrams: 15000, fee: 10900 },
  ],
  zoneFactor: { 1: 1.0, 2: 1.15, 3: 1.35 },
};

export const SHIPMENTS = [
  { code: "TR-4471", weightGrams: 800, zone: 1, contracted: true },
  { code: "TR-4472", weightGrams: 3200, zone: 2, contracted: true },
  { code: "TR-4473", weightGrams: 12400, zone: 3, contracted: false },
  { code: "TR-4474", weightGrams: 950, zone: 2, contracted: true },
  { code: "TR-4475", weightGrams: 4800, zone: 1, contracted: false },
  { code: "TR-4476", weightGrams: 14900, zone: 2, contracted: true },
];

export const selectTier = (r) => ({
  ...r,
  fee: (r.tariff.tiers.find((t) => r.shipment.weightGrams <= t.maxWeightGrams)
    ?? r.tariff.tiers.at(-1)).fee,
});

export const zoneMultiplier = (r) => {
  const factor = r.tariff.zoneFactor[r.shipment.zone];
  return { ...r, fee: Math.round(r.fee * factor) };
};

export const contractDiscount = (r) =>
  r.shipment.contracted ? { ...r, fee: Math.round(r.fee * 0.88) } : r;

export const minimumFloor = (r) => ({
  ...r,
  fee: Math.max(r.tariff.minFee, r.fee),
});

What joins the steps together is separate and very short. pipeline returns a function that applies the steps it is given from left to right; it is the same reduce skeleton as the previous lesson’s rule applicator, except this time what it produces is not a number but a callable pipeline.

// pipeline.mjs — the composition that applies the steps in sequence, and the assembled pipeline
import { selectTier, zoneMultiplier, contractDiscount, minimumFloor } from "./step.mjs";

export const pipeline = (...steps) => (start) =>
  steps.reduce((record, step) => step(record), start);

export const chargeFee = pipeline(selectTier, zoneMultiplier, contractDiscount, minimumFloor);

The same four decisions can also be written in a single body. An equivalent version is needed for the comparison to be measurable.

// monolithic.mjs — the same four steps in a single function's body
export function chargeFeeMonolithic(shipment, tariff) {
  const tier = tariff.tiers.find((t) => shipment.weightGrams <= t.maxWeightGrams)
    ?? tariff.tiers.at(-1);
  const factor = tariff.zoneFactor[shipment.zone];
  let fee = Math.round(tier.fee * factor);
  if (shipment.contracted) fee = Math.round(fee * 0.88);
  return Math.max(tariff.minFee, fee);
}

Testing the Intermediate Steps

The difference between the two designs shows up in testing. In the pipeline, every step is a function exported on its own; its test is written with a small record holding just the few fields that step reads.

// pipeline.test.mjs — the four steps tested individually, the pipeline tested once
import test from "node:test";
import assert from "node:assert/strict";
import { TARIFF, SHIPMENTS, selectTier, zoneMultiplier, contractDiscount,
  minimumFloor } from "./step.mjs";
import { chargeFee } from "./pipeline.mjs";

const record = (shipment, fee = 0) => ({ shipment, tariff: TARIFF, fee });

test("tier selection covers the upper bound", () => {
  assert.equal(selectTier(record({ weightGrams: 1000 })).fee, 4990);
  assert.equal(selectTier(record({ weightGrams: 1001 })).fee, 6490);
});

test("zone multiplier scales the fee", () => {
  assert.equal(zoneMultiplier(record({ zone: 3 }, 10000)).fee, 13500);
});

test("contract discount lands only on a contracted shipment", () => {
  assert.equal(contractDiscount(record({ contracted: true }, 10000)).fee, 8800);
  assert.equal(contractDiscount(record({ contracted: false }, 10000)).fee, 10000);
});

test("the minimum floor is not undercut", () => {
  assert.equal(minimumFloor(record({}, 3000)).fee, 4990);
});

test("the pipeline charges all six shipments end to end", () => {
  assert.deepEqual(SHIPMENTS.map((g) => chargeFee(record(g)).fee),
    [4990, 6567, 14715, 5050, 6490, 11031]);
});

In the monolithic version, none of the same four decisions can be reached directly. The tests can carry the same names, but every one of them is an end-to-end call: each builds a complete shipment and tariff and looks only at the final number.

// monolithic.test.mjs — the same four decisions, testable only end to end
import test from "node:test";
import assert from "node:assert/strict";
import { TARIFF, SHIPMENTS } from "./step.mjs";
import { chargeFeeMonolithic } from "./monolithic.mjs";

test("tier selection covers the upper bound", () => {
  assert.equal(chargeFeeMonolithic(
    { weightGrams: 1000, zone: 1, contracted: false }, TARIFF), 4990);
  assert.equal(chargeFeeMonolithic(
    { weightGrams: 1001, zone: 1, contracted: false }, TARIFF), 6490);
});

test("zone multiplier scales the fee", () => {
  assert.equal(chargeFeeMonolithic(
    { weightGrams: 12400, zone: 3, contracted: false }, TARIFF), 14715);
});

test("contract discount lands only on a contracted shipment", () => {
  assert.equal(chargeFeeMonolithic(
    { weightGrams: 3200, zone: 2, contracted: true }, TARIFF), 6567);
  assert.equal(chargeFeeMonolithic(
    { weightGrams: 3200, zone: 2, contracted: false }, TARIFF), 7463);
});

test("the minimum floor is not undercut", () => {
  assert.equal(chargeFeeMonolithic(
    { weightGrams: 800, zone: 1, contracted: true }, TARIFF), 4990);
});

test("all six shipments are charged end to end", () => {
  assert.deepEqual(SHIPMENTS.map((g) => chargeFeeMonolithic(g, TARIFF)),
    [4990, 6567, 14715, 5050, 6490, 11031]);
});

Both files hold five tests, and both pass; the last test in each expecting the same six numbers shows the two designs are behaviorally equivalent.

The Test Naming Where the Defect Is

The difference does not show up while tests pass — it shows up when one fails. The same defect is introduced into both designs: the zone factor is applied twice instead of once. The defect is introduced into a copy of the source files by a text change; the originals are left untouched.

# The same bug is introduced into both designs: the zone factor is applied twice.
perl -pe 's/r\.fee \* factor\)/r.fee * factor * factor)/' step.mjs > step-broken.mjs
perl -pe 's/tier\.fee \* factor\)/tier.fee * factor * factor)/' \
  monolithic.mjs > monolithic-broken.mjs
perl -pe 's{\./step\.mjs}{./step-broken.mjs}' pipeline.mjs > pipeline-broken.mjs
perl -pe 's{\./step\.mjs}{./step-broken.mjs}; s{\./pipeline\.mjs}{./pipeline-broken.mjs}' \
  pipeline.test.mjs > pipeline-broken.test.mjs
perl -pe 's{\./monolithic\.mjs}{./monolithic-broken.mjs}' \
  monolithic.test.mjs > monolithic-broken.test.mjs

for t in pipeline monolithic; do
  output=$(node --test --test-reporter=tap "$t-broken.test.mjs" 2>&1)
  echo "--- $t"
  printf '%s\n' "$output" | grep -E '^# (tests|fail)'
  printf '%s\n' "$output" | grep '^not ok' | sed 's/^not ok [0-9]* - /  failed: /'
done
--- pipeline
# tests 5
# fail 2
  failed: zone multiplier scales the fee
  failed: the pipeline charges all six shipments end to end
--- monolithic
# tests 5
# fail 3
  failed: zone multiplier scales the fee
  failed: contract discount lands only on a contracted shipment
  failed: all six shipments are charged end to end

The numbers say three things. In the pipeline, two of five tests drop; one of the two that drop names the broken step, the other is the end-to-end test. In the monolithic version three tests drop, and one of them is the test that checks the contract discount — although there is no defect in the discount at all. The reason is that its input belongs to the second zone, so the broken multiplication also passes through that path.

Third, both tests whose shipment belongs to the first zone still pass. Because the first zone’s factor is 1.0, applying it twice does not change the result; the defect does not show up on that path. This is the shared weakness of end-to-end tests: every test passes through the whole path, so the set of tests that drop describes not where the defect is but which paths the inputs happened to land on.

The Effect of Order on the Result

Because the pipeline’s steps read each other’s output, order is part of the result. The contract discount and the minimum-fee floor swap places, and the two runs are compared.

// order.mjs — the same four steps, two different orders: how much the result shifts
import { TARIFF, SHIPMENTS, selectTier, zoneMultiplier, contractDiscount,
  minimumFloor } from "./step.mjs";
import { pipeline } from "./pipeline.mjs";

const discountFirst = pipeline(selectTier, zoneMultiplier, contractDiscount, minimumFloor);
const floorFirst = pipeline(selectTier, zoneMultiplier, minimumFloor, contractDiscount);

const run = (f) => SHIPMENTS.map((g) => f({ shipment: g, tariff: TARIFF, fee: 0 }).fee);
const a = run(discountFirst);
const b = run(floorFirst);
const changed = a.filter((u, i) => u !== b[i]).length;
const difference = a.reduce((t, u, i) => t + Math.abs(u - b[i]), 0);
const underFloor = b.filter((u) => u < TARIFF.minFee).length;

console.log(`discount -> floor : ${a.join(", ")}`);
console.log(`floor -> discount : ${b.join(", ")}`);
console.log(`changed fee       : ${changed}/6   total difference: ${difference} cents`);
console.log(`under minimum     : ${underFloor}/6`);
discount -> floor : 4990, 6567, 14715, 5050, 6490, 11031
floor -> discount : 4391, 6567, 14715, 5050, 6490, 11031
changed fee       : 1/6   total difference: 599 cents
under minimum     : 1/6

Only one of six shipments’ fees changed, and the difference is 599 cents. The smallness of the number is deceptive: the one shipment that changed is exactly the one the floor protects. When the floor is applied before the discount, the discount punches through the floor and the result drops under the minimum fee. The organization’s rule — a calculated fee cannot fall below the minimum fee — has been violated in one of six shipments.

Two conclusions follow. First, order is not an implementation detail but part of the business rule; the floor must be positioned so that no step after it lowers the fee again. Second, the pipeline itself is a subject of testing: every step can be correct on its own and the pipeline can still be wrong. This is why an end-to-end test sits beside the step tests.

What Composition Costs

The shared record shape is what makes composition possible, but it is also the only tie between the steps. All four steps are bound to a record that recognizes the shipment, tariff, and fee fields; when a new step needs a new field, that field enters the record’s shape and becomes a load carried even by steps that do not need it. As the record grows, which step reads which field becomes invisible.

The second cost is the observability of intermediate values. In the monolithic version, while debugging, the fee variable’s value can be read on every line. In the pipeline, because the intermediate value is never named anywhere, a tracing step has to be inserted between two steps to print it out. Its cost is low — a tracing step is itself just a step that returns the record unchanged and gets added to the list — but that step is not pure and should not be left permanently inside the pipeline. Where side effects belong is the subject of the next lesson.

Summary

  • Function composition is making one function’s output another’s input; what distinguishes it from object composition is that what is combined is functions, not objects.
  • When fee calculation is split into four steps, each step can be tested with its own small input; in the monolithic version the same four decisions can only be tested end to end.
  • When the same defect is introduced into both designs, two of five tests drop in the pipeline and three drop in the monolithic version; in the monolithic version one of the tests that drops tests a rule unrelated to the defect.
  • Two tests whose shipment belongs to the first zone pass in both designs: the failure pattern of end-to-end tests shows not where the defect is but which paths the inputs land on.
  • The order of steps is part of the result: when the floor and the discount swap places, one of six shipments’ fees shifts by 599 cents and the minimum-fee rule is violated in one shipment.
  • Composition’s cost is the shared record shape: every new field is a load carried even by the steps that do not need it.

Next Step

All four of this lesson’s steps are pure: none of them looks at anything it does not read, none of them writes outward. A real fee-charging run is not pure — shipments are read from a file, whether the night tariff applies is determined by looking at the clock, and results are written to a log file. The next lesson takes on where these three dependencies belong: the number of fake dependencies the core’s testing needs when input/output and time are pushed to the boundary of the program, counted against the interleaved arrangement.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close