Lesson 06 / 19
Domain Services
Measuring, across two models where best-tariff selection is loaded onto the shipment and where it is handed to a separate domain service, the number of modules the shipment module imports, the number of objects reachable from a shipment, and the serialization length; counting the persistence trace that separates the domain service from the application service.
Contents
The aggregate root’s methods decide with the data inside their own boundary. The library holds a behavior that does not fit this pattern: selecting the best tariff to apply to a shipment. The domain expert states the rule this way — among the eligible tariffs, the one with the lowest net fee is selected, and a tariff that requires a contract applies only to a customer whose contract authorizes that tariff.
The decision requires three things together: the shipment, the tariff catalog, and the contract. None of the three owns the others. The place where a behavior is put that cannot decide with its own data, that requires several domain objects together, and that belongs to none of them, is called a domain service.
The Metric: Number of Collaborators
Where a behavior belongs is determined by two questions. How many domain objects does making the decision require? Does one of these objects own the others?
Deciding whether a shipment counts as bulky requires only the shipment’s weight; the number of collaborators is zero, and the decision is the shipment’s method. Tariff selection requires three objects and has no owner; the decision belongs to a domain service. The cap rule requires two objects — the shipment and its discounts — but one owns the other, so it remains the root’s method, as in the previous lesson.
The Model That Loads Behavior Onto the Object
Both versions use the same catalog and the same authorization rule.
mkdir -p common loaded service
// common/tariff-catalog.mjs — tariff catalog and fee calculation for a tariff export const TARIFFS = [ { name: "standard", tier: [[1, 3900], [5, 6400], [Infinity, 11800]], minimum: 3990, contracted: false }, { name: "high-volume", tier: [[1, 4200], [5, 5900], [Infinity, 9800]], minimum: 4500, contracted: true }, { name: "regional", tier: [[1, 3600], [5, 6900], [Infinity, 13200]], minimum: 3600, contracted: false }, ]; const ZONE_COEFFICIENTS = { "34": 1, "06": 1.35, "65": 1.8 }; export function tariffFee(weight, zone, tariff) { const base = tariff.tier.find(([cap]) => weight <= cap)[1]; return Math.max(Math.round(base * (ZONE_COEFFICIENTS[zone] ?? 1.8)), tariff.minimum); }
// common/contract-rules.mjs — authorization rule for a tariff that requires a contract export const isAuthorized = (contract, tariff) => !tariff.contracted || (contract?.authorizedTariffs ?? []).includes(tariff.name);
The first version makes selection the shipment’s method. The method imports the catalog and the authorization rule in its own module, and keeps the contract inside the shipment as well.
// loaded/shipment.mjs — selection is the shipment's method; catalog and contract live inside the shipment import { TARIFFS, tariffFee } from "../common/tariff-catalog.mjs"; import { isAuthorized } from "../common/contract-rules.mjs"; export class Shipment { constructor(shipmentNo, weight, zone, contract) { this.shipmentNo = shipmentNo; this.weight = weight; this.zone = zone; this.contract = contract; } bestTariff() { const eligible = TARIFFS.filter((t) => isAuthorized(this.contract, t)); return eligible.reduce((best, t) => tariffFee(this.weight, this.zone, t) < tariffFee(this.weight, this.zone, best) ? t : best); } netFee() { return tariffFee(this.weight, this.zone, this.bestTariff()); } }
This version also breaks the previous lesson’s rule: the shipment carries an aggregate outside its own consistency boundary — the contract — by object, not by identity.
The Model That Hands Behavior to the Service
In the second version, the shipment holds only its own data and refers to the contract by its number. Selection becomes a function with three parameters, in a separate module.
// service/shipment.mjs — the shipment carries only its own data, references the contract by its number export class Shipment { constructor(shipmentNo, weight, zone, contractNo) { this.shipmentNo = shipmentNo; this.weight = weight; this.zone = zone; this.contractNo = contractNo; } isBulky() { return this.weight >= 5; } }
// service/tariff-selection.mjs — domain service: all three are parameters, none is owned import { tariffFee } from "../common/tariff-catalog.mjs"; import { isAuthorized } from "../common/contract-rules.mjs"; export function bestTariff(shipment, tariffs, contract) { const eligible = tariffs.filter((t) => isAuthorized(contract, t)); if (eligible.length === 0) throw new RangeError("no eligible tariff"); return eligible.reduce((best, t) => tariffFee(shipment.weight, shipment.zone, t) < tariffFee(shipment.weight, shipment.zone, best) ? t : best); } export const netFee = (shipment, tariffs, contract) => tariffFee(shipment.weight, shipment.zone, bestTariff(shipment, tariffs, contract));
The line that separates the domain service from the application service appears in a third file that does the same job. The application layer’s responsibility was established in the Layer Responsibilities lesson of the Data Access Layer and Business Logic course, and the service layer pattern was established in the Design Patterns course; the only question here is which knowledge sits in which file.
// service/price.mjs — application service: this is where the repository, clock, and saving are known import { netFee } from "./tariff-selection.mjs"; export function price(shipmentNo, repository, clock) { const shipment = repository.findShipment(shipmentNo); const contract = repository.findContract(shipment.contractNo); const amount = netFee(shipment, repository.tariffs(), contract); repository.save({ shipmentNo, amount, at: clock() }); return amount; }
Measurement
Four measurements are taken: the number of modules the shipment module imports, the number of objects reachable from a shipment instance, the serialization length, and the persistence trace of two files. At the end, whether the two models give the same tariff and the same amount for six shipments is added.
// service-measure.mjs — external names the shipment knows, object graph, persistence trace, and equivalence import { readFileSync } from "node:fs"; import { TARIFFS } from "./common/tariff-catalog.mjs"; import { Shipment as LoadedShipment } from "./loaded/shipment.mjs"; import { Shipment as ServiceShipment } from "./service/shipment.mjs"; import { bestTariff, netFee } from "./service/tariff-selection.mjs"; const CONTRACTS = { "S-77": { contractNo: "S-77", authorizedTariffs: ["high-volume"] }, "S-90": { contractNo: "S-90", authorizedTariffs: [] }, }; const INPUTS = [ ["G-3001", 0.4, "34", "S-77"], ["G-3002", 3, "06", "S-77"], ["G-3003", 12, "65", "S-77"], ["G-3004", 0.9, "34", "S-90"], ["G-3005", 7, "06", "S-90"], ["G-3006", 18, "65", "S-90"], ]; const importCount = (d) => (readFileSync(d, "utf8").match(/^import /gm) ?? []).length; console.log(`modules imported by the shipment module: loaded ${importCount("loaded/shipment.mjs")}, ` + `service ${importCount("service/shipment.mjs")}`); function reachable(root) { const seen = new Set(); const queue = [root]; while (queue.length > 0) { const n = queue.pop(); if (n === null || typeof n !== "object" || seen.has(n)) continue; seen.add(n); for (const v of Object.values(n)) queue.push(v); } return seen.size; } const l = new LoadedShipment("G-3001", 0.4, "34", CONTRACTS["S-77"]); const s = new ServiceShipment("G-3001", 0.4, "34", "S-77"); console.log(`objects reachable from a shipment: loaded ${reachable(l)}, ` + `service ${reachable(s)}`); console.log(`serialization length: loaded ${JSON.stringify(l).length}, ` + `service ${JSON.stringify(s).length}`); const TRACE = /repository|save|clock/; for (const d of ["service/tariff-selection.mjs", "service/price.mjs"]) { const lines = readFileSync(d, "utf8").split("\n").filter((line) => TRACE.test(line)); console.log(`${d}: lines carrying a persistence trace ${lines.length}`); } let diverged = 0; for (const [no, weight, zone, contractNo] of INPUTS) { const lg = new LoadedShipment(no, weight, zone, CONTRACTS[contractNo]); const sg = new ServiceShipment(no, weight, zone, contractNo); const contract = CONTRACTS[contractNo]; const same = lg.bestTariff().name === bestTariff(sg, TARIFFS, contract).name && lg.netFee() === netFee(sg, TARIFFS, contract); if (!same) diverged += 1; console.log(` ${no} ${String(weight).padStart(4)} kg ${zone} ${contractNo} -> ` + `${lg.bestTariff().name.padEnd(12)} ${String(lg.netFee()).padStart(6)}`); } console.log(`diverged results = ${diverged} / ${INPUTS.length}`);
node service-measure.mjs
modules imported by the shipment module: loaded 2, service 0 objects reachable from a shipment: loaded 3, service 1 serialization length: loaded 117, service 68 service/tariff-selection.mjs: lines carrying a persistence trace 0 service/price.mjs: lines carrying a persistence trace 6 G-3001 0.4 kg 34 S-77 -> regional 3600 G-3002 3 kg 06 S-77 -> high-volume 7965 G-3003 12 kg 65 S-77 -> high-volume 17640 G-3004 0.9 kg 34 S-90 -> regional 3600 G-3005 7 kg 06 S-90 -> standard 15930 G-3006 18 kg 65 S-90 -> standard 21240 diverged results = 0 / 6
The first line gives the direct cost of loading behavior onto the object: the shipment module has to import two external modules. This means every place that uses the shipment indirectly loads the tariff catalog and the contract rules as well.
The second and third lines are the object-level counterpart of the cost. In the loaded model, three objects are reachable from a shipment — the shipment itself, the contract, and the contract’s authorization list — in the service model, one. The serialization length drops from 117 to 68; when the shipment travels alone, the contract does not travel with it.
The fourth measurement gives the line between the domain service and the application service. The tariff selection file has no line carrying the words repository, save, or clock; the same words appear on six lines in the application service. The domain service makes the decision; it does not know where the decision is read from or written to.
The last line again says what the measurement is not: in all six shipments, the two models select the same tariff and give the same amount.
What a Domain Service Is Not
A domain service is a carrier of a rule, not an executor of a workflow. The measure of the distinction is the fourth line above: a domain service must have zero persistence trace. If a service reads from a repository, opens a transaction, checks the clock, or sends a notification, it belongs to the application layer.
The second distinction concerns state. A domain service does not hold its own state; its input arrives through parameters, its output is the return value. A “service” that holds state is, whatever it is named, an entity with an unclear boundary, and the measures from the previous two lessons apply to it.
When Not to Open a Domain Service
The cost of a service is one file, one exposed name, and the extra parameters passed on every call. For behaviors with a single collaborator, this cost has no return: if the decision of whether a shipment counts as bulky is moved to a service, the call site is forced to pass the shipment as a parameter, and the gain is zero.
The second counter-case is a service name that conceals a concept. If a decision that requires three objects turns out to actually be a fourth domain concept — the way tariff selection is a “pricing policy” — that concept takes its own type, and the service becomes a method again. Every module carrying a “service” suffix may be hiding a concept that has no counterpart in the domain language; the measure is whether that module’s name appears in the domain expert’s sentence.
Summary
- A domain service is the place where a behavior is put that requires several domain objects together and belongs to none of them; the measure is the number of collaborators and the ownership question.
- When tariff selection was loaded onto the shipment, the shipment module imported 2 external modules; when it was handed to the service, 0.
- Objects reachable from a shipment dropped from 3 to 1, and the serialization length from 117 to 68; the shipment now carries the contract by number, not by object.
- The measure that separates the domain service from the application service is the persistence trace: 0 lines in tariff selection, 6 lines in the application service.
- In all six shipments, the two models gave the same tariff and the same amount; the gain is not in correctness but in how dependencies are distributed.
- A behavior with a single collaborator is not moved to a service; if a service name conceals a domain concept, that concept takes its own type.
Next Step
The application service read the shipment from a repository and asked it for the tariffs. Which words are written on that repository’s interface was not asked in this lesson: findShipment, or a query object carrying column names and comparison operators? The same question holds for creation as well — is the shipment built directly through the constructor, or does it pass through a factory that secures its invariant at the moment of construction? The next lesson measures the number of non-domain names on the repository interface and the number of call sites touched when a column name changes, and counts how many ways an invalid shipment can be created.
To keep your progress and take notes, Log in
My notes
Log in to take notes.