---
title: 'Structural Programming'
source: 'https://academia.sh/en/courses/paradigms/structural-programming'
course: 'Programming Paradigms'
language: en
updated: '2026-08-23T07:01:21+00:00'
license: 'CC BY-SA 4.0'
---

# Structural Programming

Showing that a paradigm is a splitting decision rather than a syntax: measuring the number of independent paths, exit points, and the number of cases branch coverage requires across a flagged and a split version of the same fee-calculation flow.

The previous course established the guarantee of changing structure while preserving
behavior: extraction, moving, and renaming could be done while tests stayed green. Every
decision made throughout that course shared a common assumption. How the code was written
was questioned; which model it was thought in was not. A function's length, a name's
clarity, a repetition's redundancy were debated — but that a program is made of
**functions** was never debated.

This course opens that assumption. A **paradigm** is not a syntax; it is a decision that
determines which units a program is split into. The unit can be a procedure, an object
carrying state, or a mapping from input to output. The choice does not determine how the
code looks, but how far a change spreads. Structural programming is the first of these
decisions and the ground the others are built on.

## Where the Splitting Decision Is Made

The Programming Fundamentals course introduced structural programming's three
constructs: sequence, selection, and iteration. That these three suffice to write any
computable program is a formal result. The question here is not sufficiency but
**discipline**: two programs written with the same three constructs carry very different
maintenance costs because of how they are split into units.

The measure that makes the distinction visible is how many separate paths can enter a
unit. The **single entry, single exit** constraint says a block is entered only at its
start and left only at its end. The constraint's value is arithmetic: reasoning about
the values at a point in the code requires examining every path that reaches it, and the
number of paths multiplies at every decision point.

Early exit — a guard clause, breaking out of a loop — is a disciplined loosening of this
constraint; its target is fixed. What is undisciplined is steering the flow with flag
variables: a flag carries a decision's outcome forward, and every line between the two
points falls under that decision's influence.

## Two Versions of the Same Flow

The same codebase will be developed throughout this course: a shipment fee-calculation
and routing library. A `shipment` carries weight, volume, delivery province, declared
value, and contracted-customer information. A `tariff` holds weight tiers, zone factors,
and a minimum fee. The fee is the result of a chain that starts with validation and ends
with a minimum-fee floor.

The first version writes this chain in a single body, with flags.

```js
// fee-flagged.mjs — the fee calculation flow written as a single body, with flags
export const ZONES = { 34: 1, 6: 1, 35: 2, 1: 2, 21: 3, 65: 3 };

export function calculateFee(shipment, tariff) {
  let error = null;
  let invalid = false;
  if (shipment.weightGrams <= 0) { error = "weight must be positive"; invalid = true; }
  if (!invalid && shipment.volumeCm3 <= 0) { error = "volume must be positive"; invalid = true; }
  if (!invalid && ZONES[shipment.deliveryProvince] === undefined) {
    error = "delivery province unknown"; invalid = true;
  }
  if (invalid) return { status: "rejected", reason: error };

  const volumeWeight = shipment.volumeCm3 / 3;
  let chargeableWeight = shipment.weightGrams;
  if (volumeWeight > chargeableWeight) chargeableWeight = volumeWeight;

  let base = 0;
  let found = false;
  for (const [maxWeight, fee] of tariff.tiers) {
    if (!found && chargeableWeight <= maxWeight) { base = fee; found = true; }
  }
  if (!found) {
    const last = tariff.tiers[tariff.tiers.length - 1];
    const extraKg = Math.ceil((chargeableWeight - last[0]) / 1000);
    base = last[1] + extraKg * tariff.ratePerKg;
  }

  const zone = ZONES[shipment.deliveryProvince];
  let fee = Math.round(base * tariff.zoneFactor[zone]);

  if (shipment.declaredValue > 100000) fee += Math.round(shipment.declaredValue * 0.01);
  if (shipment.contracted === true) fee = Math.round(fee * 0.9);
  if (fee < tariff.minimumFee) fee = tariff.minimumFee;

  return { status: "calculated", zone, chargeableWeight, fee };
}
```

There are two flags: `invalid` and `found`. Both carry a decision's outcome forward.
The `invalid` flag is written by three separate `if` statements, and all three read the
same variable; the `found` flag is written inside the loop and read after it.

The second version splits the same chain into its steps. Each step has a single entry
and finishes its own decision within itself.

