Lesson 15 / 16
Ambassador and Sidecar Patterns
Handing common work to a separate helper process: measuring how the ambassador separates the external contract from service code across two carrier versions, the sidecar's effect on runtime independence and the number of units republished, and the local hop the added process count costs.
Contents
The previous lesson raised the edge unit count from 1 to 3 and left a problem: the same common code repeats in every unit. The known way to share common code is to pull it into a library, but a library imposes two things — every unit must be on the same runtime, and every unit must be rebuilt and republished when the library’s version changes.
Another way is to hand the common work to a separate process. A sidecar is a helper process that runs in the same deployment unit as the main process and takes on its common work. An ambassador is the same layout applied to outgoing calls: the service calls a local address, the ambassador finds the external destination and translates it to the external contract. Neither is new as a pattern — the Ports and Adapters lesson in the Domain-Driven Design course built the same distinction at the code level. What gets treated here is not the pattern itself, but its deployment-unit-level counterpart and the numbers that counterpart costs.
Ambassador: Separating the External Contract from Service Code
The delivery operations context gets a shipment’s state from an external carrier. The carrier offers the same information under two separate contracts; the mechanism runs both as real processes.
// ambassador/carrier.mjs — external carrier service 8481: offers the same information under two separate contracts import { createServer } from "node:http"; const RECORD = { "TR-4821": { code: "OFD", zone: "35", time: "2026-03-11T08:24:00Z" } }; createServer((request, response) => { const [version, , tail] = request.url.split("/").filter(Boolean); const no = version === "v1" ? new URL(request.url, "http://y").searchParams.get("no") : tail; const record = RECORD[no]; const body = !record ? { error: "not_found" } : version === "v1" ? { code: record.code, zone: record.zone, ts: Date.parse(record.time) / 1000 } : { state: "out_for_delivery", region: { code: record.zone }, updatedAt: record.time }; response.writeHead(record ? 200 : 404, { "Content-Type": "application/json" }); response.end(JSON.stringify(body)); }).listen(8481, "127.0.0.1", () => console.log("carrier 127.0.0.1:8481"));
The ambassador is a separate process, and it takes which contract to speak from a startup argument. Its job has three parts: building the external path, translating the external body to the internal shape, and measuring the external call.
// ambassador/ambassador.mjs — ambassador process: translates a local address into the external contract. Usage: node ambassador/ambassador.mjs v1 8480 import { createServer } from "node:http"; const [version, port] = [process.argv[2], Number(process.argv[3])]; const STATE_NAME = { OFD: "out-for-delivery", out_for_delivery: "out-for-delivery" }; let last = { path: "-", bytes: 0 }; // the most recent external call's path and body bytes const externalPath = (no) => (version === "v1" ? `/v1/status?no=${no}` : `/v2/status/${no}`); const translate = (d) => (version === "v1" ? { state: STATE_NAME[d.code], zone: d.zone, updatedAt: new Date(d.ts * 1000).toISOString() } : { state: STATE_NAME[d.state], zone: d.region.code, updatedAt: new Date(d.updatedAt).toISOString() }); createServer(async (request, response) => { const [root, no] = request.url.split("/").filter(Boolean); if (root === "counter") { response.writeHead(200, { "Content-Type": "application/json" }); return response.end(JSON.stringify({ version, ...last })); } const path = externalPath(no); const y = await fetch(`http://127.0.0.1:8481${path}`); const text = await y.text(); last = { path, bytes: Buffer.byteLength(text) }; const body = y.ok ? translate(JSON.parse(text)) : { error: "not_found" }; response.writeHead(y.ok ? 200 : 404, { "Content-Type": "application/json" }); response.end(JSON.stringify({ trackingNo: no, ...body })); }).listen(port, "127.0.0.1", () => console.log(`ambassador-${version} 127.0.0.1:${port}`));
The measurement script stands in for the service code: it gives both ambassadors the same local address, the same path, and the same request. The entire service code is one line, and it contains no external address, external path, or external domain name.
// ambassador/measure.mjs — service code makes the same request to both ambassadors; what goes out differs by contract const get = async (url) => { const y = await fetch(url); const text = await y.text(); return { bytes: Buffer.byteLength(text), text }; }; // The entire service code: makes a request to a local address with a fixed path. const serviceRequest = (ambassadorPort) => `http://127.0.0.1:${ambassadorPort}/state/TR-4821`; const LAYOUT = [["v1", 8480], ["v2", 8489]]; const result = []; for (const [version, port] of LAYOUT) { const reply = await get(serviceRequest(port)); const counter = JSON.parse((await get(`http://127.0.0.1:${port}/counter`)).text); result.push({ version, request: "/state/TR-4821", replyBytes: reply.bytes, reply: reply.text, externalPath: counter.path, externalBytes: counter.bytes }); } console.log(`${"ambassador".padEnd(12)}${"service request".padEnd(19)}${"reply B".padStart(8)}` + `${"ext bytes".padStart(11)} external path`); for (const s of result) { console.log(`${s.version.padEnd(12)}${s.request.padEnd(19)}${String(s.replyBytes).padStart(8)}` + `${String(s.externalBytes).padStart(11)} ${s.externalPath}`); } const unique = new Set(result.map((s) => s.reply)); console.log(`\nbody returned to service (v1): ${result[0].reply}`); console.log(`body returned to service (v2): ${result[1].reply}`); console.log(`contracts the service sees = ${unique.size}, external contracts = ${result.length}`); console.log(`external addresses in service code = 0, service files edited when the external contract changes = 0`);
node ambassador/carrier.mjs & t=$! curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null "http://127.0.0.1:8481/v1/status?no=TR-4821" node ambassador/ambassador.mjs v1 8480 & a=$! curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:8480/counter node ambassador/ambassador.mjs v2 8489 & b=$! curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:8489/counter node ambassador/measure.mjs kill $t $a $b
carrier 127.0.0.1:8481
ambassador-v1 127.0.0.1:8480
ambassador-v2 127.0.0.1:8489
ambassador service request reply B ext bytes external path
v1 /state/TR-4821 102 42 /v1/status?no=TR-4821
v2 /state/TR-4821 102 86 /v2/status/TR-4821
body returned to service (v1): {"trackingNo":"TR-4821","state":"out-for-delivery","zone":"35","updatedAt":"2026-03-11T08:24:00.000Z"}
body returned to service (v2): {"trackingNo":"TR-4821","state":"out-for-delivery","zone":"35","updatedAt":"2026-03-11T08:24:00.000Z"}
contracts the service sees = 1, external contracts = 2
external addresses in service code = 0, service files edited when the external contract changes = 0
The number the measurement carries sits in the last lines: external contracts 2, contracts the
service sees 1. Both ambassadors received a request to the same path, and the body returned by
both ambassadors is identically 102 bytes. On the external side, both the paths and the bytes
differ: the v1 call carries 42 bytes, the v2 call 86 bytes — for the same three pieces of
information, the second contract uses 2.05 times the body.
The counter also works as a check tool. If the translation were left incomplete — for example, if
a timestamp were left in one contract’s original shape — it would turn contracts the service sees into 2 instead of 1. The ambassador’s job is not “forwarding the request,” it is keeping the
internal contract single, and this number is its measure.
Sidecar: Binding a Shared Capability to the Deployment Unit
The second measurement is an in-process model: there is no real deployment tool, container, or publishing step. The model counts the six deployment units that exist by this point in the course — three services (tracking, event, fee) and the three edge units the previous lesson added — and assumes two of them sit on two separate runtimes.
// ambassador/unit.mjs — two layouts of a shared capability; in-process model at the deployment-unit level const UNIT = { // the six deployment units that exist in this course tracking: "A", event: "A", fee: "B", // A and B: two separate runtimes "edge-mobile": "A", "edge-web": "A", "edge-panel": "B", }; const LAYOUT = { library: { separateProcess: false, runtime: ["A"] }, sidecar: { separateProcess: true, runtime: ["A", "B"] }, }; const measure = (y) => { const unit = Object.entries(UNIT); const canUse = unit.filter(([, z]) => y.runtime.includes(z)).length; return { "units that can use the shared capability": `${canUse}/${unit.length}`, "units rebuilt when the version changes": y.separateProcess ? 1 : canUse, "service code republished": y.separateProcess ? 0 : canUse, "processes per deployment unit": y.separateProcess ? 2 : 1, "total processes": unit.length * (y.separateProcess ? 2 : 1), "local hop inside the edge unit": y.separateProcess ? 2 : 1, }; }; const FIELD = Object.keys(measure(LAYOUT.sidecar)); const measurement = Object.entries(LAYOUT).map(([name, y]) => ({ name, ...measure(y) })); console.log(`deployment units = ${Object.keys(UNIT).length} ` + `(runtime A: ${Object.values(UNIT).filter((z) => z === "A").length}, ` + `B: ${Object.values(UNIT).filter((z) => z === "B").length})`); console.log(`${"measure".padEnd(41)}${measurement.map((o) => o.name.padStart(9)).join("")}`); for (const f of FIELD) console.log(`${f.padEnd(41)}${measurement.map((o) => String(o[f]).padStart(9)).join("")}`); // K01 Back-of-the-Envelope Estimation: peak edge 513.89 requests/s (computed-value class) const EDGE_PEAK = 513.89; console.log(""); for (const o of measurement) { const hop = 1 + o["local hop inside the edge unit"]; // gateway hop + edge hops console.log(`${o.name.padEnd(10)} hop ${hop} -> hop-requests/s = ${(EDGE_PEAK * hop).toFixed(2)}`); }
deployment units = 6 (runtime A: 4, B: 2) measure library sidecar units that can use the shared capability 4/6 6/6 units rebuilt when the version changes 4 1 service code republished 4 0 processes per deployment unit 1 2 total processes 6 12 local hop inside the edge unit 1 2 library hop 2 -> hop-requests/s = 1027.78 sidecar hop 3 -> hop-requests/s = 1541.67
Reading the Numbers
The first row shows the library layout’s silent constraint: only four of the six units can use the shared capability, because two of them sit on a different runtime. These units either rewrite the capability themselves or go without it; in both cases the label “shared capability” is wrong. Under the sidecar layout the number becomes 6/6, because the link between the two processes is a local call, not a function call.
The second and third rows separate out the publishing cost. When the shared capability’s version changes, the library layout rebuilds and republishes four units, and four service artifacts get republished. Under the sidecar layout, units rebuilt is 1, service code republished is 0: the services’ artifacts do not change. This is the deployment-level counterpart of the previous lessons’ “repeated lines” measure — there it counted how many files the same code sat in, here how many artifacts the same change forces to rebuild.
The last three rows give the cost, and the cost is not cheap. Processes per deployment unit rise
from 1 to 2, total processes from 6 to 12. Every edge unit gains one more local hop, and this grows
the first lesson’s hop-requests/s measure.
Back to the Calculation
The first lesson took K01’s peak edge rate (513.89 requests/s) and computed that with the gateway, every request touches two hops, raising the hop-request rate to 1027.78. The sidecar adds a third hop: 513.89 × 3 = 1541.67 hop-requests/s. The number changes none of K01’s assumptions — daily query count, body size, and peak multiplier all stay the same — but it triples how many places in the system process the same request rate.
This is not a duration measure; no latency has been measured on this machine, and none is written. What it says is this: a helper-process decision makes K01’s peak rate visible in three separate places in the system at once, without changing a single assumption. The decision’s defense therefore cannot be written as “it separates concerns”; the numbers 6/6, 1, 0, 12, and 1541.67 get written next to it.
Summary
- A sidecar is a helper process that runs in the same deployment unit as the main process; an ambassador is the same layout applied to outgoing calls, taking on the external destination and external contract.
- The ambassador measurement came out to external contracts 2, contracts the service sees 1: both ambassadors returned the identical 102-byte body, while 42 and 86 bytes moved externally.
- External addresses in the service code are 0, and service files edited when the external
contract changes are 0; an incomplete translation reveals itself by turning
contracts the service seesfrom 1 into 2. - Under the library layout, four of six units can use the shared capability, and four service codes get republished when the version changes; under the sidecar, 6/6 can use it, units rebuilt is 1, service code republished is 0.
- Numbers paid: processes per deployment unit rise from 1 to 2, total processes from 6 to 12.
- Back to K01: the 513.89 requests/s at the edge rises to 1541.67 hop-requests/s with the third hop; no assumption changes, the same rate is processed in three places.
Next Step
Across five lessons, every piece of work placed at the edge was written down with a gain and a cost, but all of them shared one assumption: a request reaching the edge is a request worth processing. Yet part of the peak edge rate is made of requests that will never be answered — bodies with missing fields, unrecognized paths, oversized bodies, malformed tokens. These requests spend the application’s resources if they reach it, and the edge’s resources if they are stopped before that. The last lesson takes up the gatekeeper pattern, which isolates validation into a separate layer, and measures the rate at which invalid requests reach the application, applying it to K01’s peak edge rate.
To keep your progress and take notes, Log in
My notes
Log in to take notes.