---
title: 'Service-Oriented Architecture'
source: 'https://academia.sh/en/courses/service-architectures/service-oriented-architecture'
course: 'Service Architectures'
language: en
updated: '2026-08-23T07:00:30+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

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

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

```js
// pricing.mjs — late fee unit
import { register } from "./bus.mjs";
register("fee.calculate", ({ daysLate }) => (daysLate > 0 ? daysLate * 2 : 0));
```

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

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

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

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

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

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

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

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