---
title: 'Encapsulate What Varies'
source: 'https://academia.sh/en/courses/design-principles/encapsulate-what-varies'
course: 'Design Principles'
language: en
updated: '2026-08-23T07:01:17+00:00'
license: 'CC BY-SA 4.0'
---

# Encapsulate What Varies

Isolating axes of change: deriving the file sets touched by two changes coming from the price list and output format axes, counting their intersection, and showing that a layout which pulls each axis into its own module drives the intersection to zero.

In the previous lesson's separated design, the weight limit constant and the dispatch rule
lived in the same file. The two do not change for the same reason: the limit moves when the
price list is renewed, the rule moves when organizational policy changes. This difference has
a name — an **axis of change**: each separate source that sends a change request to a module
is an axis.

**Encapsulate what varies** says to gather each axis in its own module. The single
responsibility principle measured the same idea by the count of reasons to change; the
measure here is different and more direct: the **intersection** of the file sets touched by
two changes coming from two separate axes. If the intersection is not empty, two independent
changes are sharing the same text.

## Two Axes, Scattered Layout

The measurement uses the two axes that change most often in the pricing library: the **price
list** and the **output format**. The pricing unit requests the first, accounting reporting
requests the second; the two never arrive together. The library has three clients: an HTTP
endpoint, batch processing, and a report. In the first layout, all three calculate the price
themselves and format the line themselves.

```sh
mkdir -p scattered isolated
```

```js
// records.mjs — sample shipments used by both layouts
export const RECORDS = [
  { code: "GN-1", weight: 0.8, address: "34100" },
  { code: "GN-2", weight: 3.0, address: "06500" },
  { code: "GN-3", weight: 12.0, address: "65200" },
];
```

```js
// scattered/web.mjs — both the price table and the output format live here
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export function line(shipment) {
  const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 15000;
  const cents = Math.round((base * (ZONE[shipment.address.slice(0, 2)] ?? 160)) / 100);
  return `${shipment.code} ${cents} cents`;
}
```

```js
// scattered/batch.mjs — same price table, same format, second copy
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export function lines(shipments) {
  return shipments.map((s) => {
    const base = TIER.find(([max]) => s.weight <= max)?.[1] ?? 15000;
    const cents = Math.round((base * (ZONE[s.address.slice(0, 2)] ?? 160)) / 100);
    return `${s.code} ${cents} cents`;
  });
}
```

```js
// scattered/report.mjs — third copy
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export function totalLine(shipments) {
  let total = 0;
  for (const s of shipments) {
    const base = TIER.find(([max]) => s.weight <= max)?.[1] ?? 15000;
    total += Math.round((base * (ZONE[s.address.slice(0, 2)] ?? 160)) / 100);
  }
  const cents = total;
  return `TOTAL ${cents} cents`;
}
```

## Isolated Layout

In the second layout, each axis stays in its own module: the price list in one module, the
output format in one module. The clients take both from outside.

```js
// isolated/price.mjs — first axis: the price list lives only here
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export function calculateCents(shipment) {
  const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 15000;
  return Math.round((base * (ZONE[shipment.address.slice(0, 2)] ?? 160)) / 100);
}
```

```js
// isolated/format.mjs — second axis: the output format lives only here
export const amount = (cents) => `${cents} cents`;
```

```js
// isolated/web.mjs — takes both axes from outside
import { calculateCents } from "./price.mjs";
import { amount } from "./format.mjs";

export const line = (shipment) => `${shipment.code} ${amount(calculateCents(shipment))}`;
```

```js
// isolated/batch.mjs — takes both axes from outside
import { calculateCents } from "./price.mjs";
import { amount } from "./format.mjs";

export const lines = (shipments) =>
  shipments.map((s) => `${s.code} ${amount(calculateCents(s))}`);
```

```js
// isolated/report.mjs — takes both axes from outside
import { calculateCents } from "./price.mjs";
import { amount } from "./format.mjs";

export const totalLine = (shipments) =>
  `TOTAL ${amount(shipments.reduce((t, s) => t + calculateCents(s), 0))}`;
```

```js
// setup.mjs — shows both layouts produce the same output
import { RECORDS } from "./records.mjs";

const root = process.argv[2];
const { line } = await import(`./${root}/web.mjs`);
const { lines } = await import(`./${root}/batch.mjs`);
const { totalLine } = await import(`./${root}/report.mjs`);

console.log(`${root}: ${line(RECORDS[0])} | ${lines(RECORDS).join(" ")} | ${totalLine(RECORDS)}`);
```

```sh
node setup.mjs scattered
node setup.mjs isolated
```

```
scattered: GN-1 3900 cents | GN-1 3900 cents GN-2 7360 cents GN-3 16520 cents | TOTAL 27780 cents
isolated: GN-1 3900 cents | GN-1 3900 cents GN-2 7360 cents GN-3 16520 cents | TOTAL 27780 cents
```

## Two Changes, Two Sets

