Lesson 13 / 14
Pushing Side Effects to the Boundary
The functional-core, imperative-shell arrangement: moving file reading, the clock, and log writing out of the computation into an outer layer, and comparing the number of tests that require a fake dependency and the number of lines that touch the outside world across the two arrangements.
Contents
All four of the previous lesson’s steps were pure: none of them looked at anything they did not read, none of them wrote 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 the results are written to a file and to a log stream.
These three dependencies cannot be removed; for a program to be useful it has to talk to the outside world. What can be removed is these staying in the same place as the fee rules. This lesson draws that boundary and measures two arrangements: how many fake dependencies the core needs for testing, and which files gather the lines that touch the outside world.
The Interleaved Arrangement
The first arrangement is the natural one: the run is written start to finish, and everything happens where it occurs. The tariff data is common input to both designs and lives in its own file.
// tariff.mjs — tariff data used by both designs export const TARIFF = { minFee: 4990, nightSurcharge: 1500, tiers: [ { maxWeightGrams: 1000, fee: 4990 }, { maxWeightGrams: 5000, fee: 6490 }, { maxWeightGrams: 15000, fee: 10900 }, ], zoneFactor: { 1: 1.0, 2: 1.15, 3: 1.35 }, };
// interleaved.mjs — file reading, the clock, and log writing interleaved into the computation import fs from "node:fs"; import { TARIFF } from "./tariff.mjs"; export function dailyRun(inputPath, outputPath, logPath) { const shipments = JSON.parse(fs.readFileSync(inputPath, "utf8")); const hour = new Date(Date.now()).getUTCHours(); const lines = []; let total = 0; for (const g of shipments) { const tier = TARIFF.tiers.find((t) => g.weightGrams <= t.maxWeightGrams) ?? TARIFF.tiers.at(-1); let fee = Math.round(tier.fee * TARIFF.zoneFactor[g.zone]); if (g.contracted) fee = Math.round(fee * 0.88); if (hour >= 20 || hour < 6) fee += TARIFF.nightSurcharge; fee = Math.max(TARIFF.minFee, fee); lines.push(`${g.code},${fee}`); total += fee; fs.appendFileSync(logPath, `${g.code} charged\n`); } fs.writeFileSync(outputPath, `${lines.join("\n")}\n`); fs.appendFileSync(logPath, `total ${total}\n`); return total; }
Five lines in this body touch the outside world: one read, one clock query, two log appends, and one write. The rest of the lines are fee rules. Because the two are interleaved, the only way to reach the rules is to run the entire run.
Functional Core, Imperative Shell
The second arrangement splits the same work into two parts. The core is the pure function that makes the decision: it takes shipments, the tariff, and the hour as parameters and returns the lines to be written, the log lines to be recorded, and the total. It performs none of them itself.
// core.mjs — the pure core: shipments, tariff, and hour are all given from outside export function plan({ shipments, tariff, hour }) { const night = hour >= 20 || hour < 6; const lines = []; const logLines = []; let total = 0; for (const g of shipments) { const tier = tariff.tiers.find((t) => g.weightGrams <= t.maxWeightGrams) ?? tariff.tiers.at(-1); let fee = Math.round(tier.fee * tariff.zoneFactor[g.zone]); if (g.contracted) fee = Math.round(fee * 0.88); if (night) fee += tariff.nightSurcharge; fee = Math.max(tariff.minFee, fee); lines.push(`${g.code},${fee}`); logLines.push(`${g.code} charged`); total += fee; } return { lines, logLines: [...logLines, `total ${total}`], total }; }
The critical distinction is in the returned value. The core produces a definition of “these lines should be written”; it does not do the writing. The log lines also come back as text, not printed to a stream. The hour is a number, not a queried resource.
The shell takes this definition and carries it out. Reading, the clock, and writing appear only here.
// shell.mjs — the impure shell: reading, the clock, and writing appear only in this file import fs from "node:fs"; import { TARIFF } from "./tariff.mjs"; import { plan } from "./core.mjs"; export function dailyRun(inputPath, outputPath, logPath) { const shipments = JSON.parse(fs.readFileSync(inputPath, "utf8")); const hour = new Date(Date.now()).getUTCHours(); const result = plan({ shipments, tariff: TARIFF, hour }); fs.writeFileSync(outputPath, `${result.lines.join("\n")}\n`); fs.appendFileSync(logPath, `${result.logLines.join("\n")}\n`); return result.total; }
Not a single business rule sits inside the shell: it reads, it calls, it writes. Every line that decides something is in the core; every line that decides nothing is in the shell.
The Cost Paid in Testing
The difference becomes countable in testing. In the interleaved arrangement, the only
way to reach the fee rules is a call to dailyRun, and that call also reads a file,
checks the clock, and writes a file. All four of these have to be silenced by putting a
fake in their place for every test.
// interleaved.test.mjs — every test sets up four fake dependencies import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import { dailyRun } from "./interleaved.mjs"; 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 }, ]; function withFakeEnvironment(shipments, hourUTC, task) { const real = { read: fs.readFileSync, write: fs.writeFileSync, append: fs.appendFileSync, now: Date.now }; const written = { csv: "", log: [] }; fs.readFileSync = () => JSON.stringify(shipments); fs.writeFileSync = (_, content) => { written.csv = content; }; fs.appendFileSync = (_, content) => { written.log.push(content); }; Date.now = () => Date.UTC(2024, 0, 15, hourUTC); try { return { result: task(), written }; } finally { fs.readFileSync = real.read; fs.writeFileSync = real.write; fs.appendFileSync = real.append; Date.now = real.now; } } test("the night surcharge is not applied on a daytime run", () => { // FAKE ENVIRONMENT const { result, written } = withFakeEnvironment(SHIPMENTS, 10, () => dailyRun("g.json", "c.csv", "k.log")); assert.equal(result, 26272); assert.equal(written.csv, "TR-4471,4990\nTR-4472,6567\nTR-4473,14715\n"); }); test("a fixed surcharge lands on every shipment on a night run", () => { // FAKE ENVIRONMENT const { result } = withFakeEnvironment(SHIPMENTS, 22, () => dailyRun("g.json", "c.csv", "k.log")); assert.equal(result, 30173); }); test("the discount cannot punch through the minimum-fee floor", () => { // FAKE ENVIRONMENT const single = [{ code: "TR-4477", weightGrams: 500, zone: 1, contracted: true }]; const { result } = withFakeEnvironment(single, 10, () => dailyRun("g.json", "c.csv", "k.log")); assert.equal(result, 4990); });
In the separated arrangement the same three rules are tested with a direct call to
plan. A fake environment is set up only in the shell’s own test: the only behavior
the shell has to test is that it hands what it reads to the core and writes what comes
back.
// separated.test.mjs — fee rules are tested without fakes, fakes appear only in the wiring test import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import { TARIFF } from "./tariff.mjs"; import { plan } from "./core.mjs"; import { dailyRun } from "./shell.mjs"; 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 }, ]; test("the night surcharge is not applied on a daytime run", () => { const result = plan({ shipments: SHIPMENTS, tariff: TARIFF, hour: 10 }); assert.equal(result.total, 26272); assert.deepEqual(result.lines, ["TR-4471,4990", "TR-4472,6567", "TR-4473,14715"]); }); test("a fixed surcharge lands on every shipment on a night run", () => { assert.equal(plan({ shipments: SHIPMENTS, tariff: TARIFF, hour: 22 }).total, 30173); }); test("the discount cannot punch through the minimum-fee floor", () => { const single = [{ code: "TR-4477", weightGrams: 500, zone: 1, contracted: true }]; assert.equal(plan({ shipments: single, tariff: TARIFF, hour: 10 }).total, 4990); }); test("the shell hands what it reads to the core, and writes what comes back", () => { // FAKE ENVIRONMENT const real = { read: fs.readFileSync, write: fs.writeFileSync, append: fs.appendFileSync, now: Date.now }; let csv = ""; fs.readFileSync = () => JSON.stringify(SHIPMENTS); fs.writeFileSync = (_, content) => { csv = content; }; fs.appendFileSync = () => {}; Date.now = () => Date.UTC(2024, 0, 15, 10); try { assert.equal(dailyRun("g.json", "c.csv", "k.log"), 26272); assert.equal(csv, "TR-4471,4990\nTR-4472,6567\nTR-4473,14715\n"); } finally { fs.readFileSync = real.read; fs.writeFileSync = real.write; fs.appendFileSync = real.append; Date.now = real.now; } });
Measuring the Two Arrangements
The measurement splits into two tables. The first counts, in the source files, the lines that touch the outside world: a file-system call or a line that reads the clock. The second counts the test files; tests that set up a fake environment are found by a marker in their body.
# Lines that touch the outside world: a file-system call or a line that reads the clock. printf '%-22s %5s %14s\n' file lines "outside-world" for f in interleaved.mjs shell.mjs core.mjs; do printf '%-22s %5d %14d\n' "$f" "$(awk 'END{print NR}' "$f")" \ "$(grep -cE 'fs\.[a-zA-Z]|Date\.now' "$f")" done echo # A faked test: a test whose body carries the `// FAKE ENVIRONMENT` marker. printf '%-22s %6s %7s %5s %6s\n' "test file" tests faked lines passed for f in interleaved.test.mjs separated.test.mjs; do passed=$(node --test --test-reporter=tap "$f" 2>&1 | grep '^# pass' | tr -dc '0-9') printf '%-22s %6d %7d %5d %6s\n' "$f" "$(grep -c '^test(' "$f")" \ "$(grep -c 'FAKE ENVIRONMENT' "$f")" "$(awk 'END{print NR}' "$f")" "$passed" done
file lines outside-world interleaved.mjs 24 5 shell.mjs 13 4 core.mjs 19 0 test file tests faked lines passed interleaved.test.mjs 3 3 51 3 separated.test.mjs 4 1 48 4
The real number in the first table is the zero at the end. The nineteen-line file that carries the fee rules has not a single line that touches the outside world. In the interleaved arrangement, the same rules sit in the same body as five lines that talk to the outside; there is no way to avoid those five lines without running the rule.
The second table is testing’s counterpart to that. The separated arrangement contains one extra test, because the shell’s wiring is also tested on its own; even so, the number of tests that set up a fake environment drops from three to one. The difference is sharper in the ratios: one hundred percent of the tests fake something in the interleaved arrangement, twenty-five percent do in the separated arrangement.
The real gain is in scaling. A new fee rule — a volume discount, a corporate agreement, a second zone tier — adds one more test in the separated arrangement that sets up no fake environment; the count of faked tests stays at one. In the interleaved arrangement, every new rule’s test rebuilds and breaks all four fakes again. A fake dependency is a cost paid not for the rule under test but for the path that has to be crossed to reach it.
Auditing the Boundary
This arrangement breaks in production in one characteristic way: a rushed fix adds a log line or a clock query to the core. The good part is that the violation is measurable. The first table in the measurement script can be turned into a rule — the core files’ “outside-world” column must stay at zero — and that condition can be recounted on every change.
The criterion that determines where the boundary runs is the same one used in the earlier lessons: observability. The core changes an accumulator, fills an array, assigns a local variable inside itself; none of that is seen from outside. Three things are seen from outside, and all three sit in the shell: the file system, time, and the log stream. Randomness and network calls belong to the same set.
The shell itself is not pure and cannot be made pure. The goal here is to minimize it: thirteen lines, four of them touching the outside world, none of them carrying a business rule. When a defect appears, the question can be split in two — is the computation wrong, or is the wiring wrong. In the interleaved arrangement that question cannot be split.
Summary
- Input/output, time, and the log stream cannot be removed; what can be removed is these staying in the same body as the fee rules.
- The functional core makes the decision and returns a definition of what should be done; the imperative shell carries out that definition. Every line that decides something is in the core, every line that decides nothing is in the shell.
- The core file has zero lines that touch the outside world; in the interleaved arrangement the same rules sit in the same body as five lines that talk outward.
- In the separated arrangement the test count goes from three to four while the count of tests that set up a fake environment drops from three to one; every new fee rule does not raise the faked-test count.
- A boundary violation turns into a measurable condition: the file-system-call or clock-read line count in the core files must stay at zero.
- The shell cannot be made pure — it is minimized. Thirteen lines, no business rule at all, so a defect lets the computation and the wiring be questioned separately.
Next Step
The model built across this topic recognized a single unit: the function. This course’s first topic built a different unit — the object that holds state together with the behavior that protects it. The two models divide the same domain, shipment fee calculation, in different ways. The final lesson sets the two side by side: the same problem is modeled once with an object-heavy approach and once with a function-heavy approach, then two separate changes are requested and how many files each one touches in each model is counted. The result to come out is not a declaration of superiority but a statement of which criterion decides which one is chosen.
To keep your progress and take notes, Log in
My notes
Log in to take notes.