---
title: 'Transaction Script and Domain Model'
source: 'https://academia.sh/en/courses/design-patterns/transaction-script-and-domain-model'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:15+00:00'
license: 'CC BY-SA 4.0'
---

# Transaction Script and Domain Model

Two arrangements of business logic: transaction script, which runs each scenario from start to finish, against domain model, which places the rule on the object that carries the data; counting how many places the same rule is applied, testing the two arrangements for equality on the same inputs, and weighing the domain model's file, name, and indirection cost.

The behavioral patterns topic showed the point where a pattern's cost drops below its gain.
All the patterns up to that point worked at the **object level**: a responsibility was taken,
handed to a separate object, and its cost was paid in a level of indirection.

This topic moves the scale up by one step. The question is no longer which object a
responsibility is handed to, but where an application's business logic sits in its **layering**
and which patterns establish the boundary between that logic and persistence. Most of these
patterns were established in The Data Access Layer and Business Logic course, where they were
tested against performance measures. Here the same patterns are taken up at the catalog level:
what problem each one solves, what its solution is, what consequences it produces, and **when it
does not apply**.

## Problem: How Many Places Does the Rule Live In

The shipment fee library has three scenarios: opening a new shipment record, adding a discount
to an existing shipment, and computing a return fee. All three use the same two rules: the sum
of the discount rates cannot exceed a cap, and the net fee cannot fall below the minimum fee.

Where the rule is written produces two arrangements. **Transaction Script** writes a procedure
that runs each scenario from start to finish; the rule stays inside the scenario. **Domain
Model** places the rule on the object that carries the data; the scenario only sets up the
order.

```sh
mkdir -p script domain
```

## Transaction Script

```js
// script/scenarios.mjs — three scenarios, each applying the rule inside itself
const TIER = [[1, 3900], [5, 6400], [Infinity, 11800]];
const COEFFICIENT = { "34": 1, "06": 1.35, "65": 1.8 };
const MINIMUM_FEE = 3990;
const DISCOUNT_CAP = 0.4;

const base = (weight) => TIER.find(([cap]) => weight <= cap)[1];
const coefficient = (postalCode) => COEFFICIENT[postalCode.slice(0, 2)] ?? 1.8;
const totalRate = (discounts) => discounts.reduce((t, d) => t + d.rate, 0);

export function newShipment(input) {
  const raw = Math.round(base(input.weight) * coefficient(input.postalCode));
  const rate = Math.min(totalRate(input.discounts), DISCOUNT_CAP);
  return { ...input, raw, net: Math.max(Math.round(raw * (1 - rate)), MINIMUM_FEE) };
}

export function addDiscount(shipment, discount) {
  const discounts = [...shipment.discounts, discount];
  const rate = Math.min(totalRate(discounts), DISCOUNT_CAP);
  return { ...shipment, discounts, net: Math.max(Math.round(shipment.raw * (1 - rate)), MINIMUM_FEE) };
}

export function returnFee(shipment) {
  const rate = Math.min(totalRate(shipment.discounts), DISCOUNT_CAP);
  return Math.round(shipment.raw * 0.3 * (1 - rate));
}
```

The arrangement's gain is direct readability: understanding a scenario takes reading a single
procedure from start to finish, with no need to visit another file. Its loss is that the rule is
repeated as many times as there are scenarios.

## Domain Model

The same work can also be written by placing the rule on the object.

```js
// domain/shipment.mjs — the rule on the object, in one place
const TIER = [[1, 3900], [5, 6400], [Infinity, 11800]];
const COEFFICIENT = { "34": 1, "06": 1.35, "65": 1.8 };
const MINIMUM_FEE = 3990;
const DISCOUNT_CAP = 0.4;

export class Shipment {
  constructor(input) {
    this.weight = input.weight;
    this.postalCode = input.postalCode;
    this.discounts = [...input.discounts];
    this.raw = Math.round(this.#base() * this.#coefficient());
  }
  #base() { return TIER.find(([cap]) => this.weight <= cap)[1]; }
  #coefficient() { return COEFFICIENT[this.postalCode.slice(0, 2)] ?? 1.8; }
  discountRate() {
    return Math.min(this.discounts.reduce((t, d) => t + d.rate, 0), DISCOUNT_CAP);
  }
  net() { return Math.max(Math.round(this.raw * (1 - this.discountRate())), MINIMUM_FEE); }
  addDiscount(discount) { this.discounts.push(discount); return this; }
  returnFee() { return Math.round(this.raw * 0.3 * (1 - this.discountRate())); }
}
```

