Lesson 15 / 18
Microservice Architecture
What is gained and what is paid when the shared contract is removed: running two contexts as separate local processes, bringing the files republished per deployment unit down to 1, counting the boilerplate lines paid for independence, and measuring the number of endpoints that keep responding, and the partial response, when a process is stopped.
Contents
In the service-oriented arrangement, the source of the release coupling was the shared contract: the ten-field canonical record tied even a service that did not read those fields to a release. The next step is to remove the common format. Each context takes its own contract, its own data format, and its own release schedule; the call goes directly to the other service, not through a shared surface.
Microservice architecture is this arrangement: every service is a deployment unit that can be published on its own, with its own process, its own data, and its own contract. Where it departs from service-oriented architecture is not size but the absence of shared assets — there is no enterprise-wide canonical model, no common call surface. The measurement in this lesson runs the two contexts as genuinely separate local processes.
Three Processes
The pricing context is its own server. Its contract belongs to it alone: it reads only the fields it needs from the query string and returns its own response format.
// micro/fee.mjs — pricing context: its own process, its own contract, its own release schedule import { createServer } from "node:http"; const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 }; function price(s) { const weight = Number(s.get("weight")), rate = Number(s.get("rate") ?? 0); const tier = TARIFF.tier.find(([k]) => weight <= k) ?? [0, 9600]; const base = Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[s.get("zone")]) / 100)); return { id: s.get("id"), net: base - Math.round(base * Math.min(rate, 0.4)) }; } const port = Number(process.argv[2]); if (Number.isInteger(port) === false) console.log("usage: node micro/fee.mjs <port>"); else createServer((request, response) => { const body = JSON.stringify(price(new URL(request.url, "http://local").searchParams)); response.writeHead(200, { "content-type": "application/json" }); response.end(body); }).listen(port);
// micro/operation.mjs — delivery operation context: separate process, separate contract import { createServer } from "node:http"; const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] }; function plan(s) { const route = TREE[s.get("zone")] ?? ["34"]; return { id: s.get("id"), route, day: route.length, carrier: route.length > 2 ? "MT" : "AN" }; } const port = Number(process.argv[2]); if (Number.isInteger(port) === false) console.log("usage: node micro/operation.mjs <port>"); else createServer((request, response) => { const body = JSON.stringify(plan(new URL(request.url, "http://local").searchParams)); response.writeHead(200, { "content-type": "application/json" }); response.end(body); }).listen(port);
The offer request is no longer a function call; it is the aggregation of two network calls. Because calls can fail, the aggregator has to make a decision: reject the request outright, or return what it got and report what is missing. The implementation below chooses the second and writes the missing-part count into the response contract.
// micro/offer.mjs — aggregator: calls both services over the network, flags a partial response import { createServer } from "node:http"; async function get(address, query) { const y = await fetch(`${address}?${query}`); if (y.ok === false) throw new Error(`${address} did not return 200`); return y.json(); } async function offer(query, id) { const [f, p] = await Promise.allSettled([ get(process.env.FEE_ADDRESS, query), get(process.env.OPERATION_ADDRESS, query), ]); return { id, net: f.status === "fulfilled" ? f.value.net : null, day: p.status === "fulfilled" ? p.value.day : null, carrier: p.status === "fulfilled" ? p.value.carrier : null, missing: [f, p].filter((r) => r.status === "rejected").length, }; } const port = Number(process.argv[2]); if (Number.isInteger(port) === false) console.log("usage: node micro/offer.mjs <port>"); else createServer(async (request, response) => { const s = new URL(request.url, "http://local").searchParams; const body = JSON.stringify(await offer(s.toString(), s.get("id"))); response.writeHead(200, { "content-type": "application/json" }); response.end(body); }).listen(port);
Measuring the Two States
The driver script starts three processes, measures all three endpoints, then stops the pricing process and measures the same three endpoints again. The port numbers depend on the environment; change them if they are already in use on the machine.
# measure.sh — three separate processes are started; measured both while all are up and once fee is stopped node micro/fee.mjs 8791 & FEE=$! node micro/operation.mjs 8792 & OPERATION=$! FEE_ADDRESS=http://127.0.0.1:8791/price OPERATION_ADDRESS=http://127.0.0.1:8792/plan \ node micro/offer.mjs 8793 & OFFER=$! sleep 1 S="id=G1&weight=4&zone=35&rate=0.15" measure() { for u in 8791/price 8792/plan 8793/offer; do printf " %-12s %s\n" "$u" "$(curl -s -o /dev/null -w '%{http_code} code, %{size_download} bytes' "http://127.0.0.1:$u?$S")" done printf " offer body = %s\n" "$(curl -s "http://127.0.0.1:8793/offer?$S")" } echo "state 1: three processes up" measure kill $FEE sleep 0.5 echo "state 2: fee process stopped" measure kill $OPERATION $OFFER 2>/dev/null echo "deployment unit = 3" for d in micro/fee.mjs micro/operation.mjs micro/offer.mjs; do printf " %-20s imports = %s, server boilerplate = %s lines\n" "$d" \ "$(grep -c '^import' $d)" "$(grep -cE 'createServer|writeHead|response.end|process.argv|Number.isInteger' $d)" done
state 1: three processes up
8791/price 200 code, 22 bytes
8792/plan 200 code, 59 bytes
8793/offer 200 code, 57 bytes
offer body = {"id":"G1","net":5100,"day":3,"carrier":"MT","missing":0}
state 2: fee process stopped
8791/price 000 code, 0 bytes
8792/plan 200 code, 59 bytes
8793/offer 200 code, 57 bytes
offer body = {"id":"G1","net":null,"day":3,"carrier":"MT","missing":1}
deployment unit = 3
micro/fee.mjs imports = 1, server boilerplate = 6 lines
micro/operation.mjs imports = 1, server boilerplate = 6 lines
micro/offer.mjs imports = 1, server boilerplate = 6 lines
Reading the Numbers
Failure isolation came out at zero in the first lesson: a single defect in one process left three of six requests unanswered, and the units left standing were 0. Here, when the same context is stopped, 2 of the three endpoints keep returning 200. The operation endpoint produced its 59-byte response on its own; it never looked at the pricing side. This is the measured difference, and it is the style’s real justification.
The aggregator endpoint’s behavior produces a second number. The offer endpoint returned 200,
but its body changed: the net field is null, the missing field is 1. This partial
response is not an accident; it is a design decision the style forces. It was not needed in
the monolithic arrangement, because the call could not fail; here, a field was added to the
response contract, and the client has to know what that field means.
The gain on the release side was measured: every deployment unit’s closure is 1 file, so if one
line changes in the pricing rule, the files republished are 1. That number was 4 in the
monolithic arrangement and 3 for the zone field in the service-oriented arrangement. The cost
paid shows up in the same output: 6 lines of server boilerplate are repeated in each of the
three files, 18 lines in total. Moving this repetition into a shared file brings the release
coupling back — once that file changes, all three units publish together.
The Number Behind Distributed Complexity
The style’s bill collects in a single place: the outcome of an aggregated request is no longer one of two states but one of four, depending on the sub-service count. The number of up/down states grows with ; for two sub-services there are 4 states, and the measurement actually ran two of them. In the monolithic arrangement , so the state count was 2: working or not working.
The growth of the state count is a direct maintainability cost. Every additional state needs a decision — reject, return a partial response, or use a stale value — and that decision turns into a contract field, a test scenario, and a client behavior. The measurement tested 3 endpoints and 2 states; if the sub-service count rose to four, the same test suite would have to face 16 states.
The second cost is the multiplication of contracts. In the service-oriented arrangement, the number of formats to learn was 1; here, every service publishes its own format. In the measurement, all three endpoints answered with a different body format: three separate contracts of 22, 59, and 57 bytes. Removing the canonical model lowered the byte count and broke the release coupling, but it made the number of formats to learn and track for version compatibility equal to the service count.
Summary
- In microservice architecture, every service is a deployment unit that can be published on its own, with its own process, its own data, and its own contract; there is no shared canonical model and no common call surface.
- When the pricing process was stopped, 2 of the three endpoints kept returning 200; in the monolithic arrangement, the same defect had brought the units left standing down to 0.
- The aggregator endpoint gave a partial response: the
netfield isnull, themissingfield is 1 — the style loaded a new field onto the response contract and a new decision onto the client. - Because every deployment unit’s closure is 1 file, the files republished dropped to 1; in exchange, 6 lines of server boilerplate were repeated in each of three files, 18 lines in total.
- The outcome state of an aggregated request grows with : 4 states for two sub-services, 2 states in the monolithic arrangement.
- The number of body formats to learn was made equal to the service count; in the measurement, the three endpoints returned three separate contracts of 22, 59, and 57 bytes.
Next Step
Across the three styles so far, the direction of the call never changed: the aggregator knows pricing and operation by name, calls them, and waits for the response. This obligation to know and this waiting are the common source of every cost measured — the partial response decision and the contract count both came from here. The next style reverses the direction of the call: pricing publishes an event, does not know who is listening, and does not wait for a response. The next lesson measures what changes once the event crosses the system boundary: the number of consumers the publisher knows about, the number of files edited to add a new consumer, and the deviation between two consumer designs’ results when the same event arrives twice.
To keep your progress and take notes, Log in
My notes
Log in to take notes.