---
title: 'Scope and Visibility'
source: 'https://academia.sh/en/courses/clean-code/scope-and-visibility'
course: 'Clean Code'
language: en
updated: '2026-08-23T07:01:11+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```sh
mkdir -p wide narrow
```

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

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

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

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

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

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

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

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

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

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

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

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