```js
// domain/scenarios.mjs — scenarios only set up the order, the rule stays on the object
import { Shipment } from "./shipment.mjs";

export const newShipment = (input) => new Shipment(input);
export const addDiscount = (shipment, discount) => shipment.addDiscount(discount);
export const returnFee = (shipment) => shipment.returnFee();
```

The scenario file shrank to three lines. The two rules now live inside the `discountRate` and
`net` methods, each written exactly once.

## Counting Rule Application Points

The difference between the two arrangements is not asserted, it is counted. The measure is the
**rule application point** count: how many separate lines use the rule's name, outside the line
where it is defined.

```js
// count-rules.mjs — rule application point, file, and exported name count in both arrangements
import { readFileSync } from "node:fs";

const ARRANGEMENT = {
  "transaction script": ["script/scenarios.mjs"],
  "domain model": ["domain/shipment.mjs", "domain/scenarios.mjs"],
};
const RULE = { "discount cap": /DISCOUNT_CAP/, "minimum fee": /MINIMUM_FEE/ };

for (const [name, files] of Object.entries(ARRANGEMENT)) {
  let lines = 0, exported = 0;
  const points = Object.fromEntries(Object.keys(RULE).map((k) => [k, 0]));
  for (const file of files) {
    const rows = readFileSync(file, "utf8").split("\n");
    lines += rows.filter((s) => s.trim() !== "").length;
    for (const s of rows) {
      if (/^\s*export\b/.test(s)) exported += 1;
      if (/^\s*const [A-Z_]+ =/.test(s)) continue;
      for (const [k, pattern] of Object.entries(RULE)) if (pattern.test(s)) points[k] += 1;
    }
  }
  console.log(`${name}`);
  console.log(`  files = ${files.length}  lines = ${lines}  exported names = ${exported}`);
  for (const [k, n] of Object.entries(points)) console.log(`  ${k} application points = ${n}`);
}
```

```sh
node count-rules.mjs
```

```
transaction script
  files = 1  lines = 22  exported names = 3
  discount cap application points = 3
  minimum fee application points = 2
domain model
  files = 2  lines = 26  exported names = 4
  discount cap application points = 1
  minimum fee application points = 1
```

The numbers reduce the trade-off to a single line. The discount cap is applied at 3 points in
the transaction script and 1 in the domain model; the minimum fee at 2 points against 1. If the
rule's shape changes — the cap stops being a fixed rate and becomes a value computed by customer
type — 3 lines must be re-verified in the transaction script, 1 in the domain model.

The cost side stands in the same output: the domain model holds 2 files instead of 1, 26 lines
instead of 22, and 4 exported names instead of 3. The number of files that must be read to reach
the rule also rose, from 1 to 2.

## Do the Two Arrangements Give the Same Result

That one arrangement can substitute for the other is not asserted, it is tested.

