Building Relay

Phần 1 · Chương 1.4

Bộ khung biết đi

Bạn sẽ tạo ra: Hai service bộ khung chạy được — có health check, request ID, log JSON có cấu trúc · khoảng 90 phút, bao gồm bài tập

Tài liệu gốc: SRS — Đặc tả yêu cầu phần mềm · SAD — Tài liệu kiến trúc phần mềm (tiếng Anh)

Ba chương đầu của Phần 1 xây toàn những thứ người dùng không bao giờ nhìn thấy: một workspace, bốn store, một bản giao kèo. Hôm nay có thứ biết trả lời. Chúng ta dựng dậy hai trong sáu service của Relay — API service và gateway — dưới dạng một bộ khung biết đi: không logic nghiệp vụ, không kết nối store, không có gì để đem đi demo. Thứ chúng có là tất cả những gì đội vận hành sẽ cần vào ngày tồi tệ nhất: một health check, một request ID trên mọi phản hồi, và những dòng log có cấu trúc để một lệnh grep kể trọn câu chuyện của một request. Và hai service này được xây khác nhau, một cách có chủ đích: một bên mang application framework, một bên khước từ nó, và ranh giới giữa hai bên là một quyết định có hồ sơ riêng. Dựng bộ khung trước, thêm chi tiết sau — vì cấy hệ thần kinh vào một cơ thể đã trưởng thành là một ca đại phẫu.

Vì sao là hai service này trước

Góc nhìn service của bản SAD gọi tên sáu service triển khai được, và đánh dấu đúng hai trong số đó là Phase 1. Cặp đôi ấy không hề ngẫu nhiên — nó là phép phân công lao động trung tâm của cả kiến trúc, được quyết từ những bản ADR ta đã đọc ở 0.5 và trích nguyên văn từ chính bảng trách nhiệm §4.1. API service "owns all REST semantics" — nắm trọn ngữ nghĩa REST — và là "the only service that writes to PostgreSQL — a deliberate single-writer discipline" (service duy nhất ghi vào PostgreSQL — một kỷ luật một-người-ghi có chủ đích, ADR-04). Gateway "terminates WebSockets" — tiếp nhận WebSocket — và chuyển các lệnh ghi qua HTTP nội bộ: "the gateway never writes to the database" (gateway không bao giờ ghi vào database, ADR-05).

Một framework, một đường ranh

Có một quyết định thứ hai đứng sau chương này, và nó nhào nặn từng file trong đây. Mặt tiền của API service sắp phình ra rất nhanh: Phase 2–4 trao cho nó hàng chục endpoint REST — tenancy, channel, tin nhắn, khóa API, kiểm duyệt, gói emoji, các phép đọc cho dashboard. Một mặt tiền rộng cỡ ấy sống hay chết ở sự nhất quán trên từng endpoint: cùng một tư thế validate, cùng một phong bì lỗi, cùng một nếp đấu nối, endpoint này qua endpoint khác, năm này qua năm khác. Câu trả lời của ADR-15 là NestJS — module, dependency injection, guard và pipe là những viên gạch có sẵn của framework, OpenAPI sinh tự động khi các chương tài liệu cần đến — để sự nhất quán ấy là thứ framework cung cấp, thay vì thứ một người xây đơn độc phải tự siết lại bốn mươi lần.

Và cũng chính hồ sơ ấy vạch một đường ranh cứng: gateway không nhận framework nào. Việc của nó là phần cơ khí socket trần trụi — buffer cho resume, thứ tự backfill, frame trên đường truyền. Một framework chen giữa đoạn code ấy và chiếc socket là thêm bề mặt mà chẳng thêm lợi ích; khi những chương gai góc của Phần 2 ập đến, ta muốn không gì đứng chắn giữa gateway và node:http. Các worker, khi ra đời, cũng ở dạng consumer trần — cùng một lý do.

flowchart LR
    subgraph apiSide["services/api — NestJS (ADR-15)"]
      mod["AppModule<br/>đồ thị module · DI"]
      ctl["HealthController"]
      mw["middleware request-id"]
      flt["filter lỗi theo protocol"]
      mod --> ctl
      mod --> mw
      mod --> flt
    end
    subgraph gwSide["services/gateway — không framework, do quyết định"]
      serve["serve() của service-kit<br/>node:http trần"]
    end
    kit["@relay/service-kit<br/>logger · request id<br/>(một mái nhà, cho cả hai bên)"]
    kit --> apiSide
    kit --> gwSide
    note["Framework phục vụ mặt tiền CRUD rộng<br/>và dừng bước trước cửa gateway:<br/>phần cơ khí socket không nhận thêm lớp nào<br/>chen giữa code và đường truyền (ADR-15)"]
    gwSide ~~~ note
