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. And the two services are built differently, on purpose: one carries an application framework, one refuses it, and the line between them is a decision with its own record. 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).
One framework, one boundary
There is a second decision standing behind this chapter, and it shapes every file in it. The API service's surface is about to grow enormously: Phases 2–4 give it dozens of REST endpoints — tenancy, channels, messages, keys, moderation, emoji packs, dashboard reads. A surface that wide lives or dies on per-endpoint consistency: the same validation posture, the same error envelope, the same wiring conventions, endpoint after endpoint, year after year. ADR-15's answer is NestJS — modules, dependency injection, guards and pipes as framework primitives, generated OpenAPI when the docs chapters need it — so that consistency is something the framework supplies rather than something a solo builder re-disciplines forty times.
And the same record draws a hard line: the gateway gets no framework. Its
work is raw socket mechanics — resume buffering, backfill ordering, frames on
the wire. A framework between that code and the socket is surface without
benefit; when Part 2's hard chapters arrive, we want nothing standing between
the gateway and node:http. Workers, when they come, stay plain consumers
for the same reason.
flowchart LR
subgraph apiSide["services/api — NestJS (ADR-15)"]
mod["AppModule<br/>module graph · DI"]
ctl["HealthController"]
mw["request-id middleware"]
flt["protocol error filter"]
mod --> ctl
mod --> mw
mod --> flt
end
subgraph gwSide["services/gateway — no framework, by decision"]
serve["service-kit serve()<br/>raw node:http"]
end
kit["@relay/service-kit<br/>logger · request ids<br/>(one home, both sides)"]
kit --> apiSide
kit --> gwSide
note["The framework serves the wide CRUD surface<br/>and stops at the gateway's door:<br/>socket mechanics get no layers between<br/>the code and the wire (ADR-15)"]
gwSide ~~~ noteObservability 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 — a NestJS application (ADR-15)<br/>/healthz · X-Request-Id · JSON logs<br/>(owns REST; the only Postgres writer — ADR-04)"]
gw["Gateway service ✓ STANDING — frameworkless, by decision (ADR-15)<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 — and the framework boundary makes the sharing MORE important, not less: two services built differently must still log identically.
{
"name": "@relay/service-kit",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"test": "vitest run"
}
}Two things are new here compared to the packages you have built so far, and
both exist because of what is coming three sections down: the package has a
build script, and its exports point at dist/ — compiled JavaScript plus
type declarations — instead of at TypeScript source. The build config is four
lines on top of the base — emit on, declarations on, dist out, tests
excluded — and small enough to type in whole:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}It sits alongside this package's regular tsconfig:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"erasableSyntaxOnly": true
},
"include": ["src"]
}One 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, parameter properties). Hold that
thought — this chapter finishes that story, and the ending has a twist. The
kit's source is unchanged in spirit from what you'd write by hand:
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.
REVISED by chapter 3.14. The placeholder lasted until the error reference was published.
docs_urlnow resolves: one function beside the registry builds it, the fragment is the error code verbatim, and a check in the tutorial repository fails the build if a code has no entry or an entry names no code. The sentence above describes the state at this tag, which is what the chain replays; the promise it defers is kept ten chapters later.
The protocol package learns to build — and the series learns to amend
The framework's build step (coming next section) changes something for the
package 1.3 published: a compiled service cannot import raw TypeScript
source from a neighboring package. @relay/protocol needs a build of its own,
and dist-pointing exports. But its package.json is already published — it
appeared in 1.3, byte-for-byte, under this series' fence rule: what a chapter
prints IS what the repository holds.
So this is the moment the fence discipline grows its long-promised second
verb. Published code is edited only in daylight: a change to a file an
earlier chapter fenced appears as an explicit diff — the whole file, with
+ and - carrying the change — and the earlier chapter's own checks keep
verifying against its own tag, unchanged. Here is the first such amendment
the series has ever made:
{
"name": "@relay/protocol",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
- ".": "./src/index.ts"
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
},
"scripts": {
+ "build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"zod": "^4.4.3"
}
}Read it like a reviewer: the exports map now routes consumers to dist/
(with types beside it), and a build script appears. Nothing else moves.
The new build config it references is the service-kit's file, copied
verbatim — create it as packages/protocol/tsconfig.build.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}And now the dependsOn: ["^build"] line you declared in 1.1's turbo.json
stops being latent shape and starts doing work: run pnpm build and turbo
compiles the protocol package and the service-kit before anything that
imports them — the build order is declared once, in the graph, not encoded in
a README's "run these in this order" paragraph.
The API service — a NestJS application
With the kit and the builds in place, here is the API service ADR-15 describes. Its manifest first, because half the decisions live there:
{
"name": "@relay/api",
"private": true,
"version": "0.0.0",
"type": "commonjs",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@nestjs/common": "^11.1.28",
"@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28",
"@relay/protocol": "workspace:*",
"@relay/service-kit": "workspace:*",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.24",
"@nestjs/testing": "^11.1.28",
"@swc/core": "^1.15.47",
"unplugin-swc": "^1.5.9"
}
}Read the notable lines slowly. "type": "commonjs" — in a workspace that has
been ESM since 1.1. NestJS's ecosystem compiles to CommonJS, and fighting a
framework's native dialect is how you volunteer for other people's edge
cases; so this one package speaks CJS, and Node bridges the two worlds —
require() of an ES module has been stable since Node 22.12, which is
exactly the floor 1.1's engines line pinned. (That is the debt paid: 1.1
promised the reason would appear "when the need appears." This is the need.)
The dev runner is nest start --watch — the framework's own compiler
watch — and tsx is gone from this package: the api no longer runs
TypeScript source directly, it runs what nest build emits into dist/.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
// ADR-15's stated trade-off, spent here and only here: NestJS's DI reads
// constructor parameter types from decorator metadata, which is emitted
// code — so this service gives up erasableSyntaxOnly (still ON in the
// gateway and every package) and turns the two decorator flags on.
// verbatimModuleSyntax must also yield: this package compiles to
// CommonJS (the framework's native dialect), so `import` statements are
// rewritten to `require` calls at build time.
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"verbatimModuleSyntax": false
},
"include": ["src"]
}Here is the twist the kit's tsconfig told you to hold: this service gives
erasableSyntaxOnly up. Nest's dependency injection works by reading
constructor parameter types at runtime, and those types survive compilation
only if the compiler emits decorator metadata — which is generated code, the
exact thing erasable syntax forbids. This is ADR-15's stated trade-off, paid
where the record says to pay it and nowhere else: the gateway and every
package keep the flag; the api spends it, and buys DI. (verbatimModuleSyntax
yields for the same reason — a CJS-compiled file's imports are rewritten to
require calls, the opposite of verbatim.)
That tsconfig is the typecheck view. What nest build compiles is its
build twin — the same emit idea the packages use, but extending the file
above instead of the base, with declarations off (nothing imports the api)
and every test flavor excluded so the compiled output never carries test
copies. (The second exclude pattern waits for a kind of test that 2.1
introduces — declared ahead, like 1.1's compose input.)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": false,
"outDir": "dist",
"rootDir": "src"
},
"exclude": ["src/**/*.test.ts", "src/**/*.itest.ts"]
}And the file that points the Nest CLI at it:
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"tsConfigPath": "tsconfig.build.json",
"deleteOutDir": true
}
}Now the application itself. A NestJS app is a module graph: the module declares what exists, the injector wires it, and the pieces never construct each other by hand.
import {
Module,
type MiddlewareConsumer,
type NestModule,
} from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { HealthController } from "./health.controller";
import { LOGGER, apiLogger } from "./logger";
import { ProtocolErrorFilter } from "./protocol-error.filter";
import { RequestContextMiddleware } from "./request-context.middleware";
// The application described as a module graph — ADR-15's convention for the
// wide surface Phases 2-4 will grow. Registering the error filter as a
// provider (APP_FILTER) instead of wiring it in main.ts means every entry
// point — including tests — gets the same error envelope for free.
@Module({
controllers: [HealthController],
providers: [
{ provide: LOGGER, useFactory: apiLogger },
{ provide: APP_FILTER, useClass: ProtocolErrorFilter },
RequestContextMiddleware,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestContextMiddleware).forRoutes("{*path}");
}
}Four registrations, each earning its place. The controller:
import { Controller, Get } from "@nestjs/common";
// The same /healthz contract the frameworkless skeleton answered — the
// framework changes who routes the request, never what the body promises.
@Controller()
export class HealthController {
@Get("healthz")
healthz(): Record<string, unknown> {
return {
status: "ok",
service: "api",
uptime_s: Math.round(process.uptime()),
};
}
}The logger enters as a provider under an injection token — this is the DI bargain in one file:
import { createLogger, type Logger } from "@relay/service-kit";
// The logger enters the application as a PROVIDER under an injection token,
// not as a module-level import scattered through the code — that is the DI
// bargain ADR-15 buys: tests swap the sink by overriding one provider.
export const LOGGER = "LOGGER";
export function apiLogger(): Logger {
return createLogger("api");
}The request-id middleware asks for that logger in its constructor — by type,
which is exactly what the decorator metadata is for — and implements the same
promise serve() makes in the kit: one header out, one log line per request,
same UUID in both:
import type { IncomingMessage, ServerResponse } from "node:http";
import { Inject, Injectable, type NestMiddleware } from "@nestjs/common";
import { newRequestId, type Logger } from "@relay/service-kit";
import { LOGGER } from "./logger";
// EIR-API-05 + NFR-OBS-06, unchanged from the frameworkless skeleton: every
// response carries X-Request-Id, every request logs exactly one structured
// line carrying the same id. Logging on `finish` (not on entry) is what
// keeps it to one line per request no matter which controller or filter
// ends up answering.
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
constructor(@Inject(LOGGER) private readonly logger: Logger) {}
use(req: IncomingMessage, res: ServerResponse, next: () => void): void {
const requestId = newRequestId();
res.setHeader("X-Request-Id", requestId);
res.on("finish", () => {
this.logger.log("info", "request", {
request_id: requestId,
method: req.method,
path: req.url,
status: res.statusCode,
});
});
next();
}
}And the error filter is the one-error-shape policy as a framework primitive:
whatever throws, anywhere — the router's own 404 today, a guard or a bug in
Part 2 — the wire sees the envelope @relay/protocol defines:
import type { ServerResponse } from "node:http";
import {
Catch,
HttpException,
type ArgumentsHost,
type ExceptionFilter,
} from "@nestjs/common";
// EIR-API-04: one error shape, one home. Whatever throws — the router's own
// 404, a future guard, an unhandled bug — the wire sees the same envelope
// the @relay/protocol error payload defines, so the REST surface and the
// WebSocket surface cannot drift apart. The docs_url host is a placeholder
// until the docs site exists (constitution V's reachable-page promise).
@Catch()
export class ProtocolErrorFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<ServerResponse>();
const status =
exception instanceof HttpException ? exception.getStatus() : 500;
const code = status === 404 ? "not_found" : "internal_error";
const message =
exception instanceof HttpException
? exception.message
: "unexpected internal error";
res.statusCode = status;
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
code,
message,
docs_url: `https://relay.example/docs/errors/${code}`,
}),
);
}
}The entry point boots the graph and gets out of the way — note the framework's own logger is switched off; this workspace decided what a log line looks like in the kit, and the framework does not get a second opinion:
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { createLogger } from "@relay/service-kit";
import { AppModule } from "./app.module";
// Nest's own banner logger stays off: this workspace already decided what a
// log line looks like (one JSON object, NFR-OBS-01), and the framework does
// not get a second opinion.
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { logger: false });
const port = Number(process.env.PORT ?? 4000);
await app.listen(port);
createLogger("api").log("info", "listening", { port });
}
void bootstrap();The gateway — frameworkless, by decision
The gateway's manifest gains a test script for the task graph and otherwise
refuses everything the api just adopted — no framework dependency, ESM like
the rest of the workspace, and tsx as its runner:
{
"name": "@relay/gateway",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"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 });
});
}Its tsconfig you have effectively already read — it is the kit's file,
byte for byte, erasableSyntaxOnly still on, the flag living exactly where
the rescope said it would keep living. Copy it across, or type it once
more:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"erasableSyntaxOnly": true
},
"include": ["src"]
}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
One command now — 1.1's dev task was declared shape, and this chapter gives
it its two targets; turbo starts both services, building the packages first:
pnpm 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 the log output, one
JSON line per request whose request_id matches the header you just
received — from both services, in the same shape, because the kit is one
home. That pairing is NFR-OBS-06 in miniature: given an id from anywhere,
grep finds the request's whole story. You cannot tell from the outside which
service carries a framework — and that is the boundary working.
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 kit's suite is unchanged in spirit — parse log lines, never squint at stdout:
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);
});
});The api's suite is where the framework earns its keep in testing. There is one new file first — and it exists because of a trap worth naming.
import { defineConfig } from "vitest/config";
import swc from "unplugin-swc";
// Vitest's default transform (esbuild) strips decorators but never emits
// decorator METADATA — Nest's DI would silently resolve nothing. SWC does
// emit it; `module: { type: "es6" }` keeps test files ESM so vitest can load
// them (this package compiles to CJS, but tests run in vitest's world, not
// node's). The config is .mts for the same reason: inside a
// `"type": "commonjs"` package a .ts config would be loaded as CommonJS,
// which vitest refuses.
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
plugins: [
swc.vite({
module: { type: "es6" },
jsc: { transform: { legacyDecorator: true, decoratorMetadata: true } },
}),
],
});And the suite itself — same three promises the frameworkless skeleton made, now asserted through the framework's own testing harness. Watch the LOGGER override: the test swaps the log sink by overriding one provider, touching none of the application's code. That is the DI bargain paying out on day one:
import "reflect-metadata";
import { errorFrameSchema } from "@relay/protocol";
import { createLogger } from "@relay/service-kit";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { afterEach, describe, expect, it } from "vitest";
import { AppModule } from "./app.module";
import { LOGGER } from "./logger";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
// The same three promises the frameworkless skeleton made — the framework
// swap must be invisible from the wire. Overriding the LOGGER provider is
// the DI payoff: the test swaps the sink without touching the app's code.
async function boot(
lines?: string[],
): Promise<{ app: INestApplication; url: string }> {
const builder = Test.createTestingModule({ imports: [AppModule] });
if (lines) {
builder
.overrideProvider(LOGGER)
.useValue(createLogger("api", (l) => lines.push(l)));
} else {
builder.overrideProvider(LOGGER).useValue(createLogger("api", () => {}));
}
const app = (await builder.compile()).createNestApplication({
logger: false,
});
await app.listen(0);
return { app, url: await app.getUrl() };
}
describe("api skeleton", () => {
let app: INestApplication | undefined;
afterEach(async () => {
await app?.close();
app = undefined;
});
it("answers /healthz with its shape and a fresh request id per response", async () => {
const booted = await boot();
app = booted.app;
const res = await fetch(`${booted.url}/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(`${booted.url}/healthz`)).headers.get(
"x-request-id",
);
expect(id1).toMatch(UUID_RE);
expect(id2).toMatch(UUID_RE);
expect(id1).not.toBe(id2);
});
it("shapes its 404 exactly like the protocol's error payload (EIR-API-04)", async () => {
const booted = await boot();
app = booted.app;
const res = await fetch(`${booted.url}/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");
});
it("logs exactly one structured line per request, carrying the response's id", async () => {
const lines: string[] = [];
const booted = await boot(lines);
app = booted.app;
const res = await fetch(`${booted.url}/healthz`);
// The log line lands on the response's `finish` event — settle it.
await new Promise((r) => setTimeout(r, 20));
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"));
});
});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 — through its dist now, in build order the graph enforces.
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 — plus, from this chapter on, watch the graph build the packages before it tests the services that import them.
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 the output for it. One line, one service, the whole story — that is NFR-OBS-06 rehearsed on a system a few files big. - Comment out the
swc.vite(...)plugin in the api's vitest config and run its tests. Watch DI fail without metadata — then put it back and re-read the TRAP. Cheap now; bewildering at 2 a.m. later. - Add a temporary
/versionroute to the health controller 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.
- The framework serves the wide surface and stops at the gateway's door (ADR-15): consistency-at-scale is what a framework is for; socket mechanics get no layers between the code and the wire.
- Observability starts at line one: request IDs on every response (EIR-API-05), one structured JSON log line per request (NFR-OBS-01), honest deferrals for the rest — in the same shape from both services, framework or not, because the plumbing has one home.
- Trade-offs are paid in daylight: the api spends
erasableSyntaxOnlyon decorator metadata and speaks CJS across Node'srequire(esm)bridge — each cost named by ADR-15, each visible in a config you can point at. - Published code is edited only in daylight, too: the protocol package's build arrived as this series' first amendment diff — old lines checkable against 1.3, new lines checkable against this chapter's tag.