Skip to content
academia.sh

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 sees from 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.

Start typing to search.

↑↓ Esc navigate · open · close