Lesson 04 / 18
Microservices
Splitting the same loan workflow into genuinely separate processes: five processes, eight endpoints, nine network hops per request, chained latency; one deployment unit redeploying without stopping the others, and the unfinished workflow born when one process goes down.
Contents
The previous lesson split the units apart but kept them in the same process: send was a
function call, a message was an object. This lesson splits the same loan workflow into
genuinely separate processes. Each unit listens on its own port, holds its own data,
starts on its own, and stops on its own. This arrangement, where each unit has its own
deployment unit, is called a microservice; the definition of the style was covered in the
Architectural Styles course, what is measured here is the cost the split produces in the run
and in the code.
AO5. Five processes talk to each other on the same machine, over the loopback interface. The latency, packet loss, and fragmentation of a real network do not exist in this arrangement. The properties the model carries are: a call crosses a process boundary, it gets serialized, the target process can go down independently and can be restarted independently.
Five Processes
Each service takes its port from the command line and reads its neighbors’ addresses from an
environment variable. State-changing endpoints are called with POST.
// catalog-service.mjs — book unit; its own process, its own data import { createServer } from "node:http"; const port = Number(process.argv[2]); if (!port) { console.log("usage: node catalog-service.mjs <port>"); process.exit(0); } const book = { 1: { status: "on_shelf" }, 2: { status: "on_shelf" }, 3: { status: "on_shelf" } }; createServer((request, response) => { const u = new URL(request.url, "http://y"); const no = u.searchParams.get("bookNo"); if (u.pathname === "/status") return response.end(JSON.stringify({ status: book[no].status })); if (u.pathname === "/mark-on-loan") { book[no].status = "on_loan"; return response.end("{}"); } response.statusCode = 404; response.end("{}"); }).listen(port, () => console.log(`catalog ${port}`));
// membership-service.mjs — member unit; its own process, its own data import { createServer } from "node:http"; const port = Number(process.argv[2]); if (!port) { console.log("usage: node membership-service.mjs <port>"); process.exit(0); } const member = { 4: { name: "Derek", openLoans: 1, limit: 5, balance: 0 }, 5: { name: "Grace", openLoans: 3, limit: 3, balance: 0 } }; createServer((request, response) => { const u = new URL(request.url, "http://y"); const m = member[u.searchParams.get("memberNo")]; if (m && u.pathname === "/get") return response.end(JSON.stringify(m)); if (m && u.pathname === "/add-open-loan") { m.openLoans += 1; return response.end("{}"); } if (m && u.pathname === "/add-balance") { m.balance += Number(u.searchParams.get("amount")); return response.end("{}"); } response.statusCode = 404; response.end("{}"); }).listen(port, () => console.log(`membership ${port}`));
// fee-service.mjs — late fee unit; daily rate lives here import { createServer } from "node:http"; const port = Number(process.argv[2]); if (!port) { console.log("usage: node fee-service.mjs <port>"); process.exit(0); } const DAILY_RATE = 2; createServer((request, response) => { const u = new URL(request.url, "http://y"); if (u.pathname !== "/calculate") { response.statusCode = 404; return response.end("{}"); } const days = Number(u.searchParams.get("days")); response.end(JSON.stringify({ fee: days > 0 ? days * DAILY_RATE : 0 })); }).listen(port, () => console.log(`fee ${port}`));
// notification-service.mjs — notification unit; asks the membership process for the member's name import { createServer } from "node:http"; const port = Number(process.argv[2]); if (!port) { console.log("usage: node notification-service.mjs <port>"); process.exit(0); } createServer(async (request, response) => { const u = new URL(request.url, "http://y"); if (u.pathname !== "/send") { response.statusCode = 404; return response.end("{}"); } const y = await fetch(`http://127.0.0.1:${process.env.MEMBERSHIP}/get?memberNo=${u.searchParams.get("memberNo")}`); console.log(`notification -> ${(await y.json()).name}: ${u.searchParams.get("text")}`); response.end("{}"); }).listen(port, () => console.log(`notification ${port}`));
The loan service runs the same sequence as loan.mjs in the third lesson. The only
difference is that each step is now a network call. It counts the hops it makes and writes
the duration of the chain to its own log.
// loan-service.mjs — the process running the workflow; calls the others over the network import { createServer } from "node:http"; const port = Number(process.argv[2]); if (!port) { console.log("usage: node loan-service.mjs <port>"); process.exit(0); } const A = { catalog: process.env.CATALOG, membership: process.env.MEMBERSHIP, fee: process.env.FEE, notification: process.env.NOTIFICATION }; let hop = 0; const call = async (target, path, method = "GET") => { hop += 1; return (await fetch(`http://127.0.0.1:${A[target]}${path}`, { method })).json(); }; createServer(async (request, response) => { const u = new URL(request.url, "http://y"); if (u.pathname !== "/loan") { response.statusCode = 404; return response.end("{}"); } const memberNo = u.searchParams.get("memberNo"), bookNo = u.searchParams.get("bookNo"); const days = u.searchParams.get("days") ?? 0; hop = 0; const t0 = performance.now(); try { const member = await call("membership", `/get?memberNo=${memberNo}`); if (member.openLoans >= member.limit) throw new Error("loan limit exceeded"); if ((await call("catalog", `/status?bookNo=${bookNo}`)).status !== "on_shelf") throw new Error("book not on shelf"); await call("catalog", `/mark-on-loan?bookNo=${bookNo}`, "POST"); await call("membership", `/add-open-loan?memberNo=${memberNo}`, "POST"); const { fee } = await call("fee", `/calculate?days=${days}`); if (fee > 0) await call("membership", `/add-balance?memberNo=${memberNo}&amount=${fee}`, "POST"); await call("notification", `/send?memberNo=${memberNo}&text=book+${bookNo}`, "POST"); console.log(`loan ${bookNo}/${memberNo}: hop ${hop}, duration ${Math.round(performance.now() - t0)} ms`); response.end(JSON.stringify({ status: "issued", fee, hop })); } catch (h) { console.log(`loan ${bookNo}/${memberNo}: ERROR ${h.message}, hop ${hop}`); response.statusCode = 400; response.end(JSON.stringify({ status: "failed", error: h.message, hop })); } }).listen(port, () => console.log(`loan ${port}`));
Bringing It Up
# start.sh — five processes come up; each process's output is written to its own log rm -f pid.txt node catalog-service.mjs 8792 >catalog.log 2>&1 & echo "catalog $!" >>pid.txt node membership-service.mjs 8793 >membership.log 2>&1 & echo "membership $!" >>pid.txt node fee-service.mjs 8794 >fee.log 2>&1 & echo "fee $!" >>pid.txt MEMBERSHIP=8793 node notification-service.mjs 8795 >notification.log 2>&1 & echo "notification $!" >>pid.txt CATALOG=8792 MEMBERSHIP=8793 FEE=8794 NOTIFICATION=8795 \ node loan-service.mjs 8791 >loan.log 2>&1 & echo "loan $!" >>pid.txt for p in 8791 8792 8793 8794 8795; do for i in $(seq 40); do curl -s -o /dev/null "http://127.0.0.1:$p/" && break; sleep 0.2; done done echo "process brought up: $(wc -l <pid.txt | tr -d ' ') startup step: 5 background + 1 readiness wait" echo "endpoint: $(grep -oh '"/[a-z-]*"' ./*.mjs | sort -u | wc -l | tr -d ' ') deployment unit: 5" grep -oh '"/[a-z-]*"' ./*.mjs | sort -u | tr -d '"' | tr '\n' ' ' echo
process brought up: 5 startup step: 5 background + 1 readiness wait endpoint: 8 deployment unit: 5 /add-balance /add-open-loan /calculate /get /loan /mark-on-loan /send /status
In the first lesson this number was one: node app.mjs. Here there are five background
processes and one readiness wait; ports and neighbor addresses have to be supplied from
outside.
Hops Per Request
curl -s "http://127.0.0.1:8791/loan?bookNo=1&memberNo=4&days=3" echo curl -s "http://127.0.0.1:8791/loan?bookNo=2&memberNo=5" echo
{"status":"issued","fee":6,"hop":7}
{"status":"failed","error":"loan limit exceeded","hop":1}
The loan service made 7 calls. The notification service made one more call to get the member’s name; together with the request the client sent to the loan service, that comes to 9 network hops per request in total. In the third lesson the same workflow produced 8 messages — the message count did not change, every message just came to cross a process boundary.
The chain proceeds in sequence: each call waits for the previous one’s return, so the
latencies add up. The loan service writes one line to loan.log for every request; the first
request’s line came out as loan 1/4: hop 7, duration 21 ms in this run.
The duration field depends on the machine, the core, and the load at that moment — the value
above was read in this run and changes on every run. The quantity that does not depend on the
run is 7 sequential round trips, and this number only drops if the chain is shortened.
The second request stopped at the membership check, so it finished with a single hop: when an
error surfaces early, the rest of the chain never gets built.
Independent Deployment
The real payoff is measured here: let the fee rate change.
# restart.sh — only the fee process changes and restarts; the others never stop kill "$(awk '$1 == "fee" { print $2 }' pid.txt)" sed -i.bak 's/const DAILY_RATE = 2;/const DAILY_RATE = 5;/' fee-service.mjs rm -f fee-service.mjs.bak node fee-service.mjs 8794 >>fee.log 2>&1 & echo "fee $!" >>pid.txt for i in $(seq 40); do curl -s -o /dev/null "http://127.0.0.1:8794/" && break; sleep 0.2; done echo "process restarted: 1 / 5 deployment unit touched: 1" curl -s "http://127.0.0.1:8791/loan?bookNo=2&memberNo=4&days=3" echo
process restarted: 1 / 5 deployment unit touched: 1
{"status":"issued","fee":15,"hop":7}
The new rate is in effect. The catalog, membership, notification, and loan processes never stopped; they kept their in-memory state. In the first lesson the same one-line change redeployed seven modules together; here, one deployment unit restarted.
This payoff is not unlimited. The canonical openLoans field appears in two deployment
units — membership and loan. Renaming it was a change touching 3 files within a single unit
in the third lesson; here it requires two separate deployment units to release in a
coordinated order. Independent deployment is independent only for changes that do not touch
the contract.
Partial Failure
# fault.sh — the notification process stops; the same request breaks at the end of its path kill "$(awk '$1 == "notification" { print $2 }' pid.txt)" sleep 0.5 curl -s "http://127.0.0.1:8791/loan?bookNo=3&memberNo=4" echo curl -s "http://127.0.0.1:8792/status?bookNo=3" echo curl -s "http://127.0.0.1:8793/get?memberNo=4" echo
{"status":"failed","error":"fetch failed","hop":6}
{"status":"on_loan"}
{"name":"Derek","openLoans":4,"limit":5,"balance":21}
The request came back failed, but the book is on loan and the member’s open loan count went up. The chain that broke on the sixth hop did not undo the effect of the first five hops. This did not happen in the monolith: an exception left the whole body, and no module was left having finalized half a job.
The second line of the same output says something else: the catalog service is still responding. When the notification process went down, the system as a whole did not stop — paths that do not touch notification keep working. The failure is now partial; this is both a gain and a new problem, because the state of the system now sits somewhere between “up” and “down”.
# stop.sh — stops every process while read -r name p; do kill "$p" 2>/dev/null; done <pid.txt echo "stopped"
stopped
Three Columns
| Microservices | |
|---|---|
| Cheapens | A rate change restarted 1 of 5 deployment units; each unit owns its own data, the scaling unit is a single service |
| Makes expensive | 9 network hops and 7 sequential round trips per request; startup went from 1 step to 6 steps; addresses have to be supplied from outside |
| Failure mode created | A request that breaks mid-chain leaves an unfinished state behind; the failure is partial, the system is neither fully up nor fully down |
Summary
- The same loan workflow was split into 5 separate processes: 8 endpoints, 5 deployment units, 9 network hops per request, 7 sequential round trips.
- The message count did not change from the third lesson; what changed is that every message now crosses a process boundary, and sequential round trips add up their latencies.
- Bringing the system up went from 1 step to 6; every process’s port and neighbor addresses have to be supplied from outside.
- A fee rate change restarted only 1 deployment unit; the other four processes never stopped. A change that touches the contract forces two units to release in a coordinated order.
- When the notification process went down, the request came back failed, but the book was on loan and the member’s counter had already gone up: a workflow broken mid-chain leaves an unfinished state behind.
Next Step
These five processes are long-lived: they stay up whether a request arrives or not, keep their state in memory, and are started once and left running. The next lesson shrinks the deployment unit by one more step and removes long-livedness: the unit becomes a function that exists only when an event triggers it. What gets measured are the extra calls that come from being unable to hold anything in memory between two calls, a per-call time limit splitting the workflow, and the last local calls still left inside a process turning into network calls too.
To keep your progress and take notes, Log in
My notes
Log in to take notes.