---
title: Composition
source: 'https://academia.sh/en/courses/paradigms/composition'
course: 'Programming Paradigms'
language: en
updated: '2026-08-23T07:01:19+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.

```js
// 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.

```js
// 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.

```js
// 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.

```sh
# 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.

```js
// 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.
