Lesson 14 / 14
Mixing Paradigms
Using two models together in the same system: an object-heavy and a function-heavy model of carrier options, counting how many files a new operation and a new type touch in each model, and tying the choice to a measured criterion.
Contents
The previous topic recognized one unit: the object that holds state together with the behavior that protects it. This topic built another unit: the function that maps input to output and leaves state outside. The two models divide the same domain — shipment fee calculation — from different places.
This lesson sets the two side by side. The same problem is modeled twice, then two separate changes are requested and how many files each one touches in each model is counted. The result is not a declaration of superiority; it is a statement of which criterion decides which one is chosen.
Two Models of the Same Problem
The problem is this: there are three carrier options — fast, economy, and delivery point — and two operations are defined on each: calculating the fee and estimating the delivery time.
The object-heavy model takes the carrier as its unit. Each type lives in its own file and knows both operations about itself.
mkdir -p object cat > shipments.mjs <<'EOF' export const SHIPMENTS = [ { code: "TR-4471", baseFee: 4990, distanceKm: 450, carrier: "fast" }, { code: "TR-4472", baseFee: 6567, distanceKm: 1200, carrier: "economy" }, { code: "TR-4473", baseFee: 14715, distanceKm: 320, carrier: "point" }, ]; EOF cat > object/fast.mjs <<'EOF' export class Fast { fee(g) { return g.baseFee + 2500; } days(g) { return Math.ceil(g.distanceKm / 600) + 1; } } EOF cat > object/economy.mjs <<'EOF' export class Economy { fee(g) { return g.baseFee; } days(g) { return Math.ceil(g.distanceKm / 350) + 2; } } EOF cat > object/point.mjs <<'EOF' export class Point { fee(g) { return g.baseFee - 750; } days(g) { return Math.ceil(g.distanceKm / 350) + 3; } } EOF cat > object/registry.mjs <<'EOF' import { Fast } from "./fast.mjs"; import { Economy } from "./economy.mjs"; import { Point } from "./point.mjs"; export const CARRIERS = { fast: new Fast(), economy: new Economy(), point: new Point(), }; EOF cat > object/use.mjs <<'EOF' import { SHIPMENTS } from "../shipments.mjs"; import { CARRIERS } from "./registry.mjs"; for (const g of SHIPMENTS) { const c = CARRIERS[g.carrier]; console.log(`${g.code} ${c.fee(g)} cents ${c.days(g)} day(s)`); } EOF node object/use.mjs
TR-4471 7490 cents 2 day(s) TR-4472 6567 cents 6 day(s) TR-4473 13965 cents 4 day(s)
The function-heavy model takes the operation as its unit. The carrier is only a name; each operation lives in its own file and knows every type about itself.
mkdir -p func cat > func/data.mjs <<'EOF' export const CARRIERS = ["fast", "economy", "point"]; EOF cat > func/fee.mjs <<'EOF' export function fee(g) { switch (g.carrier) { case "fast": return g.baseFee + 2500; case "economy": return g.baseFee; case "point": return g.baseFee - 750; default: throw new Error(`unknown carrier: ${g.carrier}`); } } EOF cat > func/days.mjs <<'EOF' export function days(g) { switch (g.carrier) { case "fast": return Math.ceil(g.distanceKm / 600) + 1; case "economy": return Math.ceil(g.distanceKm / 350) + 2; case "point": return Math.ceil(g.distanceKm / 350) + 3; default: throw new Error(`unknown carrier: ${g.carrier}`); } } EOF cat > func/use.mjs <<'EOF' import { SHIPMENTS } from "../shipments.mjs"; import { CARRIERS } from "./data.mjs"; import { fee } from "./fee.mjs"; import { days } from "./days.mjs"; for (const g of SHIPMENTS) { if (CARRIERS.includes(g.carrier) === false) throw new Error(g.carrier); console.log(`${g.code} ${fee(g)} cents ${days(g)} day(s)`); } EOF node func/use.mjs
TR-4471 7490 cents 2 day(s) TR-4472 6567 cents 6 day(s) TR-4473 13965 cents 4 day(s)
The two models produce the same numbers. The difference is not in behavior but in which axis the code is divided along. The object model divides by type: one file knows everything about one carrier. The function model divides by operation: one file knows how one operation is done for every type. The same body of knowledge is sliced in two different directions.
When a New Operation Is Added
The first change is a new operation: an estimated carbon emission, in grams, for every shipment, calculated with a factor that depends on the carrier. The measurement leaves call sites out of scope; calling a new operation requires a new call site in both models regardless.
cp -r object object-b && cp -r func func-b # Object model: the new operation is added to each type file separately. cat > object-b/fast.mjs <<'EOF' export class Fast { fee(g) { return g.baseFee + 2500; } days(g) { return Math.ceil(g.distanceKm / 600) + 1; } carbon(g) { return g.distanceKm * 210; } } EOF cat > object-b/economy.mjs <<'EOF' export class Economy { fee(g) { return g.baseFee; } days(g) { return Math.ceil(g.distanceKm / 350) + 2; } carbon(g) { return g.distanceKm * 95; } } EOF cat > object-b/point.mjs <<'EOF' export class Point { fee(g) { return g.baseFee - 750; } days(g) { return Math.ceil(g.distanceKm / 350) + 3; } carbon(g) { return g.distanceKm * 60; } } EOF # Function model: the new operation is a single new file. cat > func-b/carbon.mjs <<'EOF' export function carbon(g) { switch (g.carrier) { case "fast": return g.distanceKm * 210; case "economy": return g.distanceKm * 95; case "point": return g.distanceKm * 60; default: throw new Error(`unknown carrier: ${g.carrier}`); } } EOF cat > carbon-use.mjs <<'EOF' import { SHIPMENTS } from "./shipments.mjs"; import { CARRIERS } from "./object-b/registry.mjs"; import { carbon } from "./func-b/carbon.mjs"; for (const g of SHIPMENTS) { console.log(`${g.code} object ${CARRIERS[g.carrier].carbon(g)} func ${carbon(g)}`); } EOF node carbon-use.mjs for m in object func; do report=$(diff -rq -x 'use*' "$m" "$m-b") printf '%-6s new files: %d changed files: %d\n' "$m" \ "$(printf '%s\n' "$report" | grep -c '^Only in')" \ "$(printf '%s\n' "$report" | grep -c '^Files')" done
TR-4471 object 94500 func 94500 TR-4472 object 114000 func 114000 TR-4473 object 19200 func 19200 object new files: 0 changed files: 3 func new files: 1 changed files: 0
Both models give the correct result, but their costs are not equal. In the function model, the new operation is a single new file; not a single existing file was opened. In the object model, all three of the type files changed — a new behavior has to be added, separately, to every class that will carry it.
When a New Carrier Type Is Added
The second change runs the opposite direction: a fourth carrier, night courier, is added. Its fee carries a fixed surcharge, and its delivery time is one day regardless of the distance it covers.
cp -r object object-a && cp -r func func-a cat > shipments.mjs <<'EOF' export const SHIPMENTS = [ { code: "TR-4471", baseFee: 4990, distanceKm: 450, carrier: "fast" }, { code: "TR-4472", baseFee: 6567, distanceKm: 1200, carrier: "economy" }, { code: "TR-4473", baseFee: 14715, distanceKm: 320, carrier: "point" }, { code: "TR-4474", baseFee: 5739, distanceKm: 280, carrier: "night" }, ]; EOF # Object model: the new type is one new file, the registry file is updated. cat > object-a/night.mjs <<'EOF' export class Night { fee(g) { return g.baseFee + 4200; } days(g) { return 1; } } EOF cat > object-a/registry.mjs <<'EOF' import { Fast } from "./fast.mjs"; import { Economy } from "./economy.mjs"; import { Point } from "./point.mjs"; import { Night } from "./night.mjs"; export const CARRIERS = { fast: new Fast(), economy: new Economy(), point: new Point(), night: new Night(), }; EOF # Function model: each operation file gets a new branch, the data file gets a new name. cat > func-a/data.mjs <<'EOF' export const CARRIERS = ["fast", "economy", "point", "night"]; EOF cat > func-a/fee.mjs <<'EOF' export function fee(g) { switch (g.carrier) { case "fast": return g.baseFee + 2500; case "economy": return g.baseFee; case "point": return g.baseFee - 750; case "night": return g.baseFee + 4200; default: throw new Error(`unknown carrier: ${g.carrier}`); } } EOF cat > func-a/days.mjs <<'EOF' export function days(g) { switch (g.carrier) { case "fast": return Math.ceil(g.distanceKm / 600) + 1; case "economy": return Math.ceil(g.distanceKm / 350) + 2; case "point": return Math.ceil(g.distanceKm / 350) + 3; case "night": return 1; default: throw new Error(`unknown carrier: ${g.carrier}`); } } EOF node object-a/use.mjs | tail -n 1 node func-a/use.mjs | tail -n 1 for m in object func; do report=$(diff -rq -x 'use*' "$m" "$m-a") printf '%-6s new files: %d changed files: %d\n' "$m" \ "$(printf '%s\n' "$report" | grep -c '^Only in')" \ "$(printf '%s\n' "$report" | grep -c '^Files')" done
TR-4474 9939 cents 1 day(s) TR-4474 9939 cents 1 day(s) object new files: 1 changed files: 1 func new files: 0 changed files: 3
The numbers reverse. In the object model, the new type is one new file; none of the three existing type files was opened, only the registry file was updated. In the function model, all three operation files changed — every operation has to add its own branch to recognize the new type.
Read together, the two measurements complete a picture. The object model is closed to a new type but not open to a new operation; the function model is closed to a new operation but not open to a new type. The axis one model is closed on is the axis the other is open on. This symmetry is called the expression problem, and it gathers the two paradigms’ trade-off into a single sentence.
The Criterion for Choosing, and Mixing
The criterion, then, is not a judgment of superiority — it is a forecast: on which axis of the system is growth expected. In a domain where the number of carriers is fixed and the number of operations performed on them keeps growing — fee, time, carbon, insurance, returns — the function model meets every new operation with a single file. In a domain where the number of carriers keeps growing and the operation set is settled, the object model meets every new type with a single file.
When both axes grow, the choice is not taking one or the other; it is drawing the boundary. This course’s fee library is an example of that mix. The fee calculation itself — tier selection, the zone multiplier, discounts, the floor — is a pipeline built from pure functions and returns a single result; input/output and time stay in the shell. Carrier options are objects, because the axis that grows there is the type axis. The two models run in the same system, not in each other’s place, but on different axes.
The mix has one rule: the boundary must be explicit. Where objects live, state can change; where functions live, data is immutable. If it is unclear which direction a value crosses this boundary, both models’ guarantees are lost.
Summary
- The same problem can be split along two axes: the object model by type, the function model by operation. Both models produce the same numbers.
- When a new operation is added, the function model opens 1 new file and no file changes; in the object model all three type files change.
- When a new type is added, the object model opens 1 new file and 1 registry file changes; in the function model all three operation files change.
- The axis one model is closed on is the axis the other is open on; this symmetry is called the expression problem.
- The choice depends on which axis of the system growth is expected on; when both axes grow, the right decision is not choosing one but drawing the boundary between the two models.
Course Wrap-Up
This course took on the question of which units a program divides into, with two separate answers.
The first topic built structural and object-oriented organization: disciplining control flow, encapsulating state to protect invariants, abstraction that shows what is necessary and hides the rest, inheritance as a subtype relationship, three separate forms of polymorphism, the choice between an interface and an abstract class, preferring composition over the fragile base class, and holding data and behavior together. The second topic built the functional model: pure functions and referential transparency, eliminating shared state with immutable data, pulling behavior out into a function parameter, pipelines built from small steps, splitting side effects between a functional core and an imperative shell, and finally the two models’ criterion-based choice within the same system.
The two leave one assumption in common. Both paradigms described how individual units should be built — whether the unit will be an object or a function, what stays inside it, what stays outside it. Every criterion built looks into the unit: how many decision points it has, how many lines touch the outside, how many fake dependencies it needs. When the relationship between units is healthy was never measured. Yet whether a design is good is usually understood not by looking inside a unit but by looking at the bond between units: how many others a unit knows, how many units a change spreads to, whether the parts inside a unit truly belong to the same job.
The next course, Design Principles, takes on that question. The SOLID principles name a unit’s responsibility and how it may be extended; cohesion and coupling metrics turn the bond between units and the integrity inside a unit into numbers; inverting dependency direction toward abstractions turns which unit knows which into a rule rather than a preference. The units built in this course will be tied together there.
To keep your progress and take notes, Log in
My notes
Log in to take notes.