Phần 1 · Chương 1.1
Monorepo và bộ công cụ
Bạn sẽ tạo ra: Một pnpm workspace chạy được — TypeScript, lint, và bộ test xanh · khoảng 90 phút, bao gồm bài tập
Tài liệu gốc: SAD — Tài liệu kiến trúc phần mềm · ADR — phân tích chuyên sâu (tiếng Anh)
Phần 0 khép lại bằng một câu mà giờ chúng ta phải giữ lời: công cuộc xây dựng bắt đầu. Chương này chính là commit đầu tiên — và nó khiêm tốn một cách có chủ đích. Hôm nay chúng ta sẽ không viết một dòng logic chat nào. Chúng ta xây phần nền mà hơn bốn mươi chương sắp tới sẽ đứng lên trên: một repository, một ngôn ngữ, một bộ công cụ, với những quy tắc tự nó thực thi. Đến cuối chương, bạn sẽ có một workspace nơi ba câu lệnh chạy xanh — và bạn hiểu rõ vì sao mọi thứ đều chỉ có một.
Các quyết định, dành cho ai đã bỏ qua phần lập luận
Phần 0 để lại bộ tài liệu mà cả loạt bài này lấy làm chuẩn. Nếu bạn đã đọc, danh sách dưới đây là mười giây ôn lại; nếu bạn bỏ qua, đây là tấm vé vào cửa — mỗi mục là một quyết định ràng buộc, với lập luận được ghi lại trong chương tương ứng.
- Sản phẩm (0.1): Relay là hạ tầng chat — một API và SDK để các đội sản phẩm nhúng vào, không phải một ứng dụng chat. Danh sách non-goals là một bản cam kết; file media nằm trong phạm vi (một non-goal đã được đảo ngược sau khi mọi lý do được giải đáp), cuộc gọi thoại và mã hóa đầu-cuối nằm ngoài.
- Con người (0.2): Mai chọn dùng, David phê duyệt, Priya vận hành, Tuan trải nghiệm. Trật tự phân xử khi họ va chạm: độ tin cậy của Tuan thắng tất cả.
- Khoảnh khắc (0.3): ba dấu ★ định đoạt sản phẩm — tin nhắn đầu tiên của Mai, cú tái dựng của Priya, đường hầm của Tuan.
- Lời hứa (0.4): 224 yêu cầu với ID, độ ưu tiên và phương pháp kiểm chứng. FR-TEN-05 (cách ly tenant) là dòng quan trọng nhất của cả tài liệu.
- Quyết định (0.5): tám driver chưng cất từ những yêu cầu ấy; mười bảy bản ADR, bất biến một khi đã accepted, mỗi bản gọi tên các phương án bị bác bỏ và điều kiện sụp đổ của chính nó.
Hôm nay chúng ta hiện thực hóa bản đầu tiên trong số mười bảy: ADR-01.
ADR-01 — vì sao một ngôn ngữ là quyết định, không phải mặc định
Trực giác microservices quen thuộc mách bảo "việc nào công cụ nấy": Go cho gateway, có khi Rust cho những đoạn hot path, Node cho dashboard. Bản SAD của Relay ghi nhận sáu service — trực giác ấy sẽ dúi vào tay chúng ta ba bộ công cụ trước cả khi gửi được một tin nhắn. ADR-01 bác bỏ nó, và lập luận trong bản deep dive đáng để nắm cho chắc trước khi bạn gõ dòng đầu tiên.
Điểm mấu chốt nằm ở một quan sát: SDK đằng nào cũng phải là TypeScript — FR-SDK-01 nhắm đến trình duyệt, Node và React Native. Giao thức WebSocket có hai đầu, và kiểu frame, ngữ nghĩa cursor, logic idempotency key tồn tại ở cả hai. Nếu server cũng là TypeScript, bản giao kèo ấy nằm gọn trong một package dùng chung duy nhất, được gateway, API service và SDK cùng sử dụng. Nói theo đúng lời bản deep dive: đổi một kiểu frame chỉ tốn một commit, và sai lệch giữa serialization của server và client "trở thành lỗi biên dịch thay vì sự cố ngoài production." Nếu server là Go, bản giao kèo đó phải được duy trì hai lần, mãi mãi — bằng tay, hoặc bằng cỗ máy codegen mà bản thân nó cũng là thêm một gánh bảo trì.
flowchart TB
proto["@relay/protocol<br/>kiểu frame · ngữ nghĩa cursor ·<br/>logic idempotency key (một gói duy nhất)"]
gw["Gateway service"]
apisvc["API service"]
sdk["SDK JavaScript<br/>(trình duyệt · Node · React Native)"]
proto --> gw
proto --> apisvc
proto --> sdk
note["Đổi một kiểu frame chỉ tốn MỘT commit —<br/>sai lệch trở thành lỗi biên dịch,<br/>không phải sự cố ngoài production"]
proto ~~~ noteNhững lợi thế thật của Go cho một gateway — socket rẻ hơn, không có khoảng dừng GC đáng kể — là có thật nhưng không mang tính quyết định ở quy mô v1; những socket phần lớn thời gian nằm im chính là loại công việc mà event loop của Node sinh ra để phục vụ. Còn phương án "trình diễn đa ngôn ngữ" nhận lời bác bỏ sắc nhất của bản deep dive: reviewer có kinh nghiệm production đọc "năm ngôn ngữ, một tác giả" thành năm bộ công cụ chăm nửa vời và chẳng có chiều sâu ở đâu cả. Driver D8 — một kỹ sư phải vận hành và hiểu thấu tất cả — khiến một stack duy nhất trở thành lựa chọn bền vững duy nhất. Điều kiện đảo ngược cũng nằm trên giấy: xem lại khi profiling cho thấy hơn 20% thời gian event loop của gateway tiêu vào crypto hoặc serialization ở tải mục tiêu.
Workspace, từ một thư mục trống
Kiểm tra công cụ, rồi tạo thư mục. Mọi thứ bên dưới giả định Node 22+ và pnpm 10+.
mkdir relay-platform && cd relay-platform
git init -b mainpackage.json ở gốc khai báo danh tính của workspace: package manager được ghim
phiên bản (để mọi máy phân giải dependency giống hệt nhau), mức Node tối thiểu,
và — quan trọng nhất — ba script sẽ đứng gác cho mọi chương của loạt bài này:
{
"name": "relay-platform",
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=22.12"
},
"scripts": {
"dev": "turbo run dev",
"lint": "turbo run //#lint:root",
"lint:root": "eslint .",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"build": "turbo run build"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.1.2",
"eslint": "^10.8.0",
"prettier": "^3.9.6",
"turbo": "^2.10.8",
"typescript": "^5.9.3",
"typescript-eslint": "^8.65.0",
"vitest": "^4.1.10"
}
}Có ba chi tiết đáng dừng lại một nhịp. "private": true vì bản thân gốc
workspace không bao giờ được publish — còn các package bên trong thì có. Phiên bản
TypeScript được ghim vào dòng 5.9 một cách có chủ đích: tại thời điểm viết,
bản major mới nhất của TypeScript đã đi trước những gì bộ công cụ lint hỗ trợ,
và một workspace mải chạy đua theo mọi thứ mới nhất sẽ nướng hết buổi sáng này
đến buổi sáng khác vào việc "khảo cổ" các đợt nâng cấp. Và mức sàn Node là
>=22.12 chứ không phải con số tròn >=22: một năng lực runtime mà workspace
này sẽ tựa vào xuất hiện đúng ở phiên bản đó — chương 1.4 sẽ gọi tên nó khi
nhu cầu xuất hiện. Các con số phiên bản trong file này sẽ trôi dần theo tuổi
của loạt bài — tag của chương, chứ không phải phần chữ, mới luôn là sự thật.
Giờ hãy nhìn kỹ xem các script của cửa ải thực chất là gì: tất cả đều gọi turbo run. Những
câu lệnh bạn gõ không bao giờ đổi — pnpm lint, pnpm typecheck,
pnpm test — nhưng thứ chạy bên dưới chúng là một task graph, và đó là
một quyết định có hồ sơ riêng (ADR-17), được lập luận đàng hoàng vài mục bên
dưới. Trước hết, hãy dựng những mảnh ghép mà task graph ấy sẽ chạy.
Bản đồ workspace là một file duy nhất. Hai dòng glob, và chúng là một lời hứa về tương lai:
packages:
- "packages/*"
- "services/*"packages/ chứa code mà code khác import — package protocol sẽ đến ở chương 1.3.
services/ chứa sáu service triển khai được từ góc nhìn service của bản SAD —
những bộ khung đầu tiên đến ở 1.4. Hôm nay services/ chỉ chứa một .gitkeep,
và điều đó không có gì đáng ngượng: đó là tấm bản đồ khớp với tài liệu kiến
trúc trước khi những tòa nhà mọc lên.
flowchart TB
root["relay-platform/<br/>package.json · pnpm-workspace.yaml · turbo.json<br/>tsconfig.base.json · eslint.config.mjs"]
pkgs["packages/"]
svcs["services/<br/>(trống cho đến chương 1.4)"]
config["@relay/config<br/>hằng số dùng chung + bài smoke test<br/>(hôm nay)"]
protocol["@relay/protocol<br/>kiểu frame, mã lỗi<br/>(chương 1.3)"]
api["api · gateway · worker…<br/>(từ chương 1.4 trở đi)"]
root --> pkgs
root --> svcs
pkgs --> config
pkgs -.-> protocol
svcs -.-> apiMột compiler nghiêm ngặt, một cấu hình lint, một test runner
Cấu hình TypeScript nền chỉ có đúng một bản, đặt ở gốc, và mọi package đều kế thừa từ đó. Siết chặt ngay từ ngày đầu là lúc rẻ nhất — vì chưa có dòng code nào để phàn nàn cả:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noEmit": true
}
}strict là cái tên nổi bật nhất, nhưng hai dòng ngay sau nó mới là thứ các
chương sau này phải cảm ơn: noUncheckedIndexedAccess buộc mọi phép truy cập
mảng hay record phải thừa nhận khả năng không có giá trị, còn
exactOptionalPropertyTypes không cho "thiếu hẳn trường" và "trường mang giá
trị undefined" bị đánh đồng — cả hai đều cực kỳ hệ trọng trong một package
protocol, nơi một trường bị thiếu và một trường null mang hai ý nghĩa khác
nhau trên đường truyền.
Lint theo đúng nguyên tắc "mỗi luật một mái nhà" — một flat config duy nhất ở gốc:
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
// One lint config for the whole workspace (ADR-01's consequence made literal).
export default tseslint.config(
{ ignores: ["**/node_modules/**", "**/dist/**", "**/coverage/**"] },
eslint.configs.recommended,
...tseslint.configs.recommended,
);Test runner vẫn là Vitest ở mọi nơi — nhưng hãy để ý thứ vắng mặt: không có
cấu hình Vitest nào ở gốc. Mỗi package tự sở hữu một script hai chữ
"test": "vitest run", và các mặc định của Vitest tự tìm thấy test trong src
của package đó. "Một test runner" chưa bao giờ có nghĩa là một file cấu hình;
nó có nghĩa là một công cụ duy nhất — còn thứ chạy script của từng package,
theo đúng thứ tự, chính là task graph.
Cài bộ công cụ (lệnh này ghi ra đúng phần devDependencies bạn thấy phía
trên):
pnpm add -Dw typescript@~5.9.0 eslint @eslint/js typescript-eslint prettier vitest @types/node turboTask graph — ADR-17, và một cái cache bạn được phép tin
Vì sao một workspace mới có một package lại cần đến task runner? Bây giờ
thì chưa cần. Nó cần trước thời điểm mọi thứ bắt đầu đau, vì thời điểm ấy rơi
đúng vào một chương đã xuất bản: 1.4 trao cho workspace này bước build thật
đầu tiên, và một bước build kéo theo một thứ tự build (package protocol
phải biên dịch xong trước các service import nó). Script đệ quy trần trụi
không có trí nhớ và không có thứ tự — sửa một dòng tài liệu là cả thế giới bị
kiểm lại. Câu trả lời của ADR-17 là Turborepo: mỗi task vẫn là một script
bình thường của package, turbo run quyết định thứ gì cần chạy và ghi nhớ thứ
gì đã chạy rồi. Ngày mai xóa turbo.json đi thì pnpm -r vẫn chạy đúng
những script ấy — chậm, và đúng. Chính vì có lối lui ấy mà quyết định
này mới an toàn để đưa ra sớm đến vậy.
Cả task graph nằm gọn trong một file:
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
},
"typecheck": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/compose.yaml"]
},
"//#lint:root": {
"inputs": [
"**/*.{ts,mts,cts,mjs,js}",
"eslint.config.mjs",
"package.json"
]
}
}
}Đọc từ trên xuống là thấy cả tương lai của workspace: build và dev chưa
có đích để chạy — chúng là phần khung được khai báo trước, như services/ là
một thư mục được khai báo trước, và chương 1.4 sẽ lấp đầy cả hai.
dependsOn: ["^build"] là luật thứ tự ("build của các dependency phải xong
trước tôi"). Lint vẫn nguyên như trước nay — MỘT lần chạy ESLint phủ cả
repository, đăng ký thành root task để nó cũng được cache như mọi thứ khác.
Còn test khai báo thêm một input trỏ đến một file chưa hề tồn tại: chương
1.2 sẽ tạo compose.yaml, và một bài test sẽ đọc nó. Khai báo input trước
khi file ra đời không phải thói tỉ mẩn — đó chính là toàn bộ kỷ luật mà chiếc
hộp ngay dưới đây nói tới.
Package đầu tiên — và một bài test có ý nghĩa thật
Một bộ công cụ trống thì chẳng chứng minh được gì; những phép kiểm cần một thứ
gì đó để kiểm. Package đầu tiên của chúng ta là @relay/config — các hằng số dùng
chung của workspace, và là mái nhà được chỉ định cho các mảnh cấu hình lint và
test khi số package nhân lên. Cần vạch rõ ranh giới, vì cái BẪY phía trên vẫn còn
đó: cấu hình nền của compiler nằm nguyên ở gốc làm nguồn duy nhất; package này
chỉ export các hằng số runtime của workspace — và bài test của nó mới là
phần thú vị.
{
"name": "@relay/config",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
}
}Hai script ấy là toàn bộ "quyền công dân" của package trong task graph: turbo
nhận diện task theo tên script, nên chỉ cần có script test là package này
đã kiểm được, cache được, sắp thứ tự được — không cần đăng ký ở bất cứ đâu.
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}Để ý đường dẫn extends: đường dẫn tương đối, trỏ về gốc, về bản cấu hình nền
duy nhất. Mã
nguồn của package là ba hằng số:
// @relay/config — the workspace's shared constants, and the designated home
// for lint/test fragments as packages multiply. The compiler baseline itself
// lives once at the repository root (tsconfig.base.json); packages extend it
// by relative path — one home per rule, never copies (chapter 1.1's TRAP).
export const NODE_VERSION_RANGE = ">=22.12";
export const WORKSPACE_GLOBS = ["packages/*", "services/*"] as const;
export const TOOLCHAIN_CHECKS = ["lint", "typecheck", "test"] as const;Và đây là bài smoke test — không phải một expect(true).toBe(true) chiếu lệ,
mà một khẳng định thật: các hằng số package này export buộc phải khớp với các
manifest thật trong repository. Nếu package.json ở gốc và @relay/config một
ngày kể hai câu chuyện khác nhau, bộ test lập tức báo trượt:
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
NODE_VERSION_RANGE,
TOOLCHAIN_CHECKS,
WORKSPACE_GLOBS,
} from "./index.js";
// The smoke test that makes "tested state" mean something on day one: the
// shared constants must agree with the workspace's real manifests — if the
// root package.json and @relay/config ever tell different stories, this fails.
const repoRoot = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
);
describe("@relay/config agrees with the workspace manifests", () => {
const rootPkg = JSON.parse(
readFileSync(join(repoRoot, "package.json"), "utf8"),
) as { engines: { node: string }; scripts: Record<string, string> };
it("pins the same Node version range as the root manifest", () => {
expect(rootPkg.engines.node).toBe(NODE_VERSION_RANGE);
});
it("names exactly the toolchain checks the root manifest provides", () => {
for (const check of TOOLCHAIN_CHECKS) {
expect(rootPkg.scripts).toHaveProperty(check);
}
});
it("lists the same workspace globs as pnpm-workspace.yaml", () => {
const workspaceYaml = readFileSync(
join(repoRoot, "pnpm-workspace.yaml"),
"utf8",
);
for (const glob of WORKSPACE_GLOBS) {
expect(workspaceYaml).toContain(`"${glob}"`);
}
});
});Dọn nốt phần việc nhà — một .gitignore để sản phẩm build không bao giờ lọt
vào lịch sử:
node_modules/
dist/
coverage/
.turbo/
*.log
.env*
.DS_Store— cộng thêm một .nvmrc ghi 22, một .prettierrc theo nếp nhà, một
services/.gitkeep, và một README kể cho khách ghé thăm biết repository này là
gì và các tag chương vận hành ra sao. Cả bốn đều nằm trong tag nếu bạn cần đúng
từng chữ.
Cửa ải
Ba câu lệnh. Từ đây đến hết loạt bài, một chương chưa được coi là xong chừng nào chúng chưa chạy xanh:
pnpm install
pnpm lint
pnpm typecheck
pnpm testTrên workspace này, câu lệnh cuối chạy một file, ba phép khẳng định, và báo xanh trong chưa đầy một giây. Chạy nó lần nữa và xem task graph tự kiếm phần cơm của mình:
@relay/config:test: cache hit, replaying logs 11d54803264fc27c
Tasks: 1 successful, 1 total
Cached: 1 cached, 1 total
Time: 7ms >>> FULL TURBOBảy mili giây, vì không có gì thay đổi và turbo chứng minh được điều đó. Khi
bạn muốn lời chứng ấy được kiếm lại thay vì phát lại — trước một cú tag,
chẳng hạn — hãy ép nó: pnpm turbo run test --force chạy lạnh lại toàn bộ,
và nó buộc phải xanh y như thế. Nhỏ — nhưng là điểm chịu lực: cửa ải giờ đã
tồn tại, và mọi chương tương lai đều phải vượt qua nó.
flowchart LR
code["code của chương"]
lint["pnpm lint<br/>một cấu hình ESLint"]
types["pnpm typecheck<br/>một tsconfig nghiêm ngặt"]
test["pnpm test<br/>một test runner (Vitest)"]
tag["tag của chương<br/>part1-ch1 · part1-ch2 · …"]
turbo["turbo run — sắp thứ tự task graph,<br/>cache những gì chứng minh được là đã chạy (ADR-17)"]
code --> lint --> types --> test --> tag
turbo -.-> lint
turbo -.-> types
turbo -.-> testĐến lượt bạn
Bài tập của Phần 0 xây một dự án song song. Từ chương này trở đi, quy ước thay đổi: bài tập chính là công trình — bạn tự tay dựng Relay, song hành cùng chương, và thành quả của bạn chính là trạng thái chạy được ở cuối chương.
Vậy nên: hãy dựng workspace phía trên, từ thư mục trống, tự gõ thay vì copy-dán ở bất cứ chỗ nào bạn đủ kiên nhẫn. Rồi tự kiểm tra mình trước cửa ải:
pnpm lint,pnpm typecheckvàpnpm testcó cùng chạy xanh từ một lầnpnpm installsạch không?- Xóa
"lint"khỏi scripts ở gốc rồi chạy test. Bài smoke test có trượt không? (Nhớ đặt lại.) Nếu có — bài test của bạn là thật: nó nhận ra thực tế vừa thay đổi. - Đổi
strictthànhfalsetrong một bản sao của tsconfig gốc đặt trongpackages/config/, rồi trỏ package vào đó. Cảm nhận xem việc ấy dễ đến mức nào — rồi xóa bản sao đi và đọc lại cái BẪY thứ nhất. - Chạy
pnpm testhai lần và đọc kết quả lần hai:FULL TURBO. Giờ đánh dấu bài này lại, chờ đến cuối chương 1.2: xóa dòng$TURBO_ROOT$/compose.yamlkhỏiturbo.json, sửa một ký tự trongcompose.yaml, rồi chạypnpm test. Nhìn cái cache nói dối bạn bằng màu xanh. Đặt dòng ấy lại, chạy lần nữa, và nhìn nó thú nhận (cache miss, executing). Đó là cái BẪY thứ hai, diễn trực tiếp.
Nếu bạn kẹt, tag của chương đang giữ sẵn đáp án.
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:
- Một ngôn ngữ là một quyết định có lập luận, không phải một mặc định: SDK đằng nào cũng phải là TypeScript, nên server TypeScript biến giao thức thành một package dùng chung — sai lệch trở thành lỗi biên dịch, không phải sự cố ngoài production (ADR-01).
- Mỗi luật có đúng một mái nhà: một tsconfig nền, một cấu hình lint, một test runner. Các package kế thừa; không bao giờ sao chép — bản sao sẽ trôi dạt, và sự trôi dạt giết chết quả ngọt của kiểu dùng chung.
- Cửa ải chính là luật định dạng:
pnpm lint && pnpm typecheck && pnpm test, xanh ở mọi tag chương, từpart1-ch1đến cuối loạt bài. - Cache là một lời tuyên bố, và lời tuyên bố cần input trung thực (ADR-17): turbo bỏ qua phần việc nó chứng minh được là đã chạy; mỗi file một task đọc đều phải được khai báo, bằng không sẽ có ngày cái cache báo xanh về một thứ nó chưa hề kiểm.
- Bài test viết từ ngày đầu nên khẳng định một điều có thật về thực tế — bài của chúng ta trượt nếu manifest của workspace và các hằng số của nó một ngày bất đồng.
- Thư mục trống cũng có thể là kiến trúc:
services/không chứa gì ngoài một lời hứa khớp với góc nhìn service của bản SAD — tấm bản đồ có trước những tòa nhà.