Skip to content
academia.sh

Lesson 14 / 30

Strategy

Comparing a design where the fee calculation algorithm is embedded at the call site with one where the tariff is passed as a parameter: the number of algorithms that can be tried in a single run, the number of files and lines edited when a new tariff is added, the number of files added as the pattern's cost, and the candidate implementation count that must be traced from the call site.

Contents

The structural patterns organized how objects compose: the adapter bridged two incompatible interfaces, the decorator layered behavior, the composite handled a tree uniformly, and the proxy stepped in and passed the call onward through the same interface. In the proxy, the object in the middle only passed the call along; the calculation itself never changed. The next question is not composition: can the calculation itself behind the same interface be changed at run time?

In the fee library, this is a concrete request. The same set of shipments needs pricing with the standard tariff, the express tariff, and a volumetric tariff whose chargeable weight is derived from volume — all three in the same run, on the same data. The Strategy pattern does this by taking the algorithm into an object and passing it as a parameter instead of embedding it at the call site. The choice among a conditional chain, a lookup table, and polymorphism was measured in the Clean Code and Programming Paradigms courses; this lesson measures something else: which files need editing and how many algorithms can stand side by side in a single run.

Two Designs

The shared data is identical in both versions. Amounts are in cents, weight in kilograms, volume in cubic decimeters.

mkdir -p embedded strategy
// data.mjs — shipments to price (weight kg, volume dm3, amounts cents)
export const SHIPMENTS = [
  { code: "GN-1", weight: 2, volume: 8, zone: "1" },
  { code: "GN-2", weight: 6, volume: 60, zone: "2" },
  { code: "GN-3", weight: 14, volume: 20, zone: "3" },
  { code: "GN-4", weight: 1, volume: 3, zone: "2" },
  { code: "GN-5", weight: 22, volume: 180, zone: "3" },
];

In the first design, the formula lives inside the single implementation that the calling module imports. Batch pricing does not choose which algorithm runs; the import line already chose it.

// embedded/fee.mjs — tariff formula fixed inside the body
const ZONE_FACTOR = { "1": 100, "2": 130, "3": 175 };

export function fee(shipment) {
  const raw = 2500 + 420 * Math.ceil(shipment.weight);
  return Math.max(Math.round((raw * ZONE_FACTOR[shipment.zone]) / 100), 3900);
}
// embedded/batch.mjs — batch pricing; algorithm fixed by the import
import { fee } from "./fee.mjs";

export const batchFee = (shipments) =>
  shipments.map((s) => ({ code: s.code, amount: fee(s) }));

In the second design, each tariff is an object in its own file. The objects’ one shared contract is the fee(shipment) method; the one piece of data they share is the zone factors.

// strategy/zone.mjs — zone factors, the only data the tariffs share
export const ZONE_FACTOR = { "1": 100, "2": 130, "3": 175 };
export const applyZone = (raw, zone) => Math.round((raw * ZONE_FACTOR[zone]) / 100);
// strategy/tariff-standard.mjs
import { applyZone } from "./zone.mjs";

export const standard = {
  name: "standard",
  fee(shipment) {
    return Math.max(applyZone(2500 + 420 * Math.ceil(shipment.weight), shipment.zone), 3900);
  },
};
// strategy/tariff-express.mjs
import { applyZone } from "./zone.mjs";

export const express = {
  name: "express",
  fee(shipment) {
    return Math.max(applyZone(4000 + 640 * Math.ceil(shipment.weight), shipment.zone), 6500);
  },
};
// strategy/tariff-volumetric.mjs — chargeable weight is derived from volume
import { applyZone } from "./zone.mjs";

export const volumetric = {
  name: "volumetric",
  fee(shipment) {
    const chargeableWeight = Math.max(Math.ceil(shipment.weight), Math.ceil(shipment.volume / 5));
    return Math.max(applyZone(2500 + 420 * chargeableWeight, shipment.zone), 3900);
  },
};
// strategy/batch.mjs — the tariff arrives as a parameter
export const batchFee = (shipments, tariff) =>
  shipments.map((s) => ({ code: s.code, amount: tariff.fee(s) }));

Batch pricing no longer imports any tariff. All it knows is that the object it receives carries a fee method.

How Many Algorithms in a Single Run

The first measure is how many separate algorithms can be applied to the same data in the same process. The driver script calls both versions.

// run.mjs — how many tariffs can be tried in a single run
import { SHIPMENTS } from "./data.mjs";
import { batchFee as embeddedBatch } from "./embedded/batch.mjs";
import { batchFee as strategyBatch } from "./strategy/batch.mjs";
import { standard } from "./strategy/tariff-standard.mjs";
import { express } from "./strategy/tariff-express.mjs";
import { volumetric } from "./strategy/tariff-volumetric.mjs";

const print = (label, rows) =>
  console.log(`${label.padEnd(20)} ${rows.map((r) => `${r.code}=${r.amount}`).join(" ")}`);

const embeddedResult = [embeddedBatch(SHIPMENTS)];
print("embedded", embeddedResult[0]);

const strategyResult = [standard, express, volumetric].map((t) => {
  const rows = strategyBatch(SHIPMENTS, t);
  print(`strategy/${t.name}`, rows);
  return rows;
});