```js
// fee-structural.mjs — same flow, each unit split into single-entry, single-exit procedures
export const ZONES = { 34: 1, 6: 1, 35: 2, 1: 2, 21: 3, 65: 3 };

export function validate(shipment) {
  let reason = null;
  if (shipment.weightGrams <= 0) reason = "weight must be positive";
  else if (shipment.volumeCm3 <= 0) reason = "volume must be positive";
  else if (ZONES[shipment.deliveryProvince] === undefined) reason = "delivery province unknown";
  return reason;
}

export function chargeableWeight(shipment) {
  return Math.max(shipment.weightGrams, shipment.volumeCm3 / 3);
}

export function tierFee(weight, tariff) {
  const tier = tariff.tiers.find(([maxWeight]) => weight <= maxWeight);
  if (tier !== undefined) return tier[1];
  const [lastMax, lastFee] = tariff.tiers[tariff.tiers.length - 1];
  return lastFee + Math.ceil((weight - lastMax) / 1000) * tariff.ratePerKg;
}

export function applyZone(base, zone, tariff) {
  return Math.round(base * tariff.zoneFactor[zone]);
}

export function applyAdjustments(fee, shipment, tariff) {
  let result = fee;
  if (shipment.declaredValue > 100000) result += Math.round(shipment.declaredValue * 0.01);
  if (shipment.contracted === true) result = Math.round(result * 0.9);
  return Math.max(result, tariff.minimumFee);
}

export function calculateFee(shipment, tariff) {
  const reason = validate(shipment);
  if (reason !== null) return { status: "rejected", reason };
  const zone = ZONES[shipment.deliveryProvince];
  const weight = chargeableWeight(shipment);
  const base = tierFee(weight, tariff);
  const fee = applyAdjustments(applyZone(base, zone, tariff), shipment, tariff);
  return { status: "calculated", zone, chargeableWeight: weight, fee };
}
```

Both flags disappeared. `invalid` turned into `validate`'s return value; `found` turned
into a search operation's `undefined` result. A flag is a return value without a name;
once it gets one, it stops being a variable.

## Splitting Did Not Change Behavior

The first half of the claim is that the two versions do the same work. This is measured.

```js
// equivalence.mjs — verifies both versions give the same result on the same input grid
import assert from "node:assert/strict";
import { calculateFee as flagged } from "./fee-flagged.mjs";
import { calculateFee as structural } from "./fee-structural.mjs";

export const TARIFF = {
  tiers: [[1000, 4500], [5000, 7000], [10000, 11000]],
  ratePerKg: 1800,
  zoneFactor: { 1: 1, 2: 1.25, 3: 1.6 },
  minimumFee: 5000,
};

const grid = [];
for (const weightGrams of [500, 4000, 12000])
  for (const volumeCm3 of [900, 30000])
    for (const deliveryProvince of [34, 35, 21, 99])
      for (const declaredValue of [0, 500000])
        for (const contracted of [false, true])
          grid.push({ weightGrams, volumeCm3, deliveryProvince, declaredValue, contracted });

let diverged = 0;
for (const shipment of grid) {
  try {
    assert.deepEqual(flagged(shipment, TARIFF), structural(shipment, TARIFF));
  } catch {
    diverged += 1;
  }
}
const outcomes = new Set(grid.map((g) => JSON.stringify(structural(g, TARIFF))));
console.log(`input count           = ${grid.length}`);
console.log(`diverged result       = ${diverged}`);
console.log(`distinct result class = ${outcomes.size}`);
```

```sh
node equivalence.mjs
```

```
input count           = 96
diverged result       = 0
distinct result class = 48
```

None of the ninety-six inputs diverged. From here, the comparison can proceed by
structure rather than by behavior.

## The Number of Independent Paths

A procedure's number of **independent paths** is one more than its number of decision
points; this measure is called **cyclomatic complexity**. The number gives the minimum
number of paths needed to traverse all of that procedure's branches.

```js
// measurement.mjs — counts each procedure's decision points and the cost of reaching the last decision
import { readFileSync } from "node:fs";

const DECISION = /\b(if|for|while|case|catch)\b|&&|\|\||\?\?/g;

function procedures(source) {
  const found = [];
  const header = /(?:export\s+)?function\s+(\w+)\s*\(/g;
  let m;
  while ((m = header.exec(source)) !== null) {
    const start = source.indexOf("{", m.index);
    let depth = 0;
    let end = start;
    for (; end < source.length; end += 1) {
      if (source[end] === "{") depth += 1;
      else if (source[end] === "}") { depth -= 1; if (depth === 0) break; }
    }
    const body = source.slice(start, end + 1);
    found.push({
      name: m[1],
      decisions: (body.match(DECISION) ?? []).length,
      exits: (body.match(/\breturn\b/g) ?? []).length,
    });
  }
  return found;
}

for (const file of ["fee-flagged.mjs", "fee-structural.mjs"]) {
  const list = procedures(readFileSync(file, "utf8"));
  console.log(file);
  for (const p of list) {
    console.log(`  ${p.name.padEnd(18)} independent paths=${p.decisions + 1}  exits=${p.exits}`);
  }
  console.log(`  procedures=${list.length}` +
    `  largest unit=${Math.max(...list.map((p) => p.decisions + 1))}` +
    `  decisions before last decision=${Math.max(...list.map((p) => p.decisions - 1))}`);
}
```