Axis A is the price list: the mid-tier fee rises from 6400 to 7100, the Ankara zone factor
rises from 115 to 120. Axis B is the output format: amounts will be written as two-decimal
lira instead of cents. The two changes are applied separately to separate copies, and the
file set each one touches is derived. A backup extension is given for the in-place edit; GNU
and BSD `sed` behave the same way in this form.

```sh
for k in scattered isolated; do
  cp -r $k $k-a
  cp -r $k $k-b
  sed -i.y 's/\[5, 6400\]/[5, 7100]/; s/"06": 115/"06": 120/' $k-a/*.mjs
  sed -i.y 's/\${cents} cents/${(cents \/ 100).toFixed(2)} TL/' $k-b/*.mjs
  rm -f $k-a/*.y $k-b/*.y
  node setup.mjs $k-a
  node setup.mjs $k-b
done

changed() { diff -rq "$1" "$2" | sed 's#^Files [^/]*/\([^ ]*\) and .*#\1#' | sort; }
for k in scattered isolated; do
  changed $k $k-a > axis-a.txt
  changed $k $k-b > axis-b.txt
  echo "$k axis A -> $(tr '\n' ' ' < axis-a.txt)"
  echo "$k axis B -> $(tr '\n' ' ' < axis-b.txt)"
  echo "$k intersection = $(comm -12 axis-a.txt axis-b.txt | wc -l | tr -d ' ') $(comm -12 axis-a.txt axis-b.txt | tr '\n' ' ')"
  echo "$k files that know the zone factor = $(grep -l ZONE $k/*.mjs | wc -l | tr -d ' ')"
done
```

```
scattered-a: GN-1 3900 cents | GN-1 3900 cents GN-2 8520 cents GN-3 16520 cents | TOTAL 28940 cents
scattered-b: GN-1 39.00 TL | GN-1 39.00 TL GN-2 73.60 TL GN-3 165.20 TL | TOTAL 277.80 TL
isolated-a: GN-1 3900 cents | GN-1 3900 cents GN-2 8520 cents GN-3 16520 cents | TOTAL 28940 cents
isolated-b: GN-1 39.00 TL | GN-1 39.00 TL GN-2 73.60 TL GN-3 165.20 TL | TOTAL 277.80 TL
scattered axis A -> batch.mjs report.mjs web.mjs
scattered axis B -> batch.mjs report.mjs web.mjs
scattered intersection = 3 batch.mjs report.mjs web.mjs
scattered files that know the zone factor = 3
isolated axis A -> price.mjs
isolated axis B -> format.mjs
isolated intersection = 0
isolated files that know the zone factor = 1
```

Both layouts produce the same result after both changes; the difference is in cost. In the
scattered layout, both axes touched the same three files, and the intersection is three. In
the isolated layout, the sets are disjoint and the intersection is zero.

The last line shows the reason for the intersection: the zone factor was defined in three
files in the scattered layout, in one file in the isolated layout. The source of the
intersection is copying information.

## What the Intersection Means

The intersection count is the shared measure of three separate risks. First, if two
independent changes arrive at the same time, the same files must be edited; if both touch the
same lines, merging turns into manual work. Second, the person making the price change also
has to read the format code — the reading surface grows out of proportion to the change
itself. Third, and most expensive, a change on one axis can break the other axis's tests;
when the intersection is zero, that possibility disappears structurally.

The measure also reads in reverse. If the intersection is zero and a change arriving on one
axis still edits two files, that axis is not yet fully isolated. The goal of isolation is not
reducing the file count, it is keeping the sets disjoint.

## Choosing the Right Axis

Choosing the wrong axis does not reduce the cost, it relocates it. The split above was made
into "calculation" and "format"; if the split had instead been made into "web side" and
"batch side," changes from the two axes would still land on the same files, because both the
price list and the format are used on both sides.

The way to find the axis is not to look at a single codebase, it is to look at the change
requests that arrive. If two requests never arrive together and come from different people,
they are two separate axes. If they come from the same person, for the same reason, and
together, they are one axis, and splitting them only increases the file count. By this
measure, the price list and the output format are separate: one is requested by pricing, the
other by accounting reporting.

## Summary

- An axis of change is a separate source that sends change requests to a module; the
  principle says to gather each axis in its own module.
- The measure is the intersection of the file sets touched by two changes coming from two
  separate axes.
- In the scattered layout, both axes touched the same three files and the intersection came
  out to 3; in the isolated layout, the sets were disjoint and the intersection was 0.
- The source of the intersection is copying information: the zone factor was defined in 3
  files in the scattered layout, in 1 file in the isolated layout.
- The axis is chosen by the source of the incoming change requests, not by the code
  structure; the wrong axis does not reduce the intersection.

## Next Step

In the isolated layout, all three clients import the `price.mjs` module by name and call the
`calculateCents` function directly. When the price calculation needs a second implementation
— a list specific to a contracted customer, or a fixed list for testing — the clients' import
lines have to change. The cost of binding to a concrete module can be measured by the number
of lines touched when the implementation changes. The next lesson counts that cost and
compares it against a version where the client binds to an abstraction instead of a concrete
type.
