Lesson 09 / 16
Scope and Visibility
Measuring the principle of least visibility with an import graph: comparing the public surface, the number of dependent files, the unused export surface, and the number of freely changeable names between two versions of the same fee package — one that exports every name and one that exports only two.
Contents
Signature decisions determine what a function tells the outside world. The same question
is asked at the module level: how many names does a module expose to the outside? In the
previous lesson’s split/fee.mjs file, baseFee was not exported; the two fee functions
were. This decision is not arbitrary.
Scope is the zone of code where a name is visible. Every name defined in a module
file is visible only in that file by default; the export keyword carries that name
outside the file. Every exposed name is a promise the module cannot take back: the moment
another file uses that name, the name is no longer the module’s own property. This lesson
counts the cost of that promise.
The Package That Exposes Every Name
The fee package consists of four files: the calculation itself and three modules that use it — report, label, invoice. In the first version, the calculation module exposes every one of its names.
mkdir -p wide narrow
// wide/fee.mjs — every name in the module is exported export const VOLUMETRIC_DIVISOR = 3000; export const MINIMUM_FEE = 52; export const REGION_FACTORS = { 1: 1, 2: 1.35, 3: 1.8 }; export const WEIGHT_TIERS = [{ maxKg: 1, ratePerKg: 38 }, { maxKg: 5, ratePerKg: 26 }, { maxKg: 10, ratePerKg: 21 }, { maxKg: 30, ratePerKg: 17 }]; export function volumetricWeightKg(shipment) { return (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR; } export function billableWeightKg(shipment) { return Math.max(shipment.weightKg, volumetricWeightKg(shipment)); } export function selectTier(weightKg) { return WEIGHT_TIERS.find((t) => weightKg <= t.maxKg); } export function tierFee(weightKg) { return selectTier(weightKg).ratePerKg * weightKg; } export function calculateFee(shipment) { const weightKg = billableWeightKg(shipment); return Math.max(tierFee(weightKg) * REGION_FACTORS[shipment.zoneCode], MINIMUM_FEE); }
Every name that is exposed gets used, because it is available.
// wide/report.mjs — direct references to two internal names and one constant import { calculateFee, selectTier, WEIGHT_TIERS } from "./fee.mjs"; export function report(shipments) { const lines = shipments.map((s) => `${s.code} tier<=${selectTier(s.weightKg).maxKg}kg fee=${calculateFee(s).toFixed(2)}`); return lines.concat(`tier count=${WEIGHT_TIERS.length}`); }
// wide/label.mjs — direct reference to the volumetric divisor import { billableWeightKg, VOLUMETRIC_DIVISOR } from "./fee.mjs"; export function label(shipment) { return `${shipment.code} ${billableWeightKg(shipment).toFixed(2)}kg (divisor ${VOLUMETRIC_DIVISOR})`; }
// wide/invoice.mjs — recalculates the tier fee separately import { calculateFee, tierFee } from "./fee.mjs"; export function invoice(shipment) { const weightKg = Math.max(shipment.weightKg, (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / 3000); return `${shipment.code} tier=${tierFee(weightKg).toFixed(2)} total=${calculateFee(shipment).toFixed(2)}`; }
invoice.mjs stands out: even though the volumetric divisor is exported, the number
3000 is hardcoded in that file. A wide surface does not guarantee consistency, it only
increases the number of options.
The Package That Exposes Two Names
In the second version, the fee module exposes two names: the fee itself and the fee’s breakdown. The intermediate values the consuming modules need are given by name in the breakdown; the module’s constants and intermediate functions do not leave it.
// narrow/fee.mjs — two names exported, the rest stays inside the module const VOLUMETRIC_DIVISOR = 3000; const MINIMUM_FEE = 52; const REGION_FACTORS = { 1: 1, 2: 1.35, 3: 1.8 }; const WEIGHT_TIERS = [{ maxKg: 1, ratePerKg: 38 }, { maxKg: 5, ratePerKg: 26 }, { maxKg: 10, ratePerKg: 21 }, { maxKg: 30, ratePerKg: 17 }]; function volumetricWeightKg(shipment) { return (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR; } function billableWeightKg(shipment) { return Math.max(shipment.weightKg, volumetricWeightKg(shipment)); } function selectTier(kg) { return WEIGHT_TIERS.find((t) => kg <= t.maxKg); } function tierFee(kg) { return selectTier(kg).ratePerKg * kg; } export function calculateFee(shipment) { const kg = billableWeightKg(shipment); return Math.max(tierFee(kg) * REGION_FACTORS[shipment.zoneCode], MINIMUM_FEE); } export function feeBreakdown(shipment) { const kg = billableWeightKg(shipment); return { weightKg: kg, volumetricDivisor: VOLUMETRIC_DIVISOR, tierCount: WEIGHT_TIERS.length, tierMaxKg: selectTier(kg).maxKg, tierFee: tierFee(kg) }; }
// narrow/report.mjs — only through the breakdown and the fee import { calculateFee, feeBreakdown } from "./fee.mjs"; export function report(shipments) { const lines = shipments.map((s) => `${s.code} tier<=${feeBreakdown(s).tierMaxKg}kg fee=${calculateFee(s).toFixed(2)}`); return lines.concat(`tier count=${feeBreakdown(shipments[0]).tierCount}`); }
// narrow/label.mjs — the divisor value comes from the breakdown import { feeBreakdown } from "./fee.mjs"; export function label(shipment) { const breakdown = feeBreakdown(shipment); return `${shipment.code} ${breakdown.weightKg.toFixed(2)}kg (divisor ${breakdown.volumetricDivisor})`; }
// narrow/invoice.mjs — the tier fee comes from the breakdown import { calculateFee, feeBreakdown } from "./fee.mjs"; export function invoice(shipment) { return `${shipment.code} tier=${feeBreakdown(shipment).tierFee.toFixed(2)}` + ` total=${calculateFee(shipment).toFixed(2)}`; }
It is verified that the two packages produce the same output.
// compare.mjs — verifies the two packages produce the same output import { report as wideReport } from "./wide/report.mjs"; import { label as wideLabel } from "./wide/label.mjs"; import { invoice as wideInvoice } from "./wide/invoice.mjs"; import { report as narrowReport } from "./narrow/report.mjs"; import { label as narrowLabel } from "./narrow/label.mjs"; import { invoice as narrowInvoice } from "./narrow/invoice.mjs"; const SHIPMENTS = [ { code: "GN-4172", weightKg: 2.4, widthCm: 30, lengthCm: 24, heightCm: 18, zoneCode: 2 }, { code: "GN-4173", weightKg: 0.4, widthCm: 10, lengthCm: 10, heightCm: 10, zoneCode: 1 }, ]; const wide = [...wideReport(SHIPMENTS), ...SHIPMENTS.map(wideLabel), ...SHIPMENTS.map(wideInvoice)]; const narrow = [...narrowReport(SHIPMENTS), ...SHIPMENTS.map(narrowLabel), ...SHIPMENTS.map(narrowInvoice)]; for (const [i, line] of wide.entries()) { console.log(`${line === narrow[i] ? "same " : "DIFFERENT"} ${line}`); }
same GN-4172 tier<=5kg fee=151.63 same GN-4173 tier<=1kg fee=52.00 same tier count=4 same GN-4172 4.32kg (divisor 3000) same GN-4173 0.40kg (divisor 3000) same GN-4172 tier=112.32 total=151.63 same GN-4173 tier=15.20 total=52.00
The Surface Measurer
The measurer reads the modules in a directory, extracts the names each module exposes and keeps internal, then counts from the import lines which name is used by which files. The last column is the number of names the module can change on its own: every name with no external dependent counts toward it.
// surface-measure.mjs — number of names each module exposes, their dependents, and freedom to change import { readFileSync, readdirSync } from "node:fs"; const exportedNames = (m) => [...m.matchAll(/^export\s+(?:const|function|class)\s+(\w+)/gm)].map((e) => e[1]); const internalNames = (m) => [...m.matchAll(/^(?:const|function|class)\s+(\w+)/gm)].map((e) => e[1]); const imports = (m) => [...m.matchAll(/import\s*{([^}]*)}\s*from\s*"\.\/([\w.-]+)"/g)] .map((e) => [e[2], e[1].split(",").map((s) => s.trim()).filter(Boolean)]); for (const dir of process.argv.slice(2)) { const files = readdirSync(dir).filter((a) => a.endsWith(".mjs")).sort(); const text = Object.fromEntries(files.map((a) => [a, readFileSync(`${dir}/${a}`, "utf8")])); const usage = new Map(); for (const m of Object.values(text)) { for (const [target, names] of imports(m)) { for (const name of names) usage.set(`${target}:${name}`, (usage.get(`${target}:${name}`) ?? 0) + 1); } } console.log(dir); for (const file of files) { const exported = exportedNames(text[file]); const internal = internalNames(text[file]); const dependents = exported.map((name) => [name, usage.get(`${file}:${name}`) ?? 0]); const bound = dependents.filter(([, n]) => n > 0); console.log(` ${file.padEnd(12)} exported=${exported.length} internal=${internal.length}` + ` has dependents=${bound.length} unused export surface=${exported.length - bound.length}` + ` freely changeable=${exported.length + internal.length - bound.length}`); for (const [name, n] of bound) console.log(` ${name.padEnd(24)} dependent files=${n}`); } }
node surface-measure.mjs wide narrow
wide
fee.mjs exported=9 internal=0 has dependents=6 unused export surface=3 freely changeable=3
VOLUMETRIC_DIVISOR dependent files=1
WEIGHT_TIERS dependent files=1
billableWeightKg dependent files=1
selectTier dependent files=1
tierFee dependent files=1
calculateFee dependent files=2
invoice.mjs exported=1 internal=0 has dependents=0 unused export surface=1 freely changeable=1
label.mjs exported=1 internal=0 has dependents=0 unused export surface=1 freely changeable=1
report.mjs exported=1 internal=0 has dependents=0 unused export surface=1 freely changeable=1
narrow
fee.mjs exported=2 internal=8 has dependents=2 unused export surface=0 freely changeable=8
calculateFee dependent files=2
feeBreakdown dependent files=3
invoice.mjs exported=1 internal=0 has dependents=0 unused export surface=1 freely changeable=1
label.mjs exported=1 internal=0 has dependents=0 unused export surface=1 freely changeable=1
report.mjs exported=1 internal=0 has dependents=0 unused export surface=1 freely changeable=1
The calculation module’s surface dropped from nine to two. More important than that is the last column: in the wide version, the module can change three of its own names without asking anyone; in the narrow version, eight. The difference is that six names are bound from the outside.
The three consuming modules showing unused export surface=1 is a limitation of the measurement:
these are the package’s outer face, and nothing inside the package itself uses them. The
measure only sees the bindings within the given directory.
The Cost of Renaming
Freedom to change is not an abstract concept. The cost of renaming a name is the number of files it appears in.
for d in wide narrow; do echo "$d: selectTier $(grep -rl selectTier $d | wc -l | tr -d ' ') file(s)," \ "VOLUMETRIC_DIVISOR $(grep -rl VOLUMETRIC_DIVISOR $d | wc -l | tr -d ' ') file(s)" done
wide: selectTier 2 file(s), VOLUMETRIC_DIVISOR 2 file(s) narrow: selectTier 1 file(s), VOLUMETRIC_DIVISOR 1 file(s)
The difference looks like two files against one, because the package is small. The real meaning of the measure is in the ratio: in the narrow version, these two names are inside the module, and the count stays at one no matter how many outside files there are. In the wide version, the count grows together with the number of files that import that name.
Converting the tier selection to a Map-based structure, moving the volumetric divisor to
a field that varies by carrier, reading the minimum fee from the tariff — all of these are
the module’s internal business in the narrow version. In the wide version, each one also
requires changing the outside files.
The Principle of Least Visibility
The principle is one sentence: a name is defined in the narrowest scope that can do its job. Applying it takes three steps.
The default is closed. A new name first stays inside the file. Exporting is done once a consumer actually appears and that consumer has no other way.
Widening is easy, narrowing is hard. Opening a closed name is a one-line change and
breaks no file. Closing an open name requires changing every file that uses it; the
dependent files column in the measurement is exactly this cost.
The surface carries decisions, not intermediate values. feeBreakdown was an example
of this: what the consuming modules needed was not the intermediate functions but the
results of the calculation. Giving the result that a constant determines, instead of the
constant itself, preserves that constant’s freedom to change.
The same principle works inside a function too. When a variable is defined outside the block where it is used, every line outside that block becomes able to change it; someone reading the code also has to follow every line in between to understand what the value is.
Summary
- Every exposed name is an expensive promise to take back; its measure is the number of files that import it.
- In two packages that produce the same behavior, the calculation module’s surface was nine names and two; the unused export surface dropped from three to zero.
- The number of names the module can change without asking anyone rose from three to eight; the difference is six names bound from the outside.
- Exposing decisions rather than intermediate values preserves the constants’ freedom to
change;
feeBreakdowngives results, not constants. - Widening is a one-line change, narrowing requires changing every dependent file; that is why the default is closed.
Next Step
Visibility decisions determine what a module gives to the outside. One question remains: what criterion split these modules apart? The fee calculation sits in one file, the report in another; the split looks functional. But what actually strains a codebase is different people touching the same file for different reasons. If a rounding change that accounting wants and a tier change that operations wants meet in the same file, the two decisions wait on each other. The next lesson measures this meeting point: how many times two different reasons for change touch the same file, and what that number becomes when code is organized by actor.
To keep your progress and take notes, Log in
My notes
Log in to take notes.