```js
// are-equivalent.mjs — do the two arrangements give the same result for the same inputs
import * as script from "./script/scenarios.mjs";
import * as domain from "./domain/scenarios.mjs";

const INPUTS = [
  { weight: 0.6, postalCode: "34710", discounts: [] },
  { weight: 3, postalCode: "06800", discounts: [{ name: "contract", rate: 0.15 }] },
  { weight: 12, postalCode: "65100", discounts: [{ name: "contract", rate: 0.25 }, { name: "volume", rate: 0.3 }] },
  { weight: 0.4, postalCode: "81600", discounts: [{ name: "volume", rate: 0.5 }] },
];
const EXTRA = { name: "campaign", rate: 0.1 };
const show = (n) => String(n).padStart(5);

let diverged = 0;
for (const input of INPUTS) {
  const s = script.newShipment(input), d = domain.newShipment(input);
  const first = [s.net, d.net()];
  const s2 = script.addDiscount(s, EXTRA), d2 = domain.addDiscount(d, EXTRA);
  const second = [s2.net, d2.net()];
  const returnFee = [script.returnFee(s2), domain.returnFee(d2)];
  if (first[0] !== first[1] || second[0] !== second[1] || returnFee[0] !== returnFee[1]) diverged += 1;
  console.log(`${String(input.weight).padStart(4)} kg ${input.postalCode}  net ${show(first[0])}/${show(first[1])}  with extra discount ${show(second[0])}/${show(second[1])}  return ${show(returnFee[0])}/${show(returnFee[1])}`);
}
console.log(`diverged result = ${diverged} / ${INPUTS.length}`);
```

```sh
node are-equivalent.mjs
```

```
 0.6 kg 34710  net  3990/ 3990  with extra discount  3990/ 3990  return  1053/ 1053
   3 kg 06800  net  7344/ 7344  with extra discount  6480/ 6480  return  1944/ 1944
  12 kg 65100  net 12744/12744  with extra discount 12744/12744  return  3823/ 3823
 0.4 kg 81600  net  4212/ 4212  with extra discount  4212/ 4212  return  1264/ 1264
diverged result = 0 / 4
```

All three results match on all four inputs. The minimum fee takes over on the first row, the
discount cap on the fourth; both arrangements apply the same limit at the same point. The choice
is not a choice of correctness, it is a choice of change cost.

An invisible distinction sits between the two versions: the transaction script returns a new
record at every step, the domain model mutates the same object. That distinction raises the
question of how many copies of the same shipment exist in memory at once, and it is what the
identity map lesson measures.

## When It Does Not Apply

The domain model's gain comes from cutting repetition, so the gain grows with the repetition
count. In the measurement above, the gain is that the cap rule drops from 3 points to 1; the
cost is 1 extra file, 1 extra exported name, and 4 extra lines.

In a single-scenario library, the same math runs in reverse: the application point count is
already 1, the gain is 0, and the cost is unchanged. In an application that never has to write a
rule in three places, the domain model does not earn back the indirection it pays for. The
dividing measure is not the scenario count either — it is the count of scenarios that touch the
same rule. If one out of five scenarios uses the rule, the transaction script is still cheap.

The second counter-case is work whose rules derive not from data but from external systems. The
domain model produces a decision from the object's own data; if the decision's input is an
external call every time, the object only carries data and the rule stays in the scenario. This
case is called an **anemic domain model**: a class exists, behavior does not, so the cost is paid
but the gain is not collected.

## Summary

- Transaction script runs each scenario in a single procedure and writes the rule inside the
  scenario; domain model moves the rule to the object that carries the data and leaves the
  scenario only the order.
- Counting rule application points showed the discount cap applied at 3 points in the transaction
  script against 1 in the domain model, and the minimum fee at 2 points against 1.
- The domain model's cost appeared in the same measurement: 2 files instead of 1, 26 lines
  instead of 22, 4 exported names instead of 3, and 2 files to read to reach the rule.
- On all four inputs, the two arrangements produced the same net fee and the same return fee; the
  choice is not a choice of correctness, it is a choice of change cost.
- The gain grows with the number of scenarios that touch the same rule; in a single scenario the
  gain drops to zero and the cost remains. Classes without behavior produce an anemic domain
  model.

## Next Step

Both arrangements in this lesson kept the shipment in memory; neither wrote it anywhere. Once a
shipment starts being stored, a **conversion** appears between the object and the stored record,
and where that conversion is placed produces a second pair of patterns: placing the conversion on
the object itself, or handing it to a separate mapper. The next lesson builds these two patterns
on the same shipment, counts the number of names the domain module has to know about persistence
and the size of its import closure, and compares the number of files touched when a second
storage form is added.
