Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close