```sh
node measurement.mjs
```

```
fee-flagged.mjs
  calculateFee       independent paths=15  exits=2
  procedures=1  largest unit=15  decisions before last decision=13
fee-structural.mjs
  validate           independent paths=4  exits=1
  chargeableWeight   independent paths=1  exits=1
  tierFee            independent paths=2  exits=2
  applyZone          independent paths=1  exits=1
  applyAdjustments   independent paths=3  exits=1
  calculateFee       independent paths=2  exits=2
  procedures=6  largest unit=4  decisions before last decision=2
```

The total decision count is close between the two: 15 versus 13. Splitting did not
eliminate decisions; it **distributed** them. What changed is the amount concentrated in
a single unit: it dropped from 15 to 4.

The second number on the last line is more telling. In the flagged version, a run that
reaches the final decision — the minimum-fee floor — carries the outcome of the 13
decisions before it. In the split version, that same decision sits inside the
`applyAdjustments` procedure, with only 2 decisions ahead of it. This difference is the
subject of the next section.

The exits column also shows where the single-exit constraint was loosened. `tierFee` and
`calculateFee` each have two exits, both guard clauses whose target is the procedure's
end. The flagged version also has two exits, but the 30 lines between them fall under
the influence of two flags.

## The Number of Cases Branch Coverage Requires

The measure's counterpart in maintenance is how many cases must be built for **branch
coverage**. The flagged version has a single entry; every branch can only be reached by
building a complete `shipment` object.

```js
// flagged.test.mjs — every branch of the single-body version is reached by building an end-to-end shipment
import { test } from "node:test";
import assert from "node:assert/strict";
import { calculateFee } from "./fee-flagged.mjs";

const TARIFF = {
  tiers: [[1000, 4500], [5000, 7000], [10000, 11000]],
  ratePerKg: 1800,
  zoneFactor: { 1: 1, 2: 1.25, 3: 1.6 },
  minimumFee: 5000,
};
const S = (o) => ({ weightGrams: 4000, volumeCm3: 900, deliveryProvince: 34, declaredValue: 0, contracted: false, ...o });

test("zero weight", () => assert.equal(calculateFee(S({ weightGrams: 0 }), TARIFF).status, "rejected"));
test("zero volume", () => assert.equal(calculateFee(S({ volumeCm3: 0 }), TARIFF).status, "rejected"));
test("unknown province", () => assert.equal(calculateFee(S({ deliveryProvince: 99 }), TARIFF).status, "rejected"));
test("volume weight dominates", () => assert.equal(calculateFee(S({ volumeCm3: 30000 }), TARIFF).chargeableWeight, 10000));
test("weight beyond top tier", () => assert.equal(calculateFee(S({ weightGrams: 12000 }), TARIFF).fee, 14600));
test("shipment with declared value", () => assert.equal(calculateFee(S({ declaredValue: 500000 }), TARIFF).fee, 12000));
test("contracted customer", () => assert.equal(calculateFee(S({ contracted: true }), TARIFF).fee, 6300));
test("minimum fee kicks in", () => assert.equal(calculateFee(S({ weightGrams: 100 }), TARIFF).fee, 5000));
```

In the split version, every unit can be called directly. The input, in most cases, is a
number.

