Lesson 24 / 30
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.
Contents
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.
mkdir -p script domain
Transaction Script
// 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.
// 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())); } }
// 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.
// 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}`); }
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.
// 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}`);
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.