Skip to content
academia.sh

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; feeBreakdown gives 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.

Start typing to search.

↑↓ Esc navigate · open · close