Đường ranh chương này dựng: framework phục vụ mặt tiền rộng của API service và dừng bước trước cửa gateway — với service-kit nuôi cả hai bên, một mái nhà cho phần ống nước dù đi lối nào.

Observability từ dòng đầu tiên

Dòng kế hoạch của loạt bài trao cho chương này nửa còn lại: health check, request ID, log có cấu trúc. Mỗi thứ là một yêu cầu, không phải một thói quen.

Request ID là EIR-API-05, trích đủ vì mệnh đề nào cũng có việc của nó: "Every response shall include a unique X-Request-Id header, referenced in all error responses and in the request log" — mọi phản hồi phải mang một header X-Request-Id duy nhất, được nhắc lại trong mọi phản hồi lỗi và trong log của request. Tài liệu ấn định tính duy nhất nhưng không ấn định định dạng — chúng ta quyết crypto.randomUUID() và ghi lại.

Log có cấu trúc là NFR-OBS-01: "All services shall emit structured JSON logs including request ID, tenant ID, and correlation ID." Đọc câu ấy một cách trung thực thì hai trong ba trường chưa thể tồn tại — chưa có tenant nào cho đến các đường dữ liệu của Phần 2, và chưa có gì để correlate khi chưa có hơn một chặng nhảy (OpenTelemetry đến cùng NFR-OBS-02, về sau). Hôm nay chúng ta log request_id thật, và ghi nhận hai trường kia là những khoản hoãn có địa chỉ đến rõ ràng. Làm giả chúng còn tệ hơn là thiếu chúng.

Vì sao cần tất cả những thứ này trước cả khi có logic để quan sát? NFR-OBS-06: "Any customer-reported issue shall be traceable from a request ID to complete logs and traces within 5 minutes" — mọi sự cố khách hàng báo phải truy vết được từ một request ID đến trọn bộ log và trace trong vòng 5 phút. Lời hứa ấy đắt khủng khiếp nếu trang bị lại về sau và gần như miễn phí nếu bắt đầu từ ngày đầu — miễn là nó bắt đầu từ ngày đầu, tức là hôm nay.

Bản thân endpoint health là quyết định của chúng ta, có ghi lại: GET /healthz, đúng cách viết mà các healthcheck compose của 1.2 đã dùng, trả về { status: "ok", service, uptime_s }. Cổng cũng vậy: 4000 cho API service, 4001 cho gateway, mỗi bên ghi đè được bằng PORT (3000 không thuộc về thứ gì trong repo này — đó là nơi trang hướng dẫn bạn đang đọc đang sống).

flowchart TB
    api["API service ✓ ĐANG ĐỨNG — một ứng dụng NestJS (ADR-15)<br/>/healthz · X-Request-Id · log JSON<br/>(nắm REST; writer duy nhất của Postgres — ADR-04)"]
    gw["Gateway service ✓ ĐANG ĐỨNG — không framework, do quyết định (ADR-15)<br/>/healthz + bảng công bố protocol<br/>(tiếp nhận WebSocket; không bao giờ ghi DB — ADR-05)"]
    whk["Webhook dispatcher<br/>(Phần 3 →)"]
    ing["Analytics ingester<br/>(Phần 5 →)"]
    mws["Media worker<br/>(Phần 4 →)"]
    dash["Dashboard<br/>(Phần 5 →)"]
    api ~~~ gw
    whk ~~~ ing
    mws ~~~ dash
Sáu service của bản SAD, hai chiếc giờ đã đứng — rỗng phần sản phẩm, đầy đủ những thuộc tính mà đội vận hành cần vào ngày tồi tệ nhất, và mỗi chiếc được xây đúng như bản ADR của nó dặn.

Một mái nhà cho đồ nghề chung

Cả hai service cần đúng ba món: logger, con dấu request ID, và phần dây điện health/404 — và ranh giới framework khiến việc dùng chung QUAN TRỌNG hơn, chứ không kém đi: hai service xây khác nhau vẫn phải log giống hệt nhau.

packages/service-kit/package.json
{
  "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"
  }
}