```js
// structural.test.mjs — the split version's branches are covered by calling the units directly
import { test } from "node:test";
import assert from "node:assert/strict";
import { validate, chargeableWeight, tierFee, applyZone, applyAdjustments, calculateFee }
  from "./fee-structural.mjs";

const TARIFF = {
  tiers: [[1000, 4500], [5000, 7000], [10000, 11000]],
  ratePerKg: 1800,
  zoneFactor: { 1: 1, 2: 1.25, 3: 1.6 },
  minimumFee: 5000,
};

test("validate's four branches", () => {
  assert.equal(validate({ weightGrams: 0 }), "weight must be positive");
  assert.equal(validate({ weightGrams: 500, volumeCm3: 0 }), "volume must be positive");
  assert.equal(validate({ weightGrams: 500, volumeCm3: 900, deliveryProvince: 99 }), "delivery province unknown");
  assert.equal(validate({ weightGrams: 500, volumeCm3: 900, deliveryProvince: 34 }), null);
});

test("chargeableWeight's two branches", () => {
  assert.equal(chargeableWeight({ weightGrams: 4000, volumeCm3: 900 }), 4000);
  assert.equal(chargeableWeight({ weightGrams: 4000, volumeCm3: 30000 }), 10000);
});

test("tierFee's two branches", () => {
  assert.equal(tierFee(4000, TARIFF), 7000);
  assert.equal(tierFee(12000, TARIFF), 14600);
});

test("zone adjustment", () => assert.equal(applyZone(7000, 3, TARIFF), 11200));

test("adjustments' three branches", () => {
  assert.equal(applyAdjustments(7000, { declaredValue: 500000, contracted: false }, TARIFF), 12000);
  assert.equal(applyAdjustments(7000, { declaredValue: 0, contracted: true }, TARIFF), 6300);
  assert.equal(applyAdjustments(4500, { declaredValue: 0, contracted: false }, TARIFF), 5000);
});

test("flow's two branches", () => {
  assert.equal(calculateFee({ weightGrams: 0 }, TARIFF).status, "rejected");
  assert.equal(calculateFee(
    { weightGrams: 4000, volumeCm3: 900, deliveryProvince: 34, declaredValue: 0, contracted: false }, TARIFF).fee, 7000);
});
```

The following command runs both suites with coverage measurement and shows only the
numeric lines; the duration fields are filtered out because they change on every run.

```sh
for f in flagged.test.mjs structural.test.mjs; do
  node --test --experimental-test-coverage $f 2>&1 |
    grep -E '^ℹ (tests|pass|fail) |^ℹ (file|fee-|all files)'
done
```

```
ℹ tests 8
ℹ pass 8
ℹ fail 0
ℹ file            | line % | branch % | funcs % | uncovered lines
ℹ fee-flagged.mjs | 100.00 |   100.00 |  100.00 |
ℹ all files       | 100.00 |   100.00 |  100.00 |
ℹ tests 6
ℹ pass 6
ℹ fail 0
ℹ file               | line % | branch % | funcs % | uncovered lines
ℹ fee-structural.mjs | 100.00 |   100.00 |  100.00 |
ℹ all files          | 100.00 |   100.00 |  100.00 |
```

Both versions reach full branch coverage. The difference lies in the cost of achieving
that coverage. Eight cases were built for the flagged version, and **all eight** need a
complete, five-field `shipment` object; the helper `S` function was written precisely to
hide this repetition. The split version has fourteen assertions, and only **one** of them
builds a complete shipment; the rest work with two-field objects or with plain numbers
directly.

The cost shows up when the rule changes. When the minimum-fee rule changes, the flagged
version's relevant cases still have to satisfy the 13 prior decisions needed to reach
that decision; in the split version, the three lines written for `applyAdjustments` are
enough.

## The Gap Structural Splitting Leaves

The split version left one thing unsolved. The `validate` procedure says a shipment is
valid, but this information is stored nowhere. Each of the `chargeableWeight`, `tierFee`,
and `applyAdjustments` procedures **assumes** that the shipment it receives has been
validated. The assumption is not written into the code; it rests on call order.

If the same shipment object is handed to `applyAdjustments` without passing through
validation, nothing stops it. A shipment with negative weight also produces a fee. The
rule — "every shipment a fee is calculated for has been validated" — is an **invariant**,
yet it is protected at no point in the code; it lives only in the ordering inside the
`calculateFee` procedure.

Data standing apart from the procedures that process it is structural splitting's
definition. This separation is simple in small programs and unprotected in data that
carries an invariant. The next lesson's subject is exactly this gap.

## Summary

- A paradigm is not a syntax but a decision about which units a program splits into; the
  structural answer is the procedure.
- A flag variable carries a decision's outcome forward and puts every line in between
  under that decision's influence; a named return value does the same job without
  carrying it.
- The two versions of the same flow gave the same result on all 96 inputs; the
  comparison was made by structure, not by behavior.
- Splitting did not lower the total decision count (15 versus 13); it brought the
  concentration in a single unit down from 15 to 4, and the number of prior decisions to
  satisfy before reaching the final decision down from 13 to 2.
- Both versions reached full branch coverage; in the flagged version all eight of the
  eight cases had to build a complete shipment, while in the split version only one of
  the fourteen assertions does.
- Structural splitting cannot protect the invariant "every shipment a fee is calculated
  for has been validated"; this rule lives only in call order.

## Next Step

Entrusting the invariant to call order means it has no owner to protect it. The next
lesson takes up the tool that gathers data and the behavior that protects it into a
single unit: encapsulation. The measurement changes accordingly — the question is no
longer how many paths there are, but how many objects fall into a corrupted state under
the same sequence of operations.
