---
title: 'Mixing Paradigms'
source: 'https://academia.sh/en/courses/paradigms/mixing-paradigms'
course: 'Programming Paradigms'
language: en
updated: '2026-08-23T07:01:20+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```sh
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.

```sh
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.

```sh
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.

```sh
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.