console.log(`embedded : tariffs tried in one run = ${embeddedResult.length}`);
console.log(`strategy : tariffs tried in one run = ${strategyResult.length}`);
embedded             GN-1=3900 GN-2=6526 GN-3=14665 GN-4=3900 GN-5=20545
strategy/standard    GN-1=3900 GN-2=6526 GN-3=14665 GN-4=3900 GN-5=20545
strategy/express     GN-1=6500 GN-2=10192 GN-3=22680 GN-4=6500 GN-5=31640
strategy/volumetric  GN-1=3900 GN-2=9802 GN-3=14665 GN-4=3900 GN-5=30835
embedded : tariffs tried in one run = 1
strategy : tariffs tried in one run = 3

One versus three. In the embedded version, trying a second tariff means editing the source and restarting the process; a report comparing three tariffs means three runs and two edits in between. In the strategy version, all three results stand side by side in the same process, so the comparison report is a single function call. The first two lines matching is the validity condition for the measurement: under the standard tariff, the strategy version produces the exact same amounts as the embedded version, so the two designs being compared carry the same behavior.

Adding a Fourth Tariff

The second measure is the cost of change. A fourth tariff named economy is requested: base 1800, rate per kilogram 300, minimum fee 3200. Both trees are copied and the request is applied to each. In the embedded version, the body itself and the signature of the module that calls it; in the strategy version, a single new file.

cp -r embedded embedded-new
cp -r strategy strategy-new

cat > embedded-new/fee.mjs <<'SON'
// embedded/fee.mjs — tariff formula fixed inside the body
const ZONE_FACTOR = { "1": 100, "2": 130, "3": 175 };

export function fee(shipment, mode = "standard") {
  const base = mode === "economy" ? 1800 : 2500;
  const ratePerKg = mode === "economy" ? 300 : 420;
  const minimumFee = mode === "economy" ? 3200 : 3900;
  const raw = base + ratePerKg * Math.ceil(shipment.weight);
  return Math.max(Math.round((raw * ZONE_FACTOR[shipment.zone]) / 100), minimumFee);
}
SON

cat > embedded-new/batch.mjs <<'SON'
// embedded/batch.mjs — batch pricing; algorithm fixed by the import
import { fee } from "./fee.mjs";

export const batchFee = (shipments, mode) =>
  shipments.map((s) => ({ code: s.code, amount: fee(s, mode) }));
SON

cat > strategy-new/tariff-economy.mjs <<'SON'
// strategy/tariff-economy.mjs — existing files untouched
import { applyZone } from "./zone.mjs";

export const economy = {
  name: "economy",
  fee(shipment) {
    return Math.max(applyZone(1800 + 300 * Math.ceil(shipment.weight), shipment.zone), 3200);
  },
};
SON

for k in embedded strategy; do
  edited=$(diff -rq "$k" "$k-new" | grep -c '^Files')
  added=$(diff -rq "$k" "$k-new" | grep -c '^Only in')
  lines=$(diff -r -u "$k" "$k-new" | grep '^[+-][^+-]' | grep -vc '^[+-]//')
  echo "$k: files edited=$edited  files added=$added  lines edited=$lines"
done
echo "file count: embedded=$(ls embedded | wc -l | tr -d ' ')  strategy=$(ls strategy | wc -l | tr -d ' ')"
echo "candidate implementations from the call site: embedded=1  strategy=$(grep -l 'fee(shipment)' strategy/tariff-*.mjs | wc -l | tr -d ' ')"
embedded: files edited=2  files added=0  lines edited=13
strategy: files edited=0  files added=1  lines edited=0
file count: embedded=2  strategy=5
candidate implementations from the call site: embedded=1  strategy=3

In the embedded version, two files and thirteen lines were edited; part of these lines is the body the working tariffs also pass through, so adding the fourth tariff puts the first three at risk. In the strategy version, zero files were edited. This is exactly the measure of the open–closed principle, and the Strategy pattern satisfies it along the tariff axis.

The Cost of the Pattern

The last two lines of the same output give the cost. The file count rose from 2 to 5: three tariff files and the shared zone module. The second item is the reading cost. In the embedded version, the fee call leads to a single body; in the strategy version, the tariff.fee(s) call can statically lead to three separate bodies. To know which one runs, you have to look at where the object was produced — one step up the call chain. This is a level of indirection, and its count is three.

The balance is set up like this: as the tariff count grows, the number of edited lines grows in the embedded version and stays at zero in the strategy version; the file count stays constant in the embedded version and rises by one per tariff in the strategy version. Where the two curves cross is the pattern’s break-even point. In a library with a single tariff, the Strategy pattern adds three files and one level of indirection for zero gain in return; that case is measured in the topic’s last lesson.

Summary

  • The Strategy pattern takes a calculation into a separate object and passes it as a parameter instead of embedding it at the call site; the measured gain is run-time replaceability.
  • On the same data, the number of algorithms tried in a single run came out to 1 in the embedded version and 3 in the strategy version, with identical amounts under the standard tariff.
  • Adding the fourth tariff edited 2 files and 13 lines in the embedded version, versus 0 files edited and 1 file added in the strategy version.
  • The cost has two items: the file count rose from 2 to 5, and the candidate implementation count traced statically from the call site rose from 1 to 3.
  • The pattern pays off as the tariff count grows; with a single implementation, the added file and the indirection go unrewarded.

Next Step

The tariff object carries a calculation, and the caller calls it directly: the dependency is one-directional and visible. Elsewhere in the library, the relationship has to run the other way. When a shipment’s state changes, a log line must be written, a notification sent to the customer, a metrics counter incremented, and a billing record updated. If the shipment module calls these four jobs itself, it has to import all four, and it gets edited again whenever a fifth job is added. The next lesson measures this by the number of outgoing dependencies and the size of the import closure, then reverses the notification and recalculates the same numbers.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close