Lesson 01 / 30
Pattern Concept
Defining a pattern as the triad of problem, solution, and consequences: applying a registry solution already familiar from two separate lessons to carrier selection in the shipping library, measuring the two versions that produce the same output by file count, import edge count, the number of spots where a type name appears, and the longest import path, and counting the gain and the cost separately.
Contents
The Design Principles course closed with a set of criteria: keep the boundary narrow, let dependency point in the stable direction, keep what changes together in one place. None of these sentences hands over a solution. A module failing the open–closed principle is measurable, but the principle does not say what to write in that module’s place. What decides the answer is that the same problem has already been solved before.
When the same problem is solved the same way, repeatedly, across different code bases, that solution has a name, a known form, and known consequences. A pattern is exactly this: not a piece of code, but the named solution to a recurring problem, together with the consequences that solution brings. This lesson defines the parts of that triad, applies a solution already found twice before, in the shipping fee library, to carrier selection, and counts its consequences.
Finding the Same Solution Twice
In the Choosing the Right Structure lesson of the Clean Code course, a long conditional chain
had been moved into a table; the table was a registry that mapped a type name to a
behavior. In the Open–Closed Principle lesson of the Design Principles course, the same
structure showed up again: instead of resolving the tariff type with a switch, tariffs wrote
themselves into a registry, and the fee module never learned the type names at all.
The two lessons did not share a problem. The first wanted to lower one body’s decision-point count; the second wanted to stop a new type from forcing an edit to an existing file. Even so, the solution they landed on had the same shape, letter for letter: a mapping table, a function that writes to it, a function that reads from it, and a single composition root that fills it. A solution found a second time, under a separate pressure, is the sign it deserves a name.
The Three Parts of the Triad
A pattern’s narrative has three parts, and each part answers a different question.
| Part | Its question | Counterpart in the registry solution |
|---|---|---|
| Problem | Under what pressure does it show up | The number of types is growing, and each new type forces an edit to existing files |
| Solution | Which pieces stand in which relationship | A name-behavior table, a function that writes, a function that reads, a composition root that fills it |
| Consequences | What it improves, what it worsens | The number of spots the type name appears in falls; the file count and the import edge count rise |
A context sentence sits in front of the triad: the condition under which the pattern applies. The registry solution’s context is that types are selected by name at run time. If types are known at compile time and no selection ever happens, there is no problem, and therefore no pattern.
Of the three parts, consequences are skipped most often. “Applying” a pattern is not building its structure but accepting its consequences; a pattern cannot be chosen without counting them first.
Two Versions of Carrier Selection
The library has a new problem: for one shipment, several carrier offers need to be produced.
Each carrier has its own fee and its own transit time. In the first version, the carrier name
is resolved with two separate switch statements.
// switch/carrier.mjs — carrier name resolved with two separate switches export function fee(name, shipment) { switch (name) { case "domestic": return 3900 + Math.ceil(shipment.weight) * 900; case "express": return 6400 + Math.ceil(shipment.weight) * 1500; default: throw new RangeError(`unknown carrier: ${name}`); } } export function transitDays(name) { switch (name) { case "domestic": return 3; case "express": return 1; default: throw new RangeError(`unknown carrier: ${name}`); } }
// switch/offer.mjs — offer line; also counts the carrier names itself import { fee, transitDays } from "./carrier.mjs"; export const NAMES = ["domestic", "express"]; export const line = (name, s) => `${name}: ${fee(name, s)} cents / ${transitDays(name)} day(s)`;
// switch/main.mjs — writes every carrier offer for one example shipment import { NAMES, line } from "./offer.mjs"; export const SHIPMENT = { weight: 2.4, address: "34100" }; for (const name of NAMES) console.log(line(name, SHIPMENT));
node switch/main.mjs
domestic: 6600 cents / 3 day(s) express: 10900 cents / 1 day(s)
The second version applies the registry solution. Each carrier stands as an object in its own file and writes itself into the registry.
// registry/registry.mjs — carrier registry: every carrier writes itself into this table const CARRIERS = new Map(); export function register(carrier) { CARRIERS.set(carrier.name, carrier); } export const names = () => [...CARRIERS.keys()]; export function carrier(name) { const c = CARRIERS.get(name); if (c === undefined) throw new RangeError(`unknown carrier: ${name}`); return c; }
// registry/carriers/domestic.mjs — domestic carrier import { register } from "../registry.mjs"; register({ name: "domestic", fee: (s) => 3900 + Math.ceil(s.weight) * 900, transitDays: () => 3, });
// registry/carriers/express.mjs — express carrier import { register } from "../registry.mjs"; register({ name: "express", fee: (s) => 6400 + Math.ceil(s.weight) * 1500, transitDays: () => 1, });
// registry/offer.mjs — offer line; no carrier name passes through it import { carrier } from "./registry.mjs"; export const line = (name, s) => { const c = carrier(name); return `${name}: ${c.fee(s)} cents / ${c.transitDays()} day(s)`; };
// registry/main.mjs — composition root: only this file knows which carriers load import "./carriers/domestic.mjs"; import "./carriers/express.mjs"; import { names } from "./registry.mjs"; import { line } from "./offer.mjs"; export const SHIPMENT = { weight: 2.4, address: "34100" }; for (const name of names()) console.log(line(name, SHIPMENT));
node registry/main.mjs
domestic: 6600 cents / 3 day(s) express: 10900 cents / 1 day(s)
The outputs match. The only difference is in the consequences, and consequences can be counted.
Counting the Consequences
The following measurer reads the modules under a directory and yields four numbers: file count, import edge count, the number of spots where the given type names appear as string literals, and the length of the longest path in the import graph. The first two numbers measure the pattern’s cost, the third its gain, the fourth its level of indirection.
// measure.mjs — file, import edge, type-name and indirection measures of a design import { readdirSync, readFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; const root = process.argv[2]; const TYPES = process.argv.slice(3); const collect = (d) => readdirSync(d, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? collect(join(d, e.name)) : e.name.endsWith(".mjs") ? [join(d, e.name)] : []); const text = new Map(collect(root).sort().map((y) => [relative(root, y), readFileSync(y, "utf8")])); const edge = new Map(); for (const [name, m] of text) { edge.set(name, [...m.matchAll(/(?:from|import)\s*["'](\.[^"']+)["']/g)] .map((x) => relative(root, resolve(dirname(join(root, name)), x[1])))); } const edgeCount = [...edge.values()].reduce((t, h) => t + h.length, 0); let typeSpot = 0; for (const [, m] of text) for (const t of TYPES) typeSpot += [...m.matchAll(new RegExp(`"${t}"`, "g"))].length; const depth = (name, seen = new Set()) => { const next = (edge.get(name) ?? []).filter((h) => !seen.has(h)); return next.length === 0 ? 0 : 1 + Math.max(...next.map((h) => depth(h, new Set([...seen, name])))); }; const longest = Math.max(...[...edge.keys()].map((a) => depth(a))); console.log(`${root.padEnd(9)} files=${text.size} import-edges=${edgeCount} ` + `type-name-spots=${typeSpot} longest-import-path=${longest}`);
node measure.mjs switch domestic express node measure.mjs registry domestic express
switch files=3 import-edges=2 type-name-spots=6 longest-import-path=2 registry files=5 import-edges=7 type-name-spots=2 longest-import-path=2
The gain is in the third number. In the first version, the two carrier names appear in six
spots: each once in the two switch statements and once in the NAMES array. This is the
knowledge duplication defined in the Clean Code course; the carrier list is held separately in
three places, and nothing keeps the three in agreement. In the second version, each name
appears once, only in its own file.
The cost is in the first two numbers. File count went from three to five, import edge count from two to seven. The second number is the same dependency count measured in the Component Coupling Principles lesson: five edges were added to the graph, four of them into a single file, the composition root.
The fourth number shows where the cost does not show up: the longest import path is 2 in both
versions. So the indirection grows not in path length between files but in resolution that
happens at run time. In the first version, which body a fee call reaches is found by reading
the file; in the second, the call’s target depends on which files wrote into the registry, and
that information lives only in the composition root. A single measure cannot give a pattern’s
full cost.
Splitting by Problem Class
Patterns are separated by the problem type they solve, and this course takes its sections from that split. Creational patterns solve who builds an object and how. Structural patterns arrange how existing objects are combined. Behavioral patterns handle the distribution of responsibility and communication among objects. Enterprise application patterns belong to the boundary between business logic and persistence; some were already built in the Data Access Layer and Business Logic course of the Backend Development curriculum, and here they are taken up again at the pattern-catalog level.
The registry solution falls on the creational side, because what it solves is not behavior itself but where the object carrying it comes from. The next two lessons ask this question more sharply.
Summary
- A pattern is not a piece of code but the named solution to a recurring problem; its narrative is the triad of problem, solution, and consequences, with a context condition in front of it.
- A solution counts as a pattern when the same structure is rediscovered under separate pressures; the registry solution came out identical for two separate problems in the Clean Code and Design Principles courses.
- Across two versions producing the same offer line, the spots where the type name appears fell from 6 to 2; this is the pattern’s gain.
- In the same comparison, file count rose from 3 to 5 and import edge count from 2 to 7; this is the pattern’s cost, read from the same measure set as the gain.
- The longest import path stayed at 2 in both versions: a pattern’s indirection cost does not show up in a single measure, and run-time resolution of the call target must be accounted for separately.
Next Step
In the registry version, the offer module does not know the carrier names, but it still asks, through a string, which name corresponds to which object, and the registry itself builds the object. When building the object does not fit in a single call — if a carrier has a fee calculation, a route planner, and a label format — the thing written to the registry turns into a construction procedure of its own. The next lesson pulls this construction into a method: it defines the factory method, where the subtype decides which object to produce, adds the abstract factory, which produces together the objects that must fit each other, and counts the file and line count edited in both versions when a new carrier family is added.
To keep your progress and take notes, Log in
My notes
Log in to take notes.