Skip to content
academia.sh

Lesson 01 / 14

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.

Contents

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.

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

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

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

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

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

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

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close