Lesson 29 / 30
Service Layer
Defining the application boundary in a single place: comparing two clients that run the scenario themselves against a service layer that writes the same scenario once; counting the infrastructure name and repeated scenario step counts in the client file, and measuring the thin service by the number of methods that only forward the call.
Contents
This lesson’s clients took the record the mapper produced ready-made; who produces the record, in which order the steps run, and where the unit of work opens and closes were not asked. These questions have to be answered somewhere. If the answer is repeated inside every client, the scenario gets written as many times as there are clients.
Service Layer puts this answer in a single place: it defines the set of operations the application exposes, runs each operation’s step order, sets the unit of work’s boundary, and returns a contract record to the outside. The layer’s responsibilities and dependency direction were measured in The Data Access Layer and Business Logic course; what is measured here is the boundary itself: the number of infrastructure names the client has to know, and the number of scenario steps repeated across more than one file.
For the measurement, the pieces of the previous lessons are gathered into a single, condensed file.
// infrastructure.mjs — the pieces of the previous lessons, condensed: repository, unit of work, domain rule, mapper const TABLE = new Map(); export const repository = { read: (id) => structuredClone(TABLE.get(id) ?? null), write: (row) => { TABLE.set(row.id, structuredClone(row)); }, }; export const shipmentRepository = { find: (id) => repository.read(id), add: (row) => repository.write(row) }; export function unitOfWork() { const dirty = new Map(); return { markDirty: (row) => dirty.set(row.id, row), commit() { for (const r of dirty.values()) repository.write(r); return dirty.size; }, }; } export const rule = { addDiscount: (t, discount) => ({ ...t, discounts: [...t.discounts, discount] }), addTransfer: (t, point) => ({ ...t, transfers: [...t.transfers, point] }), net: (t) => Math.round(t.base * (1 - Math.min(t.discounts.reduce((s, d) => s + d.rate, 0), 0.4))), }; export const mapper = { list: (t) => ({ identity: t.id, net: rule.net(t), transfers: t.transfers.length }) };
Problem: If the Scenario Gets Rewritten in Every Client
The library has two clients: a handler that answers an incoming request, and a batch script that runs overnight. Both want the same work — adding a discount and a transfer point to a shipment — and both run the steps themselves.
// direct/client-a.mjs — the request handler runs the scenario's steps itself import { shipmentRepository, unitOfWork, rule, mapper } from "../infrastructure.mjs"; export function requestDiscount(id, discount, point) { let t = shipmentRepository.find(id); if (t === null) throw new RangeError(`shipment not found: ${id}`); t = rule.addDiscount(t, discount); t = rule.addTransfer(t, point); const unit = unitOfWork(); unit.markDirty(t); unit.commit(); return mapper.list(t); }
// direct/client-b.mjs — the batch script rewrites the same steps import { shipmentRepository, unitOfWork, rule, mapper } from "../infrastructure.mjs"; export function batchDiscount(ids, discount, point) { return ids.map((id) => { let t = shipmentRepository.find(id); if (t === null) throw new RangeError(`shipment not found: ${id}`); t = rule.addDiscount(t, discount); t = rule.addTransfer(t, point); const unit = unitOfWork(); unit.markDirty(t); unit.commit(); return mapper.list(t); }); }
The second file is the first one’s body wrapped inside a loop. What repeats is not a line of code, it is a decision: the order of the steps, where validation happens, where the transaction boundary is drawn.
Solution: Writing the Boundary Once
// service/shipment-service.mjs — the scenario, once, here: order, transaction boundary, and returned contract import { shipmentRepository, unitOfWork, rule, mapper } from "../infrastructure.mjs"; export const shipmentService = { addDiscountAndTransfer(id, discount, point) { let t = shipmentRepository.find(id); if (t === null) throw new RangeError(`shipment not found: ${id}`); t = rule.addDiscount(t, discount); t = rule.addTransfer(t, point); const unit = unitOfWork(); unit.markDirty(t); unit.commit(); return mapper.list(t); }, };
// service/clients.mjs — two clients call the same application boundary import { shipmentService } from "./shipment-service.mjs"; export const requestDiscount = (id, discount, point) => shipmentService.addDiscountAndTransfer(id, discount, point); export const batchDiscount = (ids, discount, point) => ids.map((id) => shipmentService.addDiscountAndTransfer(id, discount, point));
The batch script has one job left: the loop. That was the real difference between the single and the batch case; everything else was shared.
Counting the Boundary
The measurement produces two kinds of numbers. The first is static: how many infrastructure names each file knows and how many scenario steps it carries. The second comes from the run: whether the two arrangements produce the same contract.
// count-boundary.mjs — infrastructure names imported per client, repeated scenario steps, and output equality import { readFileSync } from "node:fs"; import { shipmentRepository } from "./infrastructure.mjs"; import * as directA from "./direct/client-a.mjs"; import * as directB from "./direct/client-b.mjs"; import * as service from "./service/clients.mjs"; const STEP = { lookup: /shipmentRepository\.find\(/, validation: /throw new RangeError/, discount: /rule\.addDiscount\(/, transfer: /rule\.addTransfer\(/, unit: /unitOfWork\(\)/, commit: /\.commit\(\)/, contract: /mapper\.list\(/, }; const INFRASTRUCTURE = /\b(shipmentRepository|unitOfWork|rule|mapper)\b/g; function scan(files) { const steps = files.map((f) => { const m = readFileSync(f, "utf8"); const found = Object.keys(STEP).filter((k) => STEP[k].test(m)); const imported = new Set([...m.matchAll(INFRASTRUCTURE)].map(([a]) => a)); console.log(` ${f.padEnd(26)} infrastructure name = ${imported.size}, scenario step = ${found.length}`); return found; }); const all = steps.flat(); const repeated = new Set(all.filter((a, i) => all.indexOf(a) !== i)); console.log(` total step writes = ${all.length}, step repeated across files = ${repeated.size}`); } for (const id of ["G1", "G2"]) shipmentRepository.add({ id, base: 8640, discounts: [], transfers: ["34"] }); const DISCOUNT = { name: "contract", rate: 0.15 }; console.log("without service layer"); scan(["direct/client-a.mjs", "direct/client-b.mjs"]); console.log("with service layer"); scan(["service/clients.mjs", "service/shipment-service.mjs"]); const a = directA.requestDiscount("G1", DISCOUNT, "06"); const b = service.requestDiscount("G2", DISCOUNT, "06"); const c = directB.batchDiscount(["G1"], DISCOUNT, "35")[0]; const d = service.batchDiscount(["G2"], DISCOUNT, "35")[0]; const eq = (x, y) => x.net === y.net && x.transfers === y.transfers; console.log(`single-request output equal = ${eq(a, b)}, batch output equal = ${eq(c, d)}`); console.log(`sample contract = ${JSON.stringify(b)}`);
node count-boundary.mjs
without service layer
direct/client-a.mjs infrastructure name = 4, scenario step = 7
direct/client-b.mjs infrastructure name = 4, scenario step = 7
total step writes = 14, step repeated across files = 7
with service layer
service/clients.mjs infrastructure name = 0, scenario step = 0
service/shipment-service.mjs infrastructure name = 4, scenario step = 7
total step writes = 7, step repeated across files = 0
single-request output equal = true, batch output equal = true
sample contract = {"identity":"G2","net":7344,"transfers":2}
Three numbers changed. The infrastructure names known by the client file dropped from 4 to 0: the presentation side no longer carries the names of the repository, the unit of work, the domain rule, and the mapper — it carries a single service name. Total step writes dropped from 14 to 7. The number of steps repeated across more than one file dropped from 7 to 0; all seven decisions were gathered into a single file.
The last two lines show the behavior was preserved: the single request and the batch run
produced the same contract. How the numbers grow also matters. With n clients, step writes are
7n in the arrangement without a service layer, and a constant 7 with the service layer; the
gain grows linearly with the client count.
Cost: The Thin Service
The service layer adds 1 file and lengthens the path from the client to the domain rule by 1 step. This cost earns a payoff only when the service method carries a decision of its own. If there is no decision, the method does nothing but forward the call.
// service/thin-service.mjs — a service whose every method forwards a single call import { shipmentRepository, rule } from "../infrastructure.mjs"; export const thinService = { find: (id) => shipmentRepository.find(id), net: (t) => rule.net(t), addDiscount: (t, d) => rule.addDiscount(t, d), };
// count-forwarding.mjs — how many service methods only forward the call import { readFileSync } from "node:fs"; const METHOD = /^\s{2}(\w+)[:(]/; const FORWARDING = /^\s{2}\w+:\s*\([^)]*\)\s*=>\s*[\w.]+\([^;]*\),$/; for (const file of ["service/shipment-service.mjs", "service/thin-service.mjs"]) { const lines = readFileSync(file, "utf8").split("\n"); const methods = lines.filter((s) => METHOD.test(s)).length; const forwarding = lines.filter((s) => FORWARDING.test(s)).length; console.log(`${file.padEnd(26)} methods = ${methods}, forwarding only = ${forwarding}`); }
node count-forwarding.mjs
service/shipment-service.mjs methods = 1, forwarding only = 0 service/thin-service.mjs methods = 3, forwarding only = 3
In the thin service, all three methods do nothing but forward the call. This arrangement adds 1 file and 3 names, and gathers 0 decisions in exchange; it does not even lower the number of names the client has to know, it only changes the names.
When It Does Not Apply
The gain in the measure depends on two conditions: the client count being more than one, and the scenario containing more than one step.
In a single-client library, the repeated step count is already 0; the service layer leaves 7 at 7 and charges 1 file and 1 level of indirection. There is also no gain in single-step operations: for an operation that reads a shipment by identity and returns it, the service method lands in the forwarding-method count.
The third boundary is a decision about thickness. If the service method starts writing the business rule inside itself, the rule’s application point count multiplies again — that is what the Transaction Script and Domain Model lesson counted. The service layer’s job is order, boundary, and contract; the decision stays in the domain model.
Summary
- The service layer defines the set of operations the application exposes; it gathers the step order, the unit of work’s boundary, and the returned contract into a single file.
- In the two-client measurement, the infrastructure names known by the client file dropped from 4 to 0, total step writes from 14 to 7, and the repeated step count from 7 to 0.
- The single request and the batch run produced the same contract; the pattern did not change the behavior, it changed which file the decisions stand in.
- The gain grows linearly with the client count: step writes are
7nwithout the service, a constant 7 with it. - A service with no decision of its own stays thin; all three methods did nothing but forward the call, and 0 decisions were gathered in exchange for 1 file and 3 names.
Next Step
The six patterns so far assumed there was a single shipment type: every shipment has a weight, a base, discounts, and a route. Once a second service type enters the library — a cold chain shipment, a valuable-goods shipment, a document shipment — each type gets its own attributes and its own fee rule. Writing a new class for every type is a decision that increases the number of files edited as the type count grows; if the types come from a data source, writing a class is already impossible. The next lesson builds the type object, which models the type as an object at runtime, and attribute variants, which manage attributes that change by type, and counts the number of files edited and lines added when a new type is added.
To keep your progress and take notes, Log in
My notes
Log in to take notes.