Lesson 12 / 16
Gateway Routing and Aggregation
The service map's two separate jobs: converting a path to a single service, and spreading one request across several services and merging the responses into one body; measuring three access patterns with real local processes and calculating how far aggregation cuts the request count while it grows the body at the edge.
Contents
The previous lesson said the gateway carries a service map but left the map as an assumption. The map can do two separate jobs. The first is gateway routing: an incoming path translates to a single service, and the client never sees the split behind it. The second is gateway aggregation: a single request spreads across several services, and the responses that come back merge into one body.
The concrete case of this distinction is the tracking page. The page takes a shipment’s state from the delivery operations context and its fee from the billing context. Two contexts are two separate services, so the page needs two responses. This lesson measures how many requests, how many bytes, and how many rounds those two responses take with real local processes; then it applies the measured ratio to K01’s Back-of-the-Envelope Estimation lesson’s tracking-response count.
Mechanism
Two domain services and one gateway run as separate processes. The field set the services return was determined by the resource design rules in the Web API Design course and is not revisited here; what gets measured is the decision made at the edge.
// gateway/service.mjs — two domain services: delivery-ops 8471, billing 8472 import { createServer } from "node:http"; const STATE = { "TR-4821": { state: "out-for-delivery", zone: "35", updatedAt: "2026-03-11T08:24:00Z", route: ["34", "41", "35"] }, "TR-9007": { state: "accepted", zone: "34", updatedAt: "2026-03-11T07:02:00Z", route: ["34"] }, }; const FEE = { "TR-4821": { tariff: 9600, discount: 1440, net: 8160, contractNo: "S7" }, }; const serve = (port, name, table) => createServer((request, response) => { const no = request.url.split("/").pop(); const record = table[no]; const body = JSON.stringify(record ? { trackingNo: no, ...record } : { error: "not_found" }); response.writeHead(record ? 200 : 404, { "Content-Type": "application/json" }); response.end(body); }).listen(port, "127.0.0.1", () => console.log(`${name} 127.0.0.1:${port}`)); serve(8471, "delivery-ops ", STATE); serve(8472, "billing ", FEE);
The gateway holds the map as an object and separates two paths: /tracking/<no>/<sub> converts
to a single service, while /tracking/<no> calls two services in one round. A third path,
/counter, reads and resets the counters between the gateway and the services; it is a
measurement tip, not part of the design.
// gateway/gateway.mjs — API gateway 8473: routing and single-request aggregation via the service map import { createServer } from "node:http"; const MAP = { state: 8471, fee: 8472 }; // path segment -> service let counter = { requests: 0, bytes: 0, round: 0 }; // counters between gateway and services async function callService(name, no) { counter.requests += 1; const y = await fetch(`http://127.0.0.1:${MAP[name]}/${name}/${no}`); const text = await y.text(); counter.bytes += Buffer.byteLength(text); return y.ok ? JSON.parse(text) : null; } createServer(async (request, response) => { const [root, no, sub] = request.url.split("/").filter(Boolean); let body; if (root === "counter") { // measurement tip: reads and resets counters body = counter; counter = { requests: 0, bytes: 0, round: 0 }; } else if (root !== "tracking") { body = null; } else if (sub) { // routing: path converts to a single service counter.round += 1; body = await callService(sub, no); } else { // aggregation: two services called in one round counter.round += 1; const [state, fee] = await Promise.all([callService("state", no), callService("fee", no)]); body = state && { ...state, fee: fee && { tariff: fee.tariff, discount: fee.discount, net: fee.net, contractNo: fee.contractNo } }; } const text = JSON.stringify(body ?? { error: "not_found" }); response.writeHead(body ? 200 : 404, { "Content-Type": "application/json" }); response.end(text); }).listen(8473, "127.0.0.1", () => console.log("gateway 127.0.0.1:8473"));
The client tries the three access patterns in turn and collects six numbers for each. Byte counts count only the body; header and protocol overhead were left out of K01’s calculation too.
// gateway/measure.mjs — measures the three access patterns and ties them to K01's tracking-response calculation const get = async (url) => { const y = await fetch(url); const text = await y.text(); return { bytes: Buffer.byteLength(text), data: y.ok ? JSON.parse(text) : null }; }; const readCounter = async () => (await get("http://127.0.0.1:8473/counter")).data; const leaf = (d) => (typeof d !== "object" || d === null ? 1 : Object.values(d).reduce((t, v) => t + leaf(v), 0)); async function measure(name, addresses, requests) { await readCounter(); // resets the previous scenario's counters let bytes = 0, fields = 0; for (const u of requests) { const r = await get(u); bytes += r.bytes; fields += r.data ? leaf(r.data) : 0; } const internal = await readCounter(); return { name, "client requests": requests.length, "addresses the client knows": addresses, "bytes reaching the client": bytes, "fields carried": fields, "gateway-service requests": internal.requests, "gateway-service bytes": internal.bytes, "gateway rounds": internal.round }; } const N = "TR-4821"; const S = [ await measure("separate calls", 2, [`http://127.0.0.1:8471/state/${N}`, `http://127.0.0.1:8472/fee/${N}`]), await measure("routing", 1, [`http://127.0.0.1:8473/tracking/${N}/state`, `http://127.0.0.1:8473/tracking/${N}/fee`]), await measure("aggregation", 1, [`http://127.0.0.1:8473/tracking/${N}`]), ]; const FIELD = ["client requests", "addresses the client knows", "bytes reaching the client", "fields carried", "gateway-service requests", "gateway-service bytes", "gateway rounds"]; console.log(`${"measure".padEnd(26)}${S.map((s) => s.name.padStart(15)).join("")}`); for (const f of FIELD) console.log(`${f.padEnd(26)}${S.map((s) => String(s[f]).padStart(15)).join("")}`); // Missing piece: billing does not know this tracking number const missing = await get(`http://127.0.0.1:8473/tracking/TR-9007`); console.log(`\naggregation with a missing piece = ${missing.bytes} bytes, fields carried = ${leaf(missing.data)}`); console.log(` body: ${JSON.stringify(missing.data)}`); // K01 Back-of-the-Envelope Estimation: V5 = 480 bytes (assumption), read peak 416.67 requests/s (computed value) const stateBytes = (await get(`http://127.0.0.1:8471/state/${N}`)).bytes; const feeBytes = (await get(`http://127.0.0.1:8472/fee/${N}`)).bytes; const combined = (await get(`http://127.0.0.1:8473/tracking/${N}`)).bytes; const V5 = 480, READ_PEAK = 416.67; const mbit = (requests, bytes) => (requests * bytes * 8) / 1e6; console.log(`\nstate ${stateBytes} B, fee ${feeBytes} B, combined ${combined} B`); console.log(`ratio — fee/state = ${(feeBytes / stateBytes).toFixed(3)}, ` + `combined/state = ${(combined / stateBytes).toFixed(3)}`); const SETUP = [ ["K01 (state only)", READ_PEAK, V5], ["separate calls", READ_PEAK * 2, (V5 * (stateBytes + feeBytes)) / stateBytes / 2], ["aggregation", READ_PEAK, (V5 * combined) / stateBytes], ]; console.log(`${"setup".padEnd(20)}${"requests/s".padStart(12)}${"bytes".padStart(9)}${"Mbit/s".padStart(9)}`); for (const [name, requests, bytes] of SETUP) { console.log(`${name.padEnd(20)}${requests.toFixed(2).padStart(12)}${bytes.toFixed(0).padStart(9)}` + `${mbit(requests, bytes).toFixed(2).padStart(9)}`); } const [, separate, agg] = SETUP; console.log(`aggregation / separate calls — requests x${(agg[1] / separate[1]).toFixed(2)}, ` + `egress x${(mbit(agg[1], agg[2]) / mbit(separate[1], separate[2])).toFixed(2)}`); // T1 sensitivity: if only a portion of views ask for the fee (T1 = 0.40) const T1 = 0.4; const separateRequests = READ_PEAK * (1 + T1); const separateBytes = (V5 * (stateBytes + T1 * feeBytes)) / stateBytes / (1 + T1); const aggBytes = (V5 * (stateBytes + T1 * (combined - stateBytes))) / stateBytes; console.log(`T1 = ${T1.toFixed(2)} -> separate calls ${separateRequests.toFixed(2)} requests/s ` + `${mbit(separateRequests, separateBytes).toFixed(2)} Mbit/s | aggregation ${READ_PEAK.toFixed(2)} requests/s ` + `${mbit(READ_PEAK, aggBytes).toFixed(2)} Mbit/s`);
node gateway/service.mjs & s=$! curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:8472/fee/TR-4821 node gateway/gateway.mjs & g=$! curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:8473/counter node gateway/measure.mjs kill $s $g
delivery-ops 127.0.0.1:8471
billing 127.0.0.1:8472
gateway 127.0.0.1:8473
measure separate calls routing aggregation
client requests 2 2 1
addresses the client knows 2 1 1
bytes reaching the client 206 206 190
fields carried 12 12 11
gateway-service requests 0 2 2
gateway-service bytes 0 206 206
gateway rounds 0 2 1
aggregation with a missing piece = 116 bytes, fields carried = 6
body: {"trackingNo":"TR-9007","state":"accepted","zone":"34","updatedAt":"2026-03-11T07:02:00Z","route":["34"],"fee":null}
state 123 B, fee 83 B, combined 190 B
ratio — fee/state = 0.675, combined/state = 1.545
setup requests/s bytes Mbit/s
K01 (state only) 416.67 480 1.60
separate calls 833.34 402 2.68
aggregation 416.67 741 2.47
aggregation / separate calls — requests x0.50, egress x0.92
T1 = 0.40 -> separate calls 583.34 requests/s 2.03 Mbit/s | aggregation 416.67 requests/s 1.95 Mbit/s
What Routing Alone Gains
The table’s first two columns answer this. Once routing is in place, client requests stay at 2, bytes reaching the client stay at 206, and fields carried stay at 12. The only number that changes is addresses the client knows: 2 instead of 1. Routing is not a performance decision, it is a binding decision. Because the service map sits at the gateway, when the billing service moves to a different address or splits into two, the client is not touched; the map is.
In exchange, gateway-service requests rise from 0 to 2. The same two calls still happen, only behind the gateway now. Routing does not eliminate a call; it changes who makes it.
What Aggregation Changes
The third column moves four numbers at once. Client requests drop from 2 to 1, fields carried
from 12 to 11, bytes reaching the client from 206 to 190, and gateway rounds from 2 to 1. The
field count drops because trackingNo moves from being carried in two bodies to one; that is
also where the byte reduction comes from. The gateway round dropping to 1 is because the two
calls happen in one round without waiting on each other; the side waiting for both services to
respond is the gateway, not the client.
The number that stays the same matters: gateway-service requests is still 2 under aggregation. Aggregation does not reduce how many calls the system makes — it reduces the call count visible outside the edge. This distinction decides where the decision gets written: the gain sits in the client’s round count and in the information the client carries, the cost sits in the gateway’s code waiting on two responses.
A Missing Piece Is a Contract Decision
Shipment TR-9007 has no billing record. Aggregation returns a 116-byte body and 6 fields in
this case; the fee field is null. This is a choice in the code, and its alternatives are
countable: the merged response can return with the missing piece, it can be treated as a full
failure, or the missing piece can be reported to the client in a separate field. A gateway that
aggregates has to make this choice, because a single response now holds the state of more than
one source. Left unwritten, the client can confuse a missing fee field with “the fee is zero.”
The decision itself does not belong to the gateway — it belongs to the response shape the two
sides have agreed on.
Back to the Calculation
K01’s assumption table counted V5, the tracking response, as 480 bytes, with the reasoning “state, zone, update time, and the last three route steps” — that is, the state piece alone. The tracking page also showing the fee was not in K01; it is this course’s assumption (T1 = 1.00: every tracking view also asks for fee information). The reasoning is that the page shows two pieces of information on one screen; its sensitivity is calculated below.
The ratios measured from the mechanism are deterministic: bodies are JSON text, and byte counts are independent of the machine. The fee body is 0.675 times the state body, the combined body 1.545 times. Applying these ratios to V5 produces three setups. K01’s own row is 416.67 requests/s and 480 bytes at 1.60 Mbit/s. With separate calls the request rate doubles (833.34) and the average body per request falls to 402 bytes; egress is 2.68 Mbit/s. With aggregation the request rate stays at 416.67 but the body rises to 741 bytes; egress is 2.47 Mbit/s.
Three numbers say three separate things. First, aggregation cuts the request rate in half (×0.50) but only cuts egress to ×0.92: what determines bandwidth is not packaging but the set of fields carried. Second, K01’s 1.60 Mbit/s is exceeded under both setups; once a requirement grows, the calculation is rerun, the old number is not kept. Third, T1’s sensitivity: if only forty percent of views ask for the fee, separate calls come out to 583.34 requests/s and 2.03 Mbit/s, aggregation to 416.67 requests/s and 1.95 Mbit/s. Aggregation’s gain in request rate shrinks as T1 shrinks; so does its cost in body growth.
Summary
- The service map does two jobs: routing converts a path to a single service, aggregation spreads one request across several services and merges the responses.
- Routing is only a binding decision: client requests stay at 2, bytes at 206, and fields at 12, while addresses the client knows drop from 2 to 1 and gateway-service requests rise from 0 to 2.
- Aggregation cuts client requests from 2 to 1, fields carried from 12 to 11, and gateway rounds from 2 to 1; the request count between the gateway and the services stays at 2.
- The body aggregation returns merges the state of more than one source; how a missing piece is
reported is a contract decision — in the example it came back as
null, with a 116-byte body and 6 fields. - Measured ratios: the fee body is 0.675 times the state body, the combined body 1.545 times; byte counts are independent of the machine.
- Back to K01: the 416.67 requests/s and 480-byte, 1.60 Mbit/s egress becomes 833.34 requests/s and 2.68 Mbit/s with separate calls, 416.67 requests/s and 2.47 Mbit/s with aggregation — requests ×0.50, egress ×0.92.
Next Step
Aggregation was the first job that touched the body, and it required a contract decision. Most of the work that can move to the edge never touches the body at all: validating a token, applying a rate limit, writing an access log entry. This work can either repeat at every service or gather in one place; the first lesson compared the two by line count but did not turn which work is movable into a rule. The next lesson builds that rule: once the inputs a check reads are written down, the work that can move to the edge separates from the work that must stay at the service by mechanical means — and it measures what having validation done in a single place costs the application.
To keep your progress and take notes, Log in
My notes
Log in to take notes.