---
title: 'Domain Services'
source: 'https://academia.sh/en/courses/domain-driven-design/domain-services'
course: 'Domain-Driven Design'
language: en
updated: '2026-08-23T07:01:19+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```sh
mkdir -p common loaded service
```

```js
// 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);
}
```

```js
// 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.

```js
// 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.

```js
// 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; }
}
```

```js
// 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.

```js
// 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.

```js
// 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}`);
```

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