Lesson 14 / 18
Service-Oriented Architecture
Two contexts speaking through a single enterprise-wide record format and a single call path: measuring the canonical record's field count, the fields each service carries but does not read, the transformation points, and the bytes crossing the boundary; comparing these against a narrow contract; and counting the files edited when a field in the shared format changes.
Contents
In the monolithic arrangement, a one-file change republished four files because both contexts sat in the same deployment unit. The first attempt at a solution is not to pull the contexts apart but to place a shared integration surface between them. Each context ships to its own deployment unit, but both speak through a common record format and a common call path.
Service-oriented architecture sets up this arrangement for the entire enterprise: every capability is published as a coarse-grained service, services hide their own implementation, and the exchange between them runs through a single record format valid across the enterprise. The service here shares its name with the in-application layer measured in the Service Layer lesson of the Design Patterns course, but it is a different thing: there, a service was an application’s internal boundary; here, it is a deployment unit published on its own.
The Canonical Record and the Common Call Path
At the center of the style stands the canonical data model: a single shipment record that both contexts accept. Because the record must meet both contexts’ needs, its fields are the union of the two.
// shared/canonical.mjs — one enterprise-wide shipment record: every service speaks the same format export const CANONICAL_FIELD = [ "id", "weight", "volume", "zone", "value", "contractNo", "contractRate", "carrierCode", "deliveryState", "lastTimestamp", ]; export function toCanonical(s) { const c = {}; for (const a of CANONICAL_FIELD) c[a] = s[a] ?? null; return c; }
Calls do not go directly from service to service; they pass through a shared surface. This surface finds the service by name, carries the body in canonical form, and counts the crossings. Serialization is real here: the body is converted to text and read back on the other side.
// bus/bus.mjs — the shared integration surface: carries the call in canonical form and counts it import { toCanonical } from "../shared/canonical.mjs"; export function bus(log) { const services = new Map(); return { register(name, service) { services.set(name, service); }, call(name, operation, shipment) { const body = JSON.stringify(toCanonical(shipment)); log.crossing.push(`bus -> ${name}.${operation}`); log.transform += 1; log.bytes += body.length; const result = services.get(name)[operation](JSON.parse(body)); const response = JSON.stringify(result); log.crossing.push(`${name}.${operation} -> bus`); log.transform += 1; log.bytes += response.length; return JSON.parse(response); }, }; }
Each service takes the canonical record, converts it to its own internal format, does its work,
and returns the result. The READ_FIELD list states which fields the service actually uses
from the canonical record; the measurement uses this list.
// service/fee-service.mjs — coarse-grained service: takes the canonical record, converts to its own format const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 }; export const READ_FIELD = ["id", "weight", "zone", "contractRate"]; export const feeService = { price(canonical) { const s = { weight: canonical.weight, zone: canonical.zone, rate: canonical.contractRate ?? 0 }; const tier = TARIFF.tier.find(([k]) => s.weight <= k) ?? [0, 9600]; const base = Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[s.zone]) / 100)); return { id: canonical.id, net: base - Math.round(base * Math.min(s.rate, 0.4)) }; }, };
// service/operation-service.mjs — coarse-grained service: produces a route from the same canonical record const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] }; export const READ_FIELD = ["id", "zone", "deliveryState"]; export const operationService = { plan(canonical) { const route = TREE[canonical.zone] ?? ["34"]; return { id: canonical.id, route, day: route.length, carrier: route.length > 2 ? "MT" : "AN", state: canonical.deliveryState ?? "planned", }; }, };
Measuring the Shared Format
The measurement asks four questions: how many boundaries does a request cross and how many transformation points does it carry, how many fields of the canonical record does each service actually read, how many bytes does the same request shrink to with a service-specific narrow contract, and how many files are edited when a field in the shared format changes.
// soa/measure.mjs — the canonical format's field usage, transform points, bytes, and the effect of a shared-field change import { readFileSync } from "node:fs"; import { CANONICAL_FIELD, toCanonical } from "../shared/canonical.mjs"; import { bus } from "../bus/bus.mjs"; import { feeService, READ_FIELD as FEE_FIELD } from "../service/fee-service.mjs"; import { operationService, READ_FIELD as OPERATION_FIELD } from "../service/operation-service.mjs"; const SHIPMENT = { id: "G1", weight: 4, volume: 18, zone: "35", value: 12000, contractNo: "S7", contractRate: 0.15 }; const log = { crossing: [], transform: 0, bytes: 0 }; const surface = bus(log); surface.register("fee", feeService); surface.register("operation", operationService); const price = surface.call("fee", "price", SHIPMENT); const plan = surface.call("operation", "plan", SHIPMENT); console.log(`offer = ${JSON.stringify({ id: price.id, net: price.net, day: plan.day, carrier: plan.carrier })}`); console.log(`boundary crossing = ${log.crossing.length}, transform point = ${log.transform}, bytes crossing the boundary = ${log.bytes}`); console.log(`canonical field = ${CANONICAL_FIELD.length}`); for (const [name, read] of [["fee", FEE_FIELD], ["operation", OPERATION_FIELD]]) { console.log(` ${name.padEnd(9)} field read = ${read.length}, field carried but unread = ${CANONICAL_FIELD.length - read.length}`); } const narrow = (fields) => JSON.stringify(Object.fromEntries(fields.map((a) => [a, SHIPMENT[a] ?? null]))); const canonicalBytes = 2 * JSON.stringify(toCanonical(SHIPMENT)).length; const narrowBytes = narrow(FEE_FIELD).length + narrow(OPERATION_FIELD).length; console.log(`two request bodies: canonical = ${canonicalBytes} bytes, service-specific narrow contract = ${narrowBytes} bytes`); for (const n of [2, 4, 8]) { console.log(` services = ${n}: canonical converter = ${n}, point-to-point converter = ${n * (n - 1)}`); } const FILE = ["shared/canonical.mjs", "bus/bus.mjs", "service/fee-service.mjs", "service/operation-service.mjs"]; for (const field of ["zone", "contractRate"]) { const touching = FILE.filter((d) => readFileSync(d, "utf8").includes(field)); console.log(`if "${field}" changes: files edited = ${touching.length} (${touching.join(", ")})`); }
node soa/measure.mjs
offer = {"id":"G1","net":5100,"day":3,"carrier":"MT"}
boundary crossing = 4, transform point = 4, bytes crossing the boundary = 417
canonical field = 10
fee field read = 4, field carried but unread = 6
operation field read = 3, field carried but unread = 7
two request bodies: canonical = 318 bytes, service-specific narrow contract = 98 bytes
services = 2: canonical converter = 2, point-to-point converter = 2
services = 4: canonical converter = 4, point-to-point converter = 12
services = 8: canonical converter = 8, point-to-point converter = 56
if "zone" changes: files edited = 3 (shared/canonical.mjs, service/fee-service.mjs, service/operation-service.mjs)
if "contractRate" changes: files edited = 2 (shared/canonical.mjs, service/fee-service.mjs)
Reading the Numbers
In the monolithic lesson, the offer request crossed 4 boundaries, the transform point was 0, and the bytes crossing the boundary were 0. In the service-oriented arrangement producing the same output, the boundary crossing is still 4, but the transform points rose to 4 and the bytes crossing the boundary rose to 417. This is the style’s first bill: every boundary is now a serialization point, and the shape of the body is a contract.
The second set of numbers shows the canonical model’s characteristic cost. The record carries
10 fields; the fee service reads 4 of them and carries 6 without reading them. The operation
service reads 3 and carries 7 without reading them. These unread fields are not just bytes:
each one puts a concept the service does not need to know into its contract. If the meaning of
the value field changes, the operation service’s contract has changed even though it never
reads that field.
The narrow contract comparison turns the cost into a number: the same two requests take 98 bytes with service-specific formats, 318 bytes with the canonical format. Where the canonical model earns its keep is the converter count. With two services, the two arrangements are equal; at eight services, the canonical arrangement needs 8 converters, the point-to-point arrangement needs 56. This is the canonical model’s justification, and it strengthens as the service count grows.
The last two lines show the style’s real limit. If the zone field changes in the shared
format, 3 files are edited, and two of them are separate deployment units: two services are
forced to publish together. When contractRate changes, 2 files are edited and only one
service is affected. Release coupling is not 100% the way it was in the monolithic
arrangement, but it is not zero either: every field the shared contract covers is a coupling
point.
Where the Style Belongs
The numbers describe a quality attribute trade-off. The quality gained is compatibility and reuse: once the ten-field record format is learned, a service newly joining the enterprise writes 1 converter to talk to the existing two services, instead of having to learn two separate formats. The quality given up is independent changeability: every field of the shared format ties even services that do not read that field to a release.
This is why the style pays off at enterprise scale. For two services, the canonical model brings no gain — the converter count is 2 in both arrangements, while the request body triples in exchange. As the service count grows and the services spread across separate teams, the balance reverses: the number of formats to learn stays at 1, while the converter count grows linearly.
Summary
- Service-oriented architecture publishes every capability as a coarse-grained deployment unit and runs the exchange through a single enterprise-wide canonical record format.
- The same offer request carried 0 transform points and 0 bytes in the monolithic arrangement, and 4 transform points and 417 bytes in this arrangement.
- Of the ten-field canonical record, the fee service read 4 fields and the operation service read 3; the fields carried but unread were 6 and 7.
- Two requests took 98 bytes with narrow contracts and 318 bytes with the canonical format; the canonical model’s payoff is the converter count: 8 against 56 for eight services.
- When the
zonefield changed in the shared format, 3 files were edited and two services were forced to publish together; release coupling dropped from 100% but did not reach zero. - The style pays off at enterprise scale: the number of formats to learn stays at 1 while the converter count grows linearly with the service count.
Next Step
The shared contract turned out to be the source of the coupling: all ten fields tie even a service that does not read a field to a release. The next step is to remove the common format. Each service takes its own contract, its own data format, and its own release schedule; the call between them passes directly, not through a shared surface. This arrangement genuinely delivers independent release, but in exchange it produces a new number: how many requests keep being answered once a service stops. The next lesson runs two services as separate local processes, measures the boundary crossing and the body size, then stops one process and counts the endpoints left standing.
To keep your progress and take notes, Log in
My notes
Log in to take notes.