Hai điều mới ở đây so với những package bạn đã dựng, và cả hai tồn tại vì thứ đang chờ ba mục phía dưới: package có script build, và exports của nó trỏ vào dist/ — JavaScript đã biên dịch kèm khai báo kiểu — thay vì vào mã nguồn TypeScript. Bản cấu hình build chỉ là bốn dòng đặt chồng lên nền — bật emit, bật declaration, xuất ra dist, loại test — và đủ nhỏ để gõ trọn:

packages/service-kit/tsconfig.build.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "noEmit": false,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts"]
}

Nó đứng cạnh tsconfig thường của package:

packages/service-kit/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "erasableSyntaxOnly": true
  },
  "include": ["src"]
}

Một dòng trong một tsconfig quen thuộc: erasableSyntaxOnly bắt compiler từ chối mọi cú pháp TypeScript không thể đơn thuần xóa đi để còn lại JavaScript hợp lệ (enum, namespace, parameter property). Giữ ý nghĩ ấy lại — chương này sẽ kết thúc câu chuyện đó, và đoạn kết có một cú ngoặt. Mã nguồn của phần đồ nghề không đổi về tinh thần so với thứ bạn sẽ tự tay viết:

packages/service-kit/src/index.ts
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,
    });
  });
}

Ba chi tiết xứng đáng với số dòng của chúng. Cái sink tiêm được từ ngoài vào, vì phần log không test được sẽ mục ruỗng trong im lặng — các bài test của chúng ta sẽ parse từng dòng log, không nheo mắt nhìn stdout. Thân của 404 là khuôn lỗi EIR-API-04 — code, message, docs_url — kèm hai quyết định có ghi lại: mã not_found sống cùng các service cho đến khi một chương API sở hữu sổ mã REST riêng, và host của docs_url là chỗ giữ tạm cho đến khi trang tài liệu ra đời để biến lời hứa trang-tra-cứu-được của constitution V thành thật. Và serve dựng server nhưng không khởi động nó — chính khoảng tách ấy cho phép các bài test khởi động trên một cổng vu vơ.

ĐÃ SỬA LẠI bởi chương 3.14. Chỗ giữ tạm ấy tồn tại cho tới khi tài liệu tham chiếu lỗi được xuất bản. docs_url giờ resolve được: một hàm duy nhất bên cạnh registry dựng ra nó, fragment chính là error code viết nguyên văn, và một phép kiểm trong repository tutorial sẽ làm build fail nếu một code không có mục hoặc một mục không ứng với code nào. Câu ở trên mô tả trạng thái tại tag này, đó là thứ mà fence chain replay lại; lời hứa nó gác lại thì được giữ mười chương sau đó.

Package protocol học cách build — và loạt bài học cách tu chính

Bước build của framework (mục kế tiếp) thay đổi một điều đối với package mà 1.3 đã công bố: một service đã biên dịch không thể import thẳng mã nguồn TypeScript thô từ package hàng xóm. @relay/protocol cần một bước build của riêng nó, và exports trỏ vào dist. Nhưng package.json của nó đã được công bố — nó xuất hiện ở 1.3, đúng từng byte, dưới luật fence của loạt bài: thứ một chương in ra CHÍNH LÀ thứ repository đang giữ.

Vậy nên đây là khoảnh khắc kỷ luật fence mọc thêm động từ thứ hai đã hứa từ lâu. Code đã công bố chỉ được sửa giữa ban ngày: một thay đổi lên file mà chương trước đã trưng sẽ hiện ra dưới dạng một bản diff tường minh — nguyên cả file, với +- cõng phần thay đổi — còn các phép kiểm của chương cũ vẫn đối chiếu với tag của chính nó, nguyên vẹn. Đây là bản tu chính đầu tiên loạt bài từng thực hiện:

packages/protocol/package.json
 {
   "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"
   }
 }

Đọc nó bằng con mắt của reviewer: bảng exports giờ dẫn người dùng package đến dist/ (kèm kiểu ngay bên cạnh), và một script build xuất hiện. Không gì khác xê dịch. Bản cấu hình build mới mà nó nhắc đến chính là file của service-kit, chép nguyên xi — tạo nó thành packages/protocol/tsconfig.build.json:

packages/protocol/tsconfig.build.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "noEmit": false,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts"]
}

Và giờ, dòng dependsOn: ["^build"] bạn đã khai trong turbo.json của 1.1 thôi là phần khung nằm chờ và bắt đầu gánh việc: chạy pnpm build và turbo biên dịch package protocol cùng service-kit trước bất cứ thứ gì import chúng — thứ tự build được khai đúng một lần, trong task graph, chứ không mã hóa vào một đoạn "hãy chạy theo thứ tự này" trong README.

