Lesson 02 / 30
Factory Method and Abstract Factory
Pulling object creation into a method and a family producer: the factory method, where the subtype decides which tariff to build; the abstract factory, which produces the matching triad of tariff, route, and label together; counting the wrong combinations silently accepted at the call site in the matching version; and comparing the file count edited when a new carrier family is added.
Contents
The registry solution gathered carrier names in one place, but never touched how the thing written into the registry gets built. As long as a carrier has one fee function, construction is a single object literal. Once a carrier has a fee tariff, a route plan, and a label format, construction becomes preparing three parts together, and those parts have to fit each other.
This lesson defines two patterns. The factory method pulls a single object’s creation into a method and expects a subtype to satisfy it. The abstract factory produces, in one call, a family of objects that must fit each other. Both ask where the creation decision gets written; their measures differ.
Factory Method
The generator is the type that prepares the offer line. It does not decide for itself which
tariff to build: construction is left to the makeTariff method, and subtypes satisfy that
method with their own implementation.
// method/generator.mjs — offer generator; the subtype decides which tariff to build export class OfferGenerator { makeTariff() { throw new TypeError(`${this.constructor.name} did not implement makeTariff`); } offer(shipment) { const t = this.makeTariff(); return `${t.name}: ${t.fee(shipment)} cents`; } }
// method/main.mjs — two subtypes satisfy the same method with their own tariff import { OfferGenerator } from "./generator.mjs"; class DomesticGenerator extends OfferGenerator { makeTariff() { return { name: "domestic", fee: (s) => 3900 + Math.ceil(s.weight) * 900 }; } } class InternationalGenerator extends OfferGenerator { makeTariff() { return { name: "international", fee: (s) => 18400 + Math.ceil(s.weight) * 2600 }; } } const S = { weight: 2.4, address: "34100" }; for (const g of [new DomesticGenerator(), new InternationalGenerator()]) console.log(g.offer(S)); class IncompleteGenerator extends OfferGenerator {} try { new IncompleteGenerator().offer(S); } catch (e) { console.log(`${e.constructor.name}: ${e.message}`); }
node method/main.mjs
domestic: 6600 cents international: 26200 cents TypeError: IncompleteGenerator did not implement makeTariff
The base type’s body carries no carrier name at all; carrier names appear only in the files holding the subtypes. Counting the lines that carry a name in the two files makes the difference visible.
for d in method/generator.mjs method/main.mjs; do echo "$d: $(grep -c 'domestic\|international' $d)" done
method/generator.mjs: 0 method/main.mjs: 2
This measure is not new: it is the same “spots where the type name appears” measure counted for the registry version, and it improves the same way. What sets the factory method apart from the registry is how the choice is made: in the registry, through a string; in the factory method, through which subtype’s instance is held. The third line shows the pattern’s one genuinely new guarantee: a subtype that fails to satisfy the method fails on the first call, not silently.
Matching Pieces at the Call Site
The family problem starts where the factory method cannot reach. In the version below, the three pieces are grouped by role: every tariff sits in one file, every route in one file, every label in one file. Matching is left to the caller.
// role/tariff.mjs — pieces grouped by role: both families' tariffs sit together export const domesticTariff = { name: "domestic", fee: (s) => 3900 + Math.ceil(s.weight) * 900 }; export const internationalTariff = { name: "international", fee: (s) => 18400 + Math.ceil(s.weight) * 2600 };
// role/route.mjs — both families' routes together; only the international one has a customs point export const domesticRoute = { stops: () => ["34", "06"], customsCode: () => null }; export const internationalRoute = { stops: () => ["34", "GUM", "DE"], customsCode: () => "TR34DE" };
// role/label.mjs — both families' labels together; each wants something different from the route export const domesticLabel = (shipment, route) => { if (route.customsCode() !== null) throw new TypeError("domestic label carries no customs point"); return `TR ${route.stops().join(">")} ${shipment.weight}kg`; }; export const internationalLabel = (shipment, route) => { const code = route.customsCode(); if (code === null) throw new TypeError("international label requires a customs code"); return `INT ${code} ${route.stops().join(">")} ${shipment.weight}kg`; };
// role/offer.mjs — offer takes the three pieces from the caller; nobody checks that they match export const offer = (tariff, route, label, shipment) => `${tariff.fee(shipment)} cents ${label(shipment, route)}`;
// role/main.mjs — the caller matches the three pieces by name itself import { domesticTariff, internationalTariff } from "./tariff.mjs"; import { domesticRoute, internationalRoute } from "./route.mjs"; import { domesticLabel, internationalLabel } from "./label.mjs"; import { offer } from "./offer.mjs"; export const SHIPMENT = { weight: 2.4, address: "34100" }; console.log(offer(domesticTariff, domesticRoute, domesticLabel, SHIPMENT)); console.log(offer(internationalTariff, internationalRoute, internationalLabel, SHIPMENT));
node role/main.mjs
6600 cents TR 34>06 2.4kg 26200 cents INT TR34DE 34>GUM>DE 2.4kg
Both lines are correct. The problem is that the offer signature also accepts calls that are
not correct: each of the three pieces can take one of two values, so eight distinct calls can be
written, and only two of them are the intended pair.
Abstract Factory
In the second version, grouping runs by family, not by role. Each family is a function, and calling it returns the three pieces together.
// family/domestic.mjs — domestic family: the three matching pieces are produced together export const domesticFamily = () => ({ tariff: { name: "domestic", fee: (s) => 3900 + Math.ceil(s.weight) * 900 }, route: { stops: () => ["34", "06"], customsCode: () => null }, label: (shipment, route) => { if (route.customsCode() !== null) throw new TypeError("domestic label carries no customs point"); return `TR ${route.stops().join(">")} ${shipment.weight}kg`; }, });
// family/international.mjs — international family: a route with customs and a label that needs a customs code export const internationalFamily = () => ({ tariff: { name: "international", fee: (s) => 18400 + Math.ceil(s.weight) * 2600 }, route: { stops: () => ["34", "GUM", "DE"], customsCode: () => "TR34DE" }, label: (shipment, route) => { const code = route.customsCode(); if (code === null) throw new TypeError("international label requires a customs code"); return `INT ${code} ${route.stops().join(">")} ${shipment.weight}kg`; }, });
// family/offer.mjs — offer takes the three pieces from a single family; no matching is done export const offer = (family, shipment) => { const { tariff, route, label } = family(); return `${tariff.fee(shipment)} cents ${label(shipment, route)}`; };
// family/main.mjs — composition root: only this file knows the family list import { domesticFamily } from "./domestic.mjs"; import { internationalFamily } from "./international.mjs"; import { offer } from "./offer.mjs"; export const FAMILIES = [domesticFamily, internationalFamily]; export const SHIPMENT = { weight: 2.4, address: "34100" }; for (const f of FAMILIES) console.log(offer(f, SHIPMENT));
node family/main.mjs
6600 cents TR 34>06 2.4kg 26200 cents INT TR34DE 34>GUM>DE 2.4kg
The same two lines. The difference is in the offer signature: it takes two parameters instead
of four, and matching between pieces has left the caller’s hands entirely.
Counting the Matches
The following measurer produces every call that can be written in both designs, runs each one, and splits the result into three classes: the ones giving the intended line, the ones throwing an error, and the ones that throw no error yet still fail to give the intended line. The third class is the silently accepted wrong call.
// combinations.mjs — tries every combination that can be built in both designs and classifies it import { domesticTariff, internationalTariff } from "./role/tariff.mjs"; import { domesticRoute, internationalRoute } from "./role/route.mjs"; import { domesticLabel, internationalLabel } from "./role/label.mjs"; import { offer as roleOffer } from "./role/offer.mjs"; import { domesticFamily } from "./family/domestic.mjs"; import { internationalFamily } from "./family/international.mjs"; import { offer as familyOffer } from "./family/offer.mjs"; const S = { weight: 2.4, address: "34100" }; const attempt = (f) => { try { return f(); } catch { return null; } }; const CORRECT = new Set([familyOffer(domesticFamily, S), familyOffer(internationalFamily, S)]); function count(label, lines) { const errors = lines.filter((s) => s === null).length; const correct = lines.filter((s) => s !== null && CORRECT.has(s)).length; const silent = lines.length - errors - correct; console.log(`${label} combinations=${lines.length} correct=${correct} ` + `silent-wrong=${silent} errors=${errors}`); } count("role ", [domesticTariff, internationalTariff].flatMap((t) => [domesticRoute, internationalRoute].flatMap((r) => [domesticLabel, internationalLabel].map((l) => attempt(() => roleOffer(t, r, l, S)))))); count("family", [domesticFamily, internationalFamily].map((f) => attempt(() => familyOffer(f, S))));
node combinations.mjs
role combinations=8 correct=2 silent-wrong=2 errors=4 family combinations=2 correct=2 silent-wrong=0 errors=0
Of the eight calls writable in the first version, four throw an error, because the label demands a customs code from the route or forbids a customs point from being present. Of the remaining four, two are the intended pair; the other two throw no error and produce a label that looks valid at the wrong fee. Matching the domestic tariff with the international route yields a line carrying 6600 cents alongside a customs label, and no check catches it.
In the second version, writable calls fall from eight to two. The gain is not in catching the
error but in the wrong call becoming unwritable: offer no longer takes pieces, so matching
is not an operation left to do. This is the abstract factory’s measurable contribution: the
silent-wrong count falls from 2 to 0.
The Cost of a New Family
The second measure comes from the Design Principles course: existing files edited and lines added when a new type is introduced. The new family is an air-freight express carrier; both trees are copied and the same requirement applied to each.
cp -r role role-new cp -r family family-new cat >> role-new/tariff.mjs <<'SON' export const expressTariff = { name: "express", fee: (s) => 24600 + Math.ceil(s.weight) * 4100 }; SON cat >> role-new/route.mjs <<'SON' export const expressRoute = { stops: () => ["34", "GUM", "FRA"], customsCode: () => "TR34FR" }; SON cat >> role-new/label.mjs <<'SON' export const expressLabel = (shipment, route) => { const code = route.customsCode(); if (code === null) throw new TypeError("express label requires a customs code"); return `EXP ${code} ${route.stops().join(">")} ${shipment.weight}kg`; }; SON cat >> role-new/main.mjs <<'SON' import { expressTariff } from "./tariff.mjs"; import { expressRoute } from "./route.mjs"; import { expressLabel } from "./label.mjs"; console.log(offer(expressTariff, expressRoute, expressLabel, SHIPMENT)); SON cat > family-new/express.mjs <<'SON' // family/express.mjs — third family: all three pieces live in this file export const expressFamily = () => ({ tariff: { name: "express", fee: (s) => 24600 + Math.ceil(s.weight) * 4100 }, route: { stops: () => ["34", "GUM", "FRA"], customsCode: () => "TR34FR" }, label: (shipment, route) => { const code = route.customsCode(); if (code === null) throw new TypeError("express label requires a customs code"); return `EXP ${code} ${route.stops().join(">")} ${shipment.weight}kg`; }, }); SON cat >> family-new/main.mjs <<'SON' import { expressFamily } from "./express.mjs"; console.log(offer(expressFamily, SHIPMENT)); SON node role-new/main.mjs node family-new/main.mjs measure() { echo "$1 -> $2" echo " edited existing file = $(diff -rq $1 $2 | grep -c '^Files ')" echo " new file = $(diff -rq $1 $2 | grep -c '^Only in ')" echo " added line = $(diff -rN $1 $2 | grep '^>' | grep -cvE '^> *(//|$)')" } measure role role-new measure family family-new
6600 cents TR 34>06 2.4kg 26200 cents INT TR34DE 34>GUM>DE 2.4kg 36900 cents EXP TR34FR 34>GUM>FRA 2.4kg 6600 cents TR 34>06 2.4kg 26200 cents INT TR34DE 34>GUM>DE 2.4kg 36900 cents EXP TR34FR 34>GUM>FRA 2.4kg role -> role-new edited existing file = 4 new file = 0 added line = 11 family -> family-new edited existing file = 1 new file = 1 added line = 11
Lines added are 11 in both; the amount of code written is the same. Existing files edited fell from four to one, and the one remaining file is the composition root. This is the same split found in the Component Cohesion Principles lesson: grouping by role scatters one family’s pieces across three files, grouping by family keeps what changes together in one place.
The cost is countable too. The family version gained a file when express was added, file count
rising from four to five, and the label body was written three separate times across three
family files. In the role version, the three label formats sit side by side in one file, and a
change to the label contract means editing that one file; the same change in the family version
touches as many files as there are families. A pattern closes one axis and opens another.
This is why the abstract factory is not applied without a real matching constraint between the pieces: without one, the silent-wrong count is already zero, and the pattern only raises file count.
Summary
- The factory method pulls one object’s creation into a method; the base type’s body carries no type name, and a subtype failing to satisfy the method throws on the first call.
- The abstract factory produces, in one call, pieces that must fit each other; its measure is the count of writable calls.
- Matching pieces at the call site, eight calls could be written: four threw an error, two were correct, two threw no error yet produced the wrong fee; in the family version, writable calls fell to 2 and silent-wrong calls to 0.
- Adding a new family, both versions added 11 lines, but existing files edited fell from 4 to 1, and that one file is the composition root.
- The cost shows up when the label contract changes: in the family version, that change touches as many files as there are families; in the role version, one.
Next Step
Calling the family function returns the three pieces together, but the pieces themselves are still constants buildable with a single expression. Once the object to build has many fields — a shipment’s weight, its three dimensions, two addresses, a declared value, a contract number — construction no longer fits one call, and positional parameters become interchangeable. The next lesson breaks this call into steps: it produces every call with a swapped argument pair, counts the silently accepted wrong constructions, moves construction into named steps, and gathers the required-field check into one place.
To keep your progress and take notes, Log in
My notes
Log in to take notes.