Part 1 · Chapter 1.4
Walking skeleton
You will produce: Two running skeleton services — health-checked, request-ID'd, logging structured JSON · about 90 minutes including the exercise
Source: SRS — Software Requirements Specification · SAD — Software Architecture Document
Three chapters of Part 1 built ground no user will ever see: a workspace, four stores, a contract. Today something answers back. We stand up the first two of Relay's six services — the API service and the gateway — as a walking skeleton: no business logic, no store connections, nothing a product manager could demo. What they have instead is everything operations will need on the worst day: a health check, a request ID on every response, and structured logs that let one grep tell a request's whole story. Deploy the skeleton before the muscles — because retrofitting a nervous system into a grown body is surgery.
Why these two services first
The SAD's service view names six deployable services, and marks exactly two of them Phase 1. That pairing is not arbitrary — it is the architecture's central division of labor, decided back in the ADRs we read in 0.5 and quoted here from §4.1's own responsibility sheet. The API service "owns all REST semantics" and is "the only service that writes to PostgreSQL — a deliberate single-writer discipline" (ADR-04). The gateway "terminates WebSockets" and forwards writes over internal HTTP — "the gateway never writes to the database" (ADR-05).
Observability from line one
The row in the tutorial plan gives this chapter its second half: health checks, request IDs, structured logs. Each one is a requirement, not a habit.
Request IDs are EIR-API-05, quoted in full because every clause does work:
"Every response shall include a unique X-Request-Id header, referenced in
all error responses and in the request log." The documents fix uniqueness but
not format — we decide crypto.randomUUID() and record it.
Structured logs are NFR-OBS-01: "All services shall emit structured JSON
logs including request ID, tenant ID, and correlation ID." Read that honestly
and two of its three fields cannot exist yet — there are no tenants until
Part 2's data paths, and no correlation until there is more than one hop to
correlate (OpenTelemetry arrives with NFR-OBS-02, later). We log request_id
for real today and record the other two as deferrals with named arrival
points. Faking them would be worse than omitting them.
Why any of this before there is logic to observe? NFR-OBS-06: "Any customer-reported issue shall be traceable from a request ID to complete logs and traces within 5 minutes." That promise is impossibly expensive to retrofit and nearly free to start with — if it starts on day one, which is this day.
The health endpoint itself is our decision, recorded: GET /healthz, the
same spelling our compose healthchecks used in 1.2, returning
{ status: "ok", service, uptime_s }. Ports too: 4000 for the API service,
4001 for the gateway, each overridable via PORT (3000 belongs to nothing in
this repo — it is where the tutorial you are reading lives).
flowchart TB
api["API service ✓ STANDING<br/>/healthz · X-Request-Id · JSON logs<br/>(owns REST; the only Postgres writer — ADR-04)"]
gw["Gateway service ✓ STANDING<br/>/healthz + protocol advertisement<br/>(terminates WebSockets; never writes — ADR-05)"]
whk["Webhook dispatcher<br/>(Part 3 →)"]
ing["Analytics ingester<br/>(Part 5 →)"]
mws["Media worker<br/>(Part 4 →)"]
dash["Dashboard<br/>(Part 5 →)"]
api ~~~ gw
whk ~~~ ing
mws ~~~ dashOne home for the plumbing
Both services need the same three pieces: the logger, the request-id stamp, the health/404 wiring.
{
"name": "@relay/service-kit",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
}
}{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"erasableSyntaxOnly": true
},
"include": ["src"]
}One new line in an otherwise familiar tsconfig: erasableSyntaxOnly makes the
compiler reject any TypeScript syntax that can't simply be deleted to leave
valid JavaScript (enums, namespaces). Hold that thought — it belongs to a
story we'll finish below. The services' own tsconfigs are this identical file;
we won't print it twice more.
import { randomUUID } from "node:crypto";
import { createServer, type Server } from "node:http";
// The operational plumbing every Relay service shares — ONE home, because
// the second copy is where drift starts (chapter 1.4's TRAP; 1.1's lesson
// applied to behavior instead of configuration).
//
// Logs are structured JSON, one object per line (NFR-OBS-01): request_id is
// real from day one; tenant_id and trace/correlation ids are recorded
// deferrals — they join when Part 2's data paths and NFR-OBS-02's tracing
// make them mean something.
export type LogSink = (line: string) => void;
const stdoutSink: LogSink = (line) => {
process.stdout.write(line + "\n");
};
export interface Logger {
log(level: "info" | "error", msg: string, fields?: Record<string, unknown>): void;
}
/** One JSON object per line; the sink is injectable so tests can assert log
* structure instead of scraping stdout — observability you can't test rots. */
export function createLogger(service: string, sink: LogSink = stdoutSink): Logger {
return {
log(level, msg, fields = {}) {
sink(
JSON.stringify({
time: new Date().toISOString(),
level,
service,
msg,
...fields,
}),
);
},
};
}
/** EIR-API-05 fixes uniqueness; the UUID format is chapter 1.4's decision. */
export function newRequestId(): string {
return randomUUID();
}
export interface ServeOptions {
service: string;
/** Extra fields merged into the /healthz payload. */
health: () => Record<string, unknown>;
logger?: Logger;
}
/** Build (but do not start) a service's HTTP server: every response carries
* X-Request-Id (EIR-API-05), every request logs exactly one structured line
* carrying the same id (NFR-OBS-06's grep-ability starts here), GET /healthz
* answers with the service's health payload, and unknown routes get the
* EIR-API-04 error shape. The docs_url host is a placeholder until the docs
* site exists — constitution V's reachable-page promise lands with it. */
export function serve(options: ServeOptions): Server {
const { service, health } = options;
const logger = options.logger ?? createLogger(service);
return createServer((req, res) => {
const requestId = newRequestId();
const path = req.url ?? "/";
res.setHeader("X-Request-Id", requestId);
res.setHeader("content-type", "application/json");
let status: number;
let body: unknown;
if (req.method === "GET" && path === "/healthz") {
status = 200;
body = { status: "ok", service, ...health() };
} else {
status = 404;
body = {
code: "not_found",
message: `no route for ${req.method ?? "?"} ${path}`,
docs_url: "https://relay.example/docs/errors/not_found",
};
}
res.statusCode = status;
res.end(JSON.stringify(body));
logger.log("info", "request", {
request_id: requestId,
method: req.method,
path,
status,
});
});
}Three details earn their lines. The sink is injectable because logging you
can't test rots quietly — our tests will parse log lines, not squint at
stdout. The 404 body is EIR-API-04's error shape — code, message,
docs_url — with two recorded decisions attached: the not_found code lives
with the services until an API chapter owns a REST registry, and the
docs_url host is a placeholder until a docs site exists to make constitution
V's reachable-page promise true. And serve builds but does not start the
server — that separation is what lets tests boot it on an ephemeral port.
The two services
With the kit in place, a service is barely a page:
{
"name": "@relay/api",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@relay/protocol": "workspace:*",
"@relay/service-kit": "workspace:*"
},
"devDependencies": {
"tsx": "^4.23.1"
}
}import { createLogger, serve, type Logger } from "@relay/service-kit";
// The API service — SAD §4.1: owns all REST semantics and is the ONLY
// service that writes to PostgreSQL (ADR-04). At walking-skeleton stage it
// is deliberately empty: health, request ids, structured logs. The muscles —
// tenancy, channels, the message write path — grow onto this in Part 2, and
// its REST error envelope is already asserted (in the test beside this file)
// to match @relay/protocol's error payload shape: one error shape, one home.
export function createServer(logger?: Logger) {
return serve({
service: "api",
health: () => ({ uptime_s: Math.round(process.uptime()) }),
...(logger ? { logger } : {}),
});
}
if (import.meta.main) {
const port = Number(process.env.PORT ?? 4000);
const logger = createLogger("api");
createServer().listen(port, () => {
logger.log("info", "listening", { port });
});
}{
"name": "@relay/gateway",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@relay/protocol": "workspace:*",
"@relay/service-kit": "workspace:*"
},
"devDependencies": {
"tsx": "^4.23.1"
}
}import { CLOSE_CODES, frameSchema } from "@relay/protocol";
import { createLogger, serve, type Logger } from "@relay/service-kit";
// The gateway — SAD §4.1: terminates WebSockets and never writes to the
// database (ADR-05). At walking-skeleton stage no sockets exist yet; instead
// the gateway DECLARES the wire vocabulary it will speak, computed from
// @relay/protocol — never hardcoded, so the advertisement cannot drift from
// the contract. Sessions, JWT verification, and real frames arrive in Part 2.
const frames = frameSchema.options.map((option) => option.shape.type.value);
const closeCodes = Object.keys(CLOSE_CODES).map(Number);
export function createServer(logger?: Logger) {
return serve({
service: "gateway",
health: () => ({
uptime_s: Math.round(process.uptime()),
protocol: { frames, close_codes: closeCodes },
}),
...(logger ? { logger } : {}),
});
}
if (import.meta.main) {
const port = Number(process.env.PORT ?? 4001);
const logger = createLogger("gateway");
createServer().listen(port, () => {
logger.log("info", "listening", { port });
});
}The gateway does one thing beyond health: its health payload advertises the
protocol vocabulary it speaks — every frame name and close code, computed at
startup from @relay/protocol's actual exports. Not one string is typed by
hand, so the advertisement cannot drift from the contract. 1.3 promised the
skeleton would speak nothing but the protocol package; an empty skeleton
cannot hold conversations yet, but it can put its vocabulary in the window.
Stand it up
Two terminals — honest tooling at this scale; a process manager is machinery we don't need yet:
pnpm --filter @relay/api devpnpm --filter @relay/gateway devNow interrogate the skeleton:
curl -i localhost:4000/healthz
curl -s localhost:4001/healthz
curl -i localhost:4000/no-such-routeThree things to see with your own eyes. The X-Request-Id header on every
response — request it twice, get two different UUIDs. The gateway's protocol
block naming all ten frames and four close codes. And in each service's
terminal, one JSON log line per request whose request_id matches the header
you just received — that pairing is NFR-OBS-06 in miniature: given an id from
anywhere, grep finds the request's whole story.
flowchart LR
req["curl /healthz"]
svc["service<br/>stamps one fresh UUID"]
header["response header<br/>X-Request-Id: 639c…e9a"]
logline["log line (stdout, JSON)<br/>{ …, request_id: 639c…e9a, status: 200 }"]
grep["grep 639c…e9a *.log<br/>→ the whole story of one request<br/>(NFR-OBS-06: traceable in minutes)"]
req --> svc
svc --> header
svc --> logline
header --> grep
logline --> grepTests that watch the watchers
The suites boot each service on port 0 — an ephemeral port the OS assigns — so tests never collide with running services or each other:
import { describe, expect, it } from "vitest";
import { createLogger, newRequestId } from "./index.js";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
describe("structured logger (NFR-OBS-01)", () => {
it("emits one valid JSON object per line with the required fields", () => {
const lines: string[] = [];
const logger = createLogger("test-svc", (line) => lines.push(line));
logger.log("info", "hello", { request_id: "r-1" });
expect(lines).toHaveLength(1);
const parsed = JSON.parse(lines[0]!) as Record<string, unknown>;
expect(parsed).toMatchObject({
level: "info",
service: "test-svc",
msg: "hello",
request_id: "r-1",
});
expect(Number.isNaN(Date.parse(parsed.time as string))).toBe(false);
});
it("keeps levels and extra fields intact through the sink", () => {
const lines: string[] = [];
const logger = createLogger("test-svc", (line) => lines.push(line));
logger.log("error", "boom", { status: 500 });
const parsed = JSON.parse(lines[0]!) as Record<string, unknown>;
expect(parsed.level).toBe("error");
expect(parsed.status).toBe(500);
});
});
describe("request ids (EIR-API-05)", () => {
it("are UUID-shaped and unique", () => {
const a = newRequestId();
const b = newRequestId();
expect(a).toMatch(UUID_RE);
expect(b).toMatch(UUID_RE);
expect(a).not.toBe(b);
});
});import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import { errorFrameSchema } from "@relay/protocol";
import { describe, expect, it } from "vitest";
import { createLogger } from "@relay/service-kit";
import { createServer } from "./main.js";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const silent = createLogger("api", () => {});
function listen(server: Server): Promise<number> {
return new Promise((resolve) =>
server.listen(0, () => resolve((server.address() as AddressInfo).port)),
);
}
describe("api skeleton", () => {
it("answers /healthz with its shape and a fresh request id per response", async () => {
const server = createServer(silent);
const port = await listen(server);
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).toMatchObject({ status: "ok", service: "api" });
expect(typeof body.uptime_s).toBe("number");
const id1 = res.headers.get("x-request-id");
const id2 = (await fetch(`http://127.0.0.1:${port}/healthz`)).headers.get(
"x-request-id",
);
expect(id1).toMatch(UUID_RE);
expect(id2).toMatch(UUID_RE);
expect(id1).not.toBe(id2);
} finally {
server.close();
}
});
it("shapes its 404 exactly like the protocol's error payload (EIR-API-04)", async () => {
const server = createServer(silent);
const port = await listen(server);
try {
const res = await fetch(`http://127.0.0.1:${port}/no-such-route`);
expect(res.status).toBe(404);
const body: unknown = await res.json();
// One error shape, one home: the REST envelope must parse against the
// wire contract's error payload schema — alignment by construction.
const parsed = errorFrameSchema.shape.payload.safeParse(body);
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.code).toBe("not_found");
} finally {
server.close();
}
});
it("logs exactly one structured line per request, carrying the response's id", async () => {
const lines: string[] = [];
const server = createServer(createLogger("api", (line) => lines.push(line)));
const port = await listen(server);
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
expect(lines).toHaveLength(1);
const entry = JSON.parse(lines[0]!) as Record<string, unknown>;
expect(entry).toMatchObject({
service: "api",
msg: "request",
path: "/healthz",
status: 200,
});
expect(entry.request_id).toBe(res.headers.get("x-request-id"));
} finally {
server.close();
}
});
});import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import { CLOSE_CODES, frameSchema } from "@relay/protocol";
import { describe, expect, it } from "vitest";
import { createLogger } from "@relay/service-kit";
import { createServer } from "./main.js";
const silent = createLogger("gateway", () => {});
function listen(server: Server): Promise<number> {
return new Promise((resolve) =>
server.listen(0, () => resolve((server.address() as AddressInfo).port)),
);
}
describe("gateway skeleton", () => {
it("advertises exactly the vocabulary @relay/protocol exports", async () => {
const server = createServer(silent);
const port = await listen(server);
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
status: string;
service: string;
protocol: { frames: string[]; close_codes: number[] };
};
expect(body.status).toBe("ok");
expect(body.service).toBe("gateway");
// Computed from the package on both sides of this assertion — but one
// side travelled over HTTP: the advertisement matches the contract.
const expectedFrames = frameSchema.options.map((o) => o.shape.type.value);
expect(body.protocol.frames).toEqual(expectedFrames);
expect(body.protocol.frames).toContain("connection.ack");
expect(body.protocol.frames).toHaveLength(10);
expect(body.protocol.close_codes).toEqual(
Object.keys(CLOSE_CODES).map(Number),
);
} finally {
server.close();
}
});
it("carries a request id and answers unknown routes with the shared 404 shape", async () => {
const server = createServer(silent);
const port = await listen(server);
try {
const res = await fetch(`http://127.0.0.1:${port}/socket-someday`);
expect(res.status).toBe(404);
expect(res.headers.get("x-request-id")).toBeTruthy();
const body = (await res.json()) as Record<string, unknown>;
expect(body.code).toBe("not_found");
expect(typeof body.docs_url).toBe("string");
} finally {
server.close();
}
});
});Note the API service's second test: its REST 404 must parse against the
protocol package's error payload schema. That single assertion is the whole
one-error-shape policy, executable — the REST envelope and the WebSocket error
frame can never quietly diverge, because a test imports the one and feeds it
the other. Both services consume @relay/protocol today, exactly as 1.3 said
they would.
Walk the gate:
pnpm install
pnpm lint
pnpm typecheck
pnpm testForty tests. No Docker, no fixed ports, and a fourth consecutive chapter where the gate needs nothing but Node.
flowchart LR
ch1["1.1 workspace<br/>part1-ch1"]
ch2["1.2 infrastructure<br/>part1-ch2"]
ch3["1.3 protocol<br/>part1-ch3"]
ch4["1.4 skeleton<br/>part1-ch4"]
done["Part 1 ✓<br/>Part 2 grows the muscles:<br/>sessions, sends, ordering"]
ch1 --> ch2 --> ch3 --> ch4 --> doneYour turn
The exercise is the build: create all three members from this chapter, typing the kit yourself. Then poke the skeleton where it teaches:
- Start both services, kill the gateway, and curl the API service — it answers, untroubled. Two processes that fail independently is the entire reason "six services" is a plausible architecture; you just observed the smallest version of it.
- Make a dozen mixed requests to both services, pick one
X-Request-Idfrom a response, and grep both terminals for it. One line, one service, the whole story — that is NFR-OBS-06 rehearsed on a system four files big. - Add a temporary
/versionroute to the API service and watch the 404 test stay green but the route go untested. Feel the gap: every route you add from now on owes the suite a test. Delete it (or test it) before moving on.
If you are stuck, the tag holds the answer key: part1-ch4.
Takeaways
If you read nothing else in this chapter, keep these:
- The skeleton deploys before the muscles — the API/gateway seam is the architecture's most load-bearing line, and it exists (and is tested) before any logic can blur it.
- Observability starts at line one: request IDs on every response (EIR-API-05), one structured JSON log line per request (NFR-OBS-01), and honest deferrals for the fields that can't exist yet — because NFR-OBS-06's five-minute promise is cheap now and surgery later.
- Operational plumbing gets one home — the second copy is where drift starts, and plumbing drifts silently until the incident that needed it.
- Dependencies are decisions with stories: the platform's type stripping fell to a resolver wall at the first cross-package import; tsx is the smallest tool that fixes it, taken deliberately and pinned.
- One error shape, one home, machine-verified — the REST 404 parses against the protocol's error payload schema, so the two failure vocabularies cannot diverge.