API service — một ứng dụng NestJS

Với phần đồ nghề và các bước build sẵn sàng, đây là API service mà ADR-15 mô tả. Manifest của nó trước, vì một nửa số quyết định sống ở đó:

services/api/package.json
{
  "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"
  }
}

Đọc chậm những dòng đáng chú ý. "type": "commonjs" — trong một workspace đã là ESM từ 1.1. Hệ sinh thái NestJS biên dịch ra CommonJS, và chống lại phương ngữ bản địa của một framework là cách bạn tình nguyện nhận về những ca oái oăm của người khác; nên riêng package này nói CJS, và Node bắc cầu giữa hai thế giới — require() một ES module đã ổn định từ Node 22.12, chính xác là mức sàn mà dòng engines của 1.1 đã ghim. (Món nợ đã được trả: 1.1 hứa lý do sẽ xuất hiện "khi nhu cầu xuất hiện". Đây là nhu cầu đó.) Trình chạy dev là nest start --watch — chế độ watch của chính compiler trong framework — và tsx biến mất khỏi package này: api không còn chạy mã nguồn TypeScript trực tiếp nữa, nó chạy thứ nest build xuất vào dist/.

services/api/tsconfig.json
{
  "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"]
}

Đây là cú ngoặt mà tsconfig của phần đồ nghề dặn bạn giữ trong đầu: service này từ bỏ erasableSyntaxOnly. Dependency injection của Nest vận hành bằng cách đọc kiểu của các tham số constructor lúc runtime, và những kiểu ấy chỉ sống sót qua biên dịch nếu compiler phát ra decorator metadata — tức là code được sinh thêm, đúng thứ cú pháp xóa-được cấm tiệt. Đây là cái giá ADR-15 công khai chấp nhận, trả đúng nơi hồ sơ dặn trả và không nơi nào khác: gateway và mọi package giữ nguyên lá cờ; api chi nó ra, và mua về DI. (verbatimModuleSyntax nhượng bộ cùng lý do — import của một file biên dịch ra CJS bị viết lại thành các cú require, trái nghĩa với verbatim.)

Tsconfig ấy là góc nhìn của typecheck. Thứ nest build biên dịch là người anh em build của nó — vẫn ý tưởng emit như các package, nhưng kế thừa file phía trên thay vì bản nền, tắt declaration (không ai import api), và loại trừ mọi thể loại test để sản phẩm biên dịch không bao giờ cõng theo bản test đã compile. (Mẫu loại trừ thứ hai đang chờ một thể loại test mà 2.1 sẽ giới thiệu — khai trước, như input compose của 1.1.)

services/api/tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "declaration": false,
    "outDir": "dist",
    "rootDir": "src"
  },
  "exclude": ["src/**/*.test.ts", "src/**/*.itest.ts"]
}

Và file trỏ Nest CLI vào nó:

services/api/nest-cli.json
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "tsConfigPath": "tsconfig.build.json",
    "deleteOutDir": true
  }
}

Giờ đến chính ứng dụng. Một ứng dụng NestJS là một đồ thị module: module khai những gì tồn tại, injector đấu nối chúng, và các mảnh không bao giờ tự tay dựng lẫn nhau.

services/api/src/app.module.ts
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}");
  }
}

Bốn dòng đăng ký, mỗi dòng xứng với chỗ của nó. Controller:

services/api/src/health.controller.ts
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()),
    };
  }
}

Logger bước vào dưới dạng một provider, đứng sau một injection token — đây là món hời DI gói trong một file:

services/api/src/logger.ts
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");
}

Middleware request-id xin chiếc logger ấy ngay trong constructor — xin đích danh theo kiểu dữ liệu, chính là việc decorator metadata sinh ra để làm — và thực hiện đúng lời hứa mà serve() trong phần đồ nghề đưa ra: một header đi ra, một dòng log mỗi request, cùng một UUID ở cả hai:

services/api/src/request-context.middleware.ts
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();
  }
}

Còn filter lỗi là chính sách một-hình-dạng-lỗi đúc thành viên gạch framework: bất cứ thứ gì ném ra, ở bất cứ đâu — cú 404 của chính router hôm nay, một guard hay một con bọ ở Phần 2 — đường truyền chỉ thấy chiếc phong bì mà @relay/protocol định nghĩa:

