---
title: 'Pushing Side Effects to the Boundary'
source: 'https://academia.sh/en/courses/paradigms/pushing-side-effects-to-the-boundary'
course: 'Programming Paradigms'
language: en
updated: '2026-08-23T07:01:20+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```js
// 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 },
};
```

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

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

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

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

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

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