Lesson 03 / 18
Service-Oriented Architecture
Measuring enterprise service thinking: units talking through a shared contract and a canonical data model, counting how many units are coupled to the shared layer, how many units a canonical field rename touches, and the silent wrong behavior born when the owner is updated but the consumer is not.
Contents
The previous lesson built the boundary into the import graph, but store.mjs kept holding the
shared data structure for every domain. The boundary existed in the code, not in the data.
This lesson grows that shared ground: domains turn into separate units that carry their own
state, talk through a shared path, and let an enterprise shared model decide the shape of
that conversation.
This arrangement is called service-oriented architecture. The definition of the style was covered in the Architectural Styles course; what is measured here is not the definition, it is the coupling the shared layer brings into the code.
AO4. The units are not separate processes; they all talk within the same process, over a message bus. What is measured is contract coupling, not the network. Splitting into processes happens in the next lesson.
Shared Layer
The shared layer defines two things: the envelope every message carries, and the fields of the canonical member record.
// shared/contract.mjs — enterprise shared model: message envelope and canonical member fields export const ENVELOPE = ["source", "operation", "body"]; export const MEMBER_FIELDS = ["memberNo", "name", "openLoans", "limit", "balance"]; export function validate(message) { for (const a of ENVELOPE) if (a in message === false) throw new Error(`envelope field missing: ${a}`); return message; }
// bus.mjs — enterprise service bus; every message is validated against the shared contract import { validate } from "./shared/contract.mjs"; const units = new Map(); export const counter = { message: 0 }; export function register(operation, fn) { units.set(operation, fn); } export function send(message) { counter.message += 1; return units.get(validate(message).operation)(message.body); }
Each unit carries its own state and registers what it does with the bus. There is no direct import; units know each other not by name, but by operation name.
// catalog.mjs — book unit import { register } from "./bus.mjs"; const books = { 1: { bookNo: 1, title: "Blindness", status: "on_shelf" }, 2: { bookNo: 2, title: "The Book of Sand", status: "on_shelf" } }; register("book.status", ({ bookNo }) => books[bookNo].status); register("book.markOnLoan", ({ bookNo }) => { books[bookNo].status = "on_loan"; });
// membership.mjs — unit holding the canonical member record import { register } from "./bus.mjs"; import { MEMBER_FIELDS } from "./shared/contract.mjs"; const members = { 4: { memberNo: 4, name: "Derek", openLoans: 1, limit: 3, balance: 0 }, 5: { memberNo: 5, name: "Grace", openLoans: 3, limit: 3, balance: 0 }, }; export const missingFields = (m) => MEMBER_FIELDS.filter((a) => a in m === false); register("member.get", ({ memberNo }) => members[memberNo]); register("member.addOpenLoan", ({ memberNo }) => { members[memberNo].openLoans += 1; }); register("member.addToBalance", ({ memberNo, amount }) => { members[memberNo].balance += amount; });
// pricing.mjs — late fee unit import { register } from "./bus.mjs"; register("fee.calculate", ({ daysLate }) => (daysLate > 0 ? daysLate * 2 : 0));
// notification.mjs — notification unit; reads the name field from the canonical record import { register, send } from "./bus.mjs"; register("notification.send", ({ memberNo, text }) => { const member = send({ source: "notification", operation: "member.get", body: { memberNo } }); console.log(` notification -> ${member.name}: ${text}`); });
The loan unit runs the workflow. The place to notice is the limit check: it takes the canonical record and applies the rule itself.
// loan.mjs — workflow; reads the canonical member record and applies the rule itself import { send, counter } from "./bus.mjs"; export function issueLoan(bookNo, memberNo, daysLate = 0) { const call = (operation, body) => send({ source: "loan", operation, body }); const member = call("member.get", { memberNo }); if (member.openLoans >= member.limit) throw new Error("loan limit exceeded"); if (call("book.status", { bookNo }) !== "on_shelf") throw new Error("book not on shelf"); call("book.markOnLoan", { bookNo }); call("member.addOpenLoan", { memberNo }); const amount = call("fee.calculate", { daysLate }); if (amount > 0) call("member.addToBalance", { memberNo, amount }); call("notification.send", { memberNo, text: `book ${bookNo} loan issued` }); return { amount, message: counter.message }; }
// app.mjs — loads every unit and runs the workflow import "./catalog.mjs"; import "./membership.mjs"; import "./pricing.mjs"; import "./notification.mjs"; import { issueLoan } from "./loan.mjs"; for (const [bookNo, memberNo, days] of [[1, 4, 3], [2, 5, 0]]) { try { const s = issueLoan(bookNo, memberNo, days); console.log(`loan issued: book ${bookNo} -> member ${memberNo}, fee ${s.amount}, message ${s.message}`); } catch (h) { console.log(`failed (member ${memberNo}):`, h.message); } }
node app.mjs
notification -> Derek: book 1 loan issued loan issued: book 1 -> member 4, fee 6, message 8 failed (member 5): loan limit exceeded
The first measurement is the message count: one loan workflow produced 8 messages. In the
first lesson the same workflow ran with 6 local calls. The difference comes from two places.
The notification unit now sends a separate message to get the member’s name; the loan unit
also pulls the canonical record and applies the rule itself, instead of calling a behavior
like isEligible. The shared model shifted the conversation from behavior to data.
Unit Coupled to the Shared Model
The measure of coupling is how many units reference the canonical field names.
// coupling.mjs — which unit references which canonical field name import { readdirSync, readFileSync } from "node:fs"; import { MEMBER_FIELDS } from "./shared/contract.mjs"; const excluded = ["coupling.mjs", "app.mjs", "bus.mjs"]; const units = readdirSync(".").filter((a) => a.endsWith(".mjs") && excluded.includes(a) === false); let coupled = 0; for (const u of units.sort()) { const text = readFileSync(u, "utf8"); const found = MEMBER_FIELDS.filter((a) => new RegExp(`\\b${a}\\b`).test(text)); if (found.length > 0) coupled += 1; console.log(`${u.padEnd(20)} canonical field reference: ${found.join(", ") || "(none)"}`); } console.log(`unit: ${units.length} unit coupled to shared model: ${coupled}`);
node coupling.mjs
catalog.mjs canonical field reference: (none) loan.mjs canonical field reference: memberNo, openLoans, limit membership.mjs canonical field reference: memberNo, name, openLoans, limit, balance notification.mjs canonical field reference: memberNo, name pricing.mjs canonical field reference: (none) unit: 5 unit coupled to shared model: 3
Three of the five units know the field names of the canonical member record. In the previous
lesson this number was one: only the membership domain recognized the open field, and loan
asked it through isEligible. The shared model spread the record’s internal shape to three
units.
The distinction here is this: notification.mjs and loan.mjs do not own the member
record, but they are coupled to its shape. Data ownership sits with the membership unit; data
knowledge sits in three places.
The Shared Layer Changing
Say a field of the canonical model is renamed. Making the change in the shared layer and in the field’s owner looks like it should be enough.
# rename.sh — the canonical field name changes; only the shared layer and the field's owner are updated sed -i.bak 's/openLoans/openLoanCount/g' shared/contract.mjs membership.mjs rm -f shared/contract.mjs.bak membership.mjs.bak echo "file touched: 2" node app.mjs
file touched: 2 notification -> Derek: book 1 loan issued loan issued: book 1 -> member 4, fee 6, message 8 notification -> Grace: book 2 loan issued loan issued: book 2 -> member 5, fee 0, message 15
The second request went through. Member 5 was at the limit and had been rejected in the previous run; now they got a loan. No error was printed, no warning appeared anywhere.
The cause is a single line in the loan unit: member.openLoans now returns undefined, and
the comparison undefined >= 3 returns false, so the limit check always passes. A name
change in the shared model produced not an error but wrong behavior at the consumer.
The fix is carrying the same change to the consumer unit as well.
# fix.sh — the same change is also carried to the consumer unit sed -i.bak 's/openLoans/openLoanCount/g' loan.mjs rm -f loan.mjs.bak echo "file touched: 3 (shared/contract.mjs, membership.mjs, loan.mjs)" node app.mjs
file touched: 3 (shared/contract.mjs, membership.mjs, loan.mjs) notification -> Derek: book 1 loan issued loan issued: book 1 -> member 4, fee 6, message 8 failed (member 5): loan limit exceeded
The measure is this: a field name change in the shared layer touched 3 files, and these
three files have to ship together. Even though units were registered separately, the
canonical model binds them to a single release step. The -i option behaves differently
between the GNU and BSD versions when editing in place, so a backup extension is given and
then removed here.
Three Columns
| Service-oriented architecture | |
|---|---|
| Cheapens | Units do not import each other; adding a new unit only means registering an operation name, none of the existing units change |
| Makes expensive | Units coupled to the shared model went from 1 to 3; a canonical field name change forces 3 files to release together; the workflow produces 8 messages instead of 6 calls |
| Failure mode created | No error is raised when the shared model’s owner is updated but its consumer is not; the limit check silently stops working |
The third column is this lesson’s real payoff. The shared layer is a coupling point, and the defects it produces mostly show up not as a crash, but as silent wrong behavior. The loan unit ran, returned a response, wrote a record; it just failed to apply a rule it was supposed to.
Summary
- In the service-oriented arrangement units do not import each other; they talk by operation name over a shared path, and adding a new unit does not change the existing ones.
- The shared canonical model shifted the conversation from behavior to data: the workflow produced 8 messages instead of 6 local calls, because consumers pull the record and apply the rule themselves.
- The number of units that know the canonical field names went from 1 to 3; data ownership stayed with a single unit while data knowledge spread to three.
- A field name change in the shared layer touched 3 files and bound all three to a single release step.
- When the owner was updated but the consumer was not, the system raised no error; a member at the limit was given a loan. The shared model produces defects as silent wrong behavior.
Next Step
In this arrangement units were split apart, but the cost of splitting still has not been
paid: everything runs in the same process, send is a function call, a message is an object.
If one unit crashes, all of them crash; if one unit redeploys, all of them redeploy. The next
lesson splits the same loan workflow into genuinely separate processes: each unit listens
on its own port, messages travel over the network. What gets measured is process count,
endpoint count, network hops per request, chained latency, the deployment units a change
touches, and the steps needed to bring the system up.
To keep your progress and take notes, Log in
My notes
Log in to take notes.