services/api/src/protocol-error.filter.ts
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}`,
      }),
    );
  }
}

Điểm vào khởi động đồ thị rồi tránh đường — để ý logger riêng của framework bị tắt: workspace này đã quyết một dòng log trông ra sao từ trong phần đồ nghề, và framework không được cho ý kiến thứ hai:

services/api/src/main.ts
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();

Gateway — không framework, do quyết định

Manifest của gateway nhận thêm một script test cho task graph và ngoài ra khước từ mọi thứ api vừa đón nhận — không dependency framework nào, ESM như phần còn lại của workspace, và tsx làm trình chạy:

services/gateway/package.json
{
  "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"
  }
}
services/gateway/src/main.ts
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 });
  });
}

Tsconfig của gateway thì bạn coi như đã đọc rồi — nó chính là file của phần đồ nghề, giống từng byte, erasableSyntaxOnly vẫn bật, lá cờ sống đúng nơi cú rescope đã hứa nó sẽ tiếp tục sống. Chép nó sang, hoặc gõ lại một lần nữa:

services/gateway/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "erasableSyntaxOnly": true
  },
  "include": ["src"]
}

Gateway làm đúng một việc ngoài health: payload health của nó công bố bộ từ vựng protocol nó sẽ nói — từng tên frame, từng close code, tính lúc khởi động từ chính các export của @relay/protocol. Không một chuỗi nào được gõ tay, nên bảng công bố không thể lệch khỏi bản giao kèo. 1.3 đã hứa bộ khung sẽ không nói thứ tiếng nào ngoài package protocol; một bộ khung rỗng chưa thể trò chuyện, nhưng nó có thể bày bộ từ vựng của mình ra tủ kính — giờ là qua dist của package, theo thứ tự build mà task graph cưỡng chế.

Dựng nó dậy

Giờ chỉ một câu lệnh — task dev của 1.1 là phần khung khai báo trước, và chương này trao cho nó hai đích; turbo khởi động cả hai service, build các package trước:

pnpm dev

Giờ tra khảo bộ khung:

curl -i localhost:4000/healthz
curl -s localhost:4001/healthz
curl -i localhost:4000/no-such-route

Ba thứ đáng nhìn tận mắt. Header X-Request-Id trên mọi phản hồi — gọi hai lần, nhận hai UUID khác nhau. Khối protocol của gateway điểm danh đủ mười frame và bốn close code. Và trong dòng log, đúng một dòng JSON cho mỗi request, với request_id khớp với header bạn vừa nhận — từ cả hai service, theo cùng một khuôn, vì bộ đồ nghề là một mái nhà. Cặp đôi ấy là NFR-OBS-06 thu nhỏ: cầm một id từ bất cứ đâu, grep tìm ra trọn câu chuyện của request đó. Đứng từ bên ngoài, bạn không thể đoán service nào đang mang framework — và đó chính là ranh giới đang vận hành.

flowchart LR
    req["curl /healthz"]
    svc["service<br/>đóng dấu một UUID mới"]
    header["header phản hồi<br/>X-Request-Id: 639c…e9a"]
    logline["dòng log (stdout, JSON)<br/>{ …, request_id: 639c…e9a, status: 200 }"]
    grep["grep 639c…e9a *.log<br/>→ trọn câu chuyện của một request<br/>(NFR-OBS-06: truy vết trong vài phút)"]
    req --> svc
    svc --> header
    svc --> logline
    header --> grep
    logline --> grep
Một request, một id, hai nơi: header phản hồi người gọi giữ lấy và dòng log người vận hành grep ra — cùng một UUID xâu chuỗi cả hai.

Những bài test canh chừng người gác

Bộ test của phần đồ nghề không đổi về tinh thần — parse từng dòng log, không bao giờ nheo mắt nhìn stdout:

packages/service-kit/src/index.test.ts
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);
  });
});

Bộ test của api là nơi framework tự chứng minh trong khâu kiểm thử. Nhưng trước hết phải có một file mới — và nó tồn tại vì một cái bẫy đáng được gọi tên.

services/api/vitest.config.mts
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 } },
    }),
  ],
});

Và chính bộ test — vẫn ba lời hứa mà bộ khung không-framework từng đưa ra, giờ khẳng định qua chính bộ khung kiểm thử của framework. Hãy nhìn cú override LOGGER: bài test tráo chiếc sink log bằng cách override một provider, không chạm vào một dòng code nào của ứng dụng. Đó là món hời DI trả lãi ngay ngày đầu:

services/api/src/main.test.ts
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"));
  });
});
services/gateway/src/main.test.ts
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();
    }
  });
});

Để ý bài test thứ hai của API service: cú 404 REST của nó phải parse được bằng schema payload lỗi của package protocol. Một phép khẳng định ấy là trọn chính sách một-khuôn-lỗi, chạy được — phong bì REST và frame lỗi WebSocket không bao giờ có thể lặng lẽ tách đôi, vì có một bài test import bên này và đút cho nó bên kia. Cả hai service đều tiêu thụ @relay/protocol ngay hôm nay, đúng như 1.3 đã nói.

Bước qua cửa ải:

pnpm install
pnpm lint
pnpm typecheck
pnpm test

Bốn mươi bài test. Không Docker, không cổng cố định, và là chương thứ tư liên tiếp cửa ải không cần gì ngoài Node — cộng thêm, từ chương này trở đi, hãy nhìn task graph build các package xong xuôi rồi mới test những service import chúng.

flowchart LR
    ch1["1.1 workspace<br/>part1-ch1"]
    ch2["1.2 hạ tầng<br/>part1-ch2"]
    ch3["1.3 protocol<br/>part1-ch3"]
    ch4["1.4 bộ khung<br/>part1-ch4"]
    done["Phần 1 ✓<br/>Phần 2 thêm chi tiết:<br/>session, gửi tin, thứ tự"]
    ch1 --> ch2 --> ch3 --> ch4 --> done
Bốn chương, bốn tag, một cửa ải — Phần 1 đứng trọn vẹn, và Phần 2 đã có chỗ để thêm chi tiết.

Đến lượt bạn

Bài tập chính là công trình: tự tạo cả ba thành viên theo chương này, tự tay gõ phần đồ nghề. Rồi chọc vào bộ khung ở những chỗ nó biết dạy:

  1. Khởi động cả hai service, giết gateway, rồi curl API service — nó vẫn trả lời, bình thản. Hai tiến trình hỏng độc lập với nhau chính là toàn bộ lý do "sáu service" là một kiến trúc khả dĩ; bạn vừa quan sát phiên bản nhỏ nhất của điều đó.
  2. Bắn một tá request lẫn lộn vào cả hai service, nhặt một X-Request-Id từ một phản hồi, và grep phần log tìm nó. Một dòng, một service, trọn câu chuyện — đó là NFR-OBS-06 tập dượt trên một hệ thống to vài file.
  3. Bình luận hóa plugin swc.vite(...) trong vitest config của api rồi chạy test của nó. Nhìn DI thất bại khi vắng metadata — rồi đặt lại và đọc lại cái BẪY. Rẻ bây giờ; hoang mang lúc 2 giờ sáng về sau.
  4. Thêm tạm một route /version vào health controller và để ý: bài test 404 vẫn xanh nhưng cái route thì không ai test. Cảm nhận khoảng hở ấy: mỗi route bạn thêm từ nay về sau đều nợ bộ test một bài. Xóa nó đi (hoặc test nó) trước khi đi tiếp.

Nếu bạn kẹt, tag của chương đang giữ sẵn đáp án: part1-ch4.

Những điều đọng lại

Nếu không đọc gì khác trong chương này, hãy giữ lấy những điều sau:

  • Bộ khung dựng trước, chi tiết thêm sau — đường nối API/gateway là dòng kẻ chịu lực nhất của kiến trúc, và nó tồn tại (kèm test) trước khi bất kỳ logic nào kịp làm nhòe nó.
  • Framework phục vụ mặt tiền rộng và dừng bước trước cửa gateway (ADR-15): nhất quán theo quy mô là việc framework sinh ra để làm; phần cơ khí socket không nhận lớp nào chen giữa code và đường truyền.
  • Observability bắt đầu từ dòng đầu tiên: request ID trên mọi phản hồi (EIR-API-05), một dòng log JSON có cấu trúc cho mỗi request (NFR-OBS-01), và những khoản hoãn trung thực cho phần còn lại — theo cùng một khuôn từ cả hai service, framework hay không, vì phần đồ nghề có một mái nhà.
  • Cái giá được trả giữa ban ngày: api chi erasableSyntaxOnly cho decorator metadata và nói CJS qua cây cầu require(esm) của Node — mỗi cái giá được ADR-15 gọi tên, mỗi cái nằm trong một file cấu hình bạn chỉ tay vào được.
  • Code đã công bố cũng chỉ được sửa giữa ban ngày: bước build của package protocol đến dưới dạng bản diff tu chính đầu tiên của loạt bài — dòng cũ kiểm được theo 1.3, dòng mới kiểm được theo tag của chương này.