GĐ04 — Express: cơ chế & cấu trúc project (Dự án 1: Todo API)

Ghi chú cho FE engineer (JS/TS mạnh) chuyển sang Backend. Mỗi khái niệm: định nghĩa → tại sao quan trọng → cơ chế → code ngắn → pitfall. Mindset chuyển đổi: ở FE bạn gọi API; ở BE bạn là cái API đó — bạn nhận request thô, tự parse, tự validate, tự quyết định response và status code.

Hộp phiên bản — Express 5. Mọi ví dụ trong file chạy trên Express 5.2 (npm i express@5, cần Node ≥ 18). Nhiều bài viết cũ vẫn viết cho Express 4; khác biệt cần nhớ:

  • Async handler tự chuyển lỗi. Handler async mà throw (hoặc promise reject) → Express 5 tự gọi next(err). Không cần express-async-errors, asyncHandler hay try/catch chỉ để chuyển lỗi. Trên Express 4 cùng đoạn code làm request treo và sinh unhandledRejection (đã chạy thử cả hai bản).
  • Wildcard phải đặt tên: app.get("/files/*splat", …); "/files/*" ném lỗi ngay lúc đăng ký route. req.params.splat là mảng các đoạn path.
  • req.body là undefined khi không có parser nào khớp Content-Type (hoặc client không gửi body). Luôn xử lý trường hợp này trước khi đọc field.
  • req.query là getter chỉ đọc: gán req.query = … ném TypeError. Validate xong thì lưu kết quả vào biến mới.
  • Dự án còn ở Express 4 (4.22.x)? Giữ asyncHandler ở mục 6 (hoặc dùng express-async-errors).
  • Kiểm chứng ngày 2026-10-05 (npm registry, cài và chạy trong thư mục tạm): express 5.2.1, helmet 8.3.0, jsonwebtoken 9.0.3, bcryptjs 3.0.3, supertest 7.3.1, zod 4.6.5, Node 24.21, tsc --strict (TypeScript 7.0.2) không lỗi. Mã trong lời giải Dự án 1 đã chạy thật với các bản này. Nguồn phiên bản: https://registry.npmjs.org/ và https://nodejs.org/en/about/previous-releases.

1. Express là gì — quan hệ với http module#

Định nghĩa. Express là một framework tối giản (thin layer) bọc quanh module http built-in của Node. Bản chất Express không thay thế http; nó chỉ thêm lớp routing (map method + path → handler) và middleware (chuỗi hàm xử lý request tuần tự). Cuối cùng Express vẫn gọi http.createServer().

Tại sao quan trọng. Nếu chỉ dùng http thô, bạn phải tự viết if (req.url === '/todos' && req.method === 'GET'), tự parse body từ stream, tự set header. Không scale được. Express biến mớ if/else đó thành khai báo route + middleware sạch sẽ. Hiểu rằng Express chỉ là lớp bọc giúp bạn không "sợ" nó — khi debug, bạn biết req/res vẫn là http.IncomingMessage/http.ServerResponse được mở rộng thêm.

Cơ chế. app = express() trả về một request listener function (req, res). Function này có thể truyền thẳng vào http.createServer(app). Bên trong, mỗi request đi qua một stack middleware; Express duyệt stack, tìm cái nào match path

  • method rồi gọi tuần tự.
typescriptReady
import express from "express";import http from "node:http";const app = express();app.get("/health/live", (_req, res) => res.json({ ok: true })); // liveness: không chạm DB// (khi có DB, thêm /health/ready kiểm kết nối — xem GĐ15; đừng gộp DB vào /health/live)// app CHÍNH LÀ (req,res) handler của http:http.createServer(app).listen(3000);// app.listen(3000) chỉ là shortcut cho đúng dòng trên.

Pitfall. Đừng nhầm Express là "server". Nó là handler. Khi bạn cần thêm WebSocket (socket.io) hay HTTPS, bạn cần đối tượng http.Server để dùng chung cổng. app.listen() có trả về chính http.Server đó (const server = app.listen(3000)), nên dùng cách nào cũng lấy được tham chiếu; http.createServer(app) chỉ rõ ràng hơn khi bạn muốn tự chọn https.createServer(opts, app) hoặc gắn thêm listener trước khi listen.


2. Routing: method, params, query, Router modular#

Định nghĩa. Routing là việc map (HTTP method, URL path) tới một handler. Express cung cấp app.get/post/put/patch/delete(path, handler). Path có thể chứa route params (/todos/:id) và request kèm query string (/todos?page=2).

Tại sao quan trọng. Đây là "bảng định tuyến" của API — hợp đồng giữa client và server. Route params dùng cho định danh tài nguyên (resource id); query dùng cho tùy chọn (filter, sort, pagination). Phân biệt đúng giúp API RESTful, dễ đoán.

Cơ chế.

  • req.params — object từ các :name trong path (luôn là string).
  • req.query — object parse từ query string (giá trị là string hoặc string[]).
  • express.Router() — mini-app con để nhóm route theo domain, mount bằng app.use("/prefix", router). Giúp tách file, tránh 1 file router khổng lồ.
typescriptReady
// routes/todos.route.tsimport { Router } from "express";const router = Router();router.get("/", (req, res) => {  const page = Number(req.query.page ?? 1); // query -> string, phải ép kiểu  res.json({ page });});router.get("/:id", (req, res) => {  res.json({ id: req.params.id }); // params.id LUÔN là string});export default router;// app.tsimport todosRouter from "./routes/todos.route";app.use("/todos", todosRouter); // GET /todos/:id

Kết quả (đã chạy trên Express 5.2.1, Node 24): khai báo /todos/stats trước /todos/:id thì GET /todos/stats ra {"route":"stats"}; GET /todos/42 ra id: "42" kiểu string; GET /todos?page=5 với q.query.page + 1 ra "51", không phải 6.

Pitfall.

  • req.params.id và req.query.page luôn là string, không phải number. Ép kiểu + validate trước khi dùng, nếu không id + 1 = "5" + 1 = "51".
  • Thứ tự route quan trọng: route cụ thể (/todos/stats) phải đặt trước route động (/todos/:id), nếu không :id nuốt luôn stats.

3. Middleware chain & next()#

Định nghĩa. Middleware là hàm (req, res, next) chạy tuần tự trong pipeline xử lý request. Mỗi middleware có 3 lựa chọn: (a) gọi next() để chuyển cho cái kế tiếp, (b) kết thúc bằng res.send/json/end (early return), hoặc (c) gọi next(err) để nhảy sang error handler.

Tại sao quan trọng. Đây là xương sống của Express. Auth, logging, body parsing, validation, rate limit — tất cả đều là middleware. Hiểu chuỗi này = hiểu 80% Express. Nó cũng là nguồn bug phổ biến nhất (quên next() → request treo).

Cơ chế. Express giữ một stack. Với mỗi request, nó gọi middleware đầu tiên match; next() là con trỏ "đi tiếp". Nếu không gọi next() và cũng không trả response → request treo mãi mãi (client timeout). Middleware có thể:

  • Global: app.use(fn) — chạy cho mọi request.
  • Route-level: app.get("/x", mw1, mw2, handler) — chỉ cho route đó.
typescriptReady
// global loggerapp.use((req, _res, next) => {  console.log(req.method, req.url);  next(); // BẮT BUỘC, nếu quên => treo});// route-level guard (early return, KHÔNG gọi next)const requireQueryKey = (req, res, next) => {  if (!req.query.key) return res.status(400).json({ error: "missing key" });  next();};app.get("/secret", requireQueryKey, (_req, res) => res.json({ ok: true }));

Luồng một request đi qua stack (thứ tự đăng ký = thứ tự chạy):

textReady
request  |  v[logger] --next()--> [express.json] --next()--> [auth] --next()--> [handler]                          |                        |                   |                     JSON hỏng:             thiếu token:      throw / next(err)                     next(err)                res 401 (dừng)            |                          |                                             |                          +----------------> [errorHandler] <-----------+                                                  |                                              res 4xx/500

Kết quả (đã chạy): middleware không gọi next() và không trả response thì fetch tới route đó hết thời gian chờ (TimeoutError), server không báo lỗi gì.

Pitfall.

  • Quên next(): request treo. Triệu chứng: Postman quay mãi.
  • Gọi next() rồi vẫn res.json(): lỗi Cannot set headers after they are sent. Sau khi trả response phải return ngay.
  • Thứ tự đăng ký = thứ tự chạy. app.use(express.json()) phải nằm trước route đọc req.body. Đăng ký sau route thì route không thấy body.

4. Body parsing: express.json(), limit, raw body cho webhook#

Định nghĩa. Body của POST/PUT đến dưới dạng stream byte thô. express.json() là middleware đọc hết stream, JSON.parse rồi gán vào req.body. Không có nó, req.body là undefined.

Tại sao quan trọng. FE quen await res.json() ở client. Ở server, bạn mới là người phải làm bước đó. Đây là bug "kinh điển" của người mới: POST data lên nhưng req.body rỗng vì quên đăng ký parser.

Cơ chế. express.json() chỉ parse khi header Content-Type: application/json. Tham số limit giới hạn kích thước body (mặc định 100kb) để chống DoS bằng payload khổng lồ. express.urlencoded() cho form HTML.

typescriptReady
app.use(express.json({ limit: "1mb" }));app.post("/todos", (req, res) => {  // req.body giờ mới có; nếu quên middleware trên => undefined  res.status(201).json({ received: req.body });});

Raw body cho webhook. Webhook (Stripe, GitHub) ký payload bằng HMAC trên chuỗi byte gốc. Nếu để express.json() parse thành object rồi JSON.stringify lại, byte có thể khác (thứ tự key, khoảng trắng) → verify chữ ký thất bại. Phải giữ raw body cho đúng route đó:

typescriptReady
app.post(  "/webhook",  express.raw({ type: "application/json" }), // req.body = Buffer thô  (req, res) => {    verifySignature(req.body, req.headers["x-signature"]); // dùng Buffer gốc    res.sendStatus(200);  });

Kết quả (đã chạy): POST /echo không có body, hoặc có Content-Type: text/plain, cho req.body === undefined; gửi application/json {"a":1} thì req.body là {a:1}. Route /webhook đăng ký express.raw trước express.json() nhận Buffer đúng 12 byte của chuỗi { "a" : 1 } (kể cả khoảng trắng), nên HMAC tính trên byte gốc.

Pitfall. Đặt express.json() global rồi mới thêm route webhook → raw body đã bị nuốt. Fix: đăng ký express.raw() cho route webhook trước global json, hoặc dùng express.json({ verify }) để lưu buffer gốc.


5. Validation với Zod#

Định nghĩa. Zod là thư viện schema validation ưu tiên TypeScript. Bạn khai báo schema mô tả hình dạng dữ liệu; Zod kiểm tra runtime và suy ra type tự động.

Tại sao quan trọng. req.body/req.query là dữ liệu không tin được từ client. TypeScript chỉ kiểm tra lúc compile, không bảo vệ runtime — client có thể gửi bất cứ thứ gì. Validate ở biên (boundary) chặn dữ liệu bẩn trước khi nó chạm tới business logic. "Parse, don't validate": sau khi qua Zod, bạn có object đã đúng type, không phải rải if (typeof x...) khắp nơi.

Cơ chế. schema.parse(data) — throw ZodError nếu sai. schema.safeParse(data) — trả { success, data | error }, không throw. Trong middleware thường dùng safeParse để tự kiểm soát response.

typescriptReady
import { z } from "zod";import type { RequestHandler } from "express";const createTodoSchema = z.object({  title: z.string().min(1).max(200),  done: z.boolean().default(false),});type CreateTodo = z.infer<typeof createTodoSchema>; // type auto// middleware validate tái sử dụngconst validate =  (schema: z.ZodType): RequestHandler =>  (req, res, next) => {    const result = schema.safeParse(req.body ?? {}); // Express 5: body có thể là undefined    if (!result.success) {      return res.status(422).json({        error: "ValidationError",        details: z.flattenError(result.error).fieldErrors, // chi tiết từng field      });    }    req.body = result.data; // dùng data ĐÃ parse (đã có default, đã ép kiểu)    next();  };app.post("/todos", validate(createTodoSchema), (req, res) => {  const body = req.body as CreateTodo; // an toàn, đúng type  res.status(201).json(body);});

Pitfall.

  • Dùng 422 Unprocessable Entity cho lỗi validate (semantics rõ ràng hơn 400).
  • Query string toàn string → dùng z.coerce.number() để ép "2" → 2.
  • Đừng dùng lại req.body gốc sau validate; dùng result.data (đã có default, đã strip field lạ nếu schema strict).

6. Error handling tập trung#

Định nghĩa. Express nhận diện error-handling middleware bằng 4 tham số (err, req, res, next). Khi bất kỳ đâu gọi next(err), Express nhảy thẳng tới handler 4-tham-số này, bỏ qua mọi middleware thường ở giữa.

Tại sao quan trọng. Không có nó, mỗi route phải tự try/catch rồi res.status lặp đi lặp lại → trùng lặp, dễ sót, response lỗi không đồng nhất. Một global handler = một chỗ duy nhất map lỗi → HTTP response, log tập trung.

Cơ chế. Ba mảnh ghép:

  1. AppError class — lỗi có chủ đích (operational) mang theo statusCode. Phân biệt với bug lập trình (programmer error) để biết cái nào an toàn để lộ ra client, cái nào phải giấu (500).
typescriptReady
export class AppError extends Error {  constructor(    public statusCode: number,    message: string,    public isOperational = true // true = lỗi dự kiến (404, 409...), false = bug  ) {    super(message);    Object.setPrototypeOf(this, AppError.prototype);  }}
  1. Lỗi từ async handler — trên Express 5 một async route throw (hoặc promise reject) được chuyển thẳng sang next(err), không cần wrapper. Trên Express 4 Express không thấy lỗi đó → request treo, nên phải bọc để chuyển reject thành next(err) (chỉ cần nếu bạn còn dùng Express 4):
typescriptReady
import type { RequestHandler } from "express";// CHỈ cho Express 4 — Express 5 đã làm việc này sẵnexport const asyncHandler =  (fn: RequestHandler): RequestHandler =>  (req, res, next) =>    Promise.resolve(fn(req, res, next)).catch(next); // reject -> next(err)
  1. Global handler (đăng ký cuối cùng, sau mọi route). Nó phải map cả lỗi không phải AppError mà client có thể gây ra: ZodError (input sai) và lỗi của express.json() (JSON hỏng, body quá lớn). Thiếu hai nhánh này thì chúng rơi hết xuống 500, trái với lời hứa "input sai → 422/400":
typescriptReady
import { z, ZodError } from "zod";import type { ErrorRequestHandler } from "express";export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {  if (err instanceof AppError && err.isOperational) {    return res.status(err.statusCode).json({ error: err.message });  }  if (err instanceof ZodError) {    return res.status(422).json({      error: "ValidationError",      details: z.flattenError(err).fieldErrors,    });  }  // lỗi của express.json() mang sẵn status 4xx  if (err?.type === "entity.parse.failed") {    return res.status(400).json({ error: "Malformed JSON body" });  }  if (typeof err?.status === "number" && err.status >= 400 && err.status < 500) {    return res.status(err.status).json({ error: err.message }); // vd 413 body quá lớn  }  console.error("UNEXPECTED:", err); // bug -> log, KHÔNG lộ chi tiết  res.status(500).json({ error: "Internal Server Error" });};// dùng (Express 5: không cần wrapper):app.get("/todos/:id", async (req, res) => {  // service ném AppError(404) nếu không tồn tại hoặc không phải của user này  const todo = await service.getOwned(req.params.id, req.user!.id);  res.json(todo);});app.use(errorHandler); // PHẢI đăng ký sau cùng

Kiểm tra bằng curl (đã chạy trên Express 5.2.1 + Zod 4):

bashReady
curl -i "localhost:3000/todos?page=abc"                        # 422 ValidationError (trước khi có nhánh ZodError: 500)curl -i -X POST localhost:3000/todos -H 'content-type: application/json' -d '{bad'   # 400 (trước: 500)

Pitfall.

  • Trên Express 4, quên asyncHandler là bug #1 với async route: throw biến mất, request treo. Express 5 hết bẫy này.
  • ZodError và lỗi body-parser không phải AppError. Handler chỉ nhận AppError thì schema.parse() sai input và JSON hỏng đều thành 500 (đã đo: 500 cho cả hai).
  • Error handler phải đủ 4 tham số kể cả không dùng next — nếu chỉ 3 tham số, Express coi nó là middleware thường, không nhận lỗi.
  • Đăng ký errorHandler trước route → nó không bao giờ chạy. Luôn cuối cùng.

7. Cấu trúc project theo layer#

Định nghĩa. Tách code thành các tầng trách nhiệm rõ ràng: route → controller → service → repository.

LayerTrách nhiệmKHÔNG làm
routekhai báo path + gắn middlewarekhông có logic
controllerđọc req, gọi service, trả res + statuskhông truy vấn DB, không business rule
servicebusiness logic, orchestration, transactionkhông biết req/res
repositorytruy cập DB (query thô)không có business rule

Tại sao quan trọng. Là FE bạn quen tách component/hook/api-client — cùng tư duy separation of concerns. Nhồi tất cả vào route handler thì: không test unit được (service dính chặt req/res), không tái sử dụng logic, một file phình 800 dòng. Tách tầng giúp test service độc lập, đổi DB chỉ sửa repository.

Cơ chế. Dữ liệu chảy xuống, kết quả chảy lên. Controller là dịch giả giữa thế giới HTTP và business logic; service thuần TypeScript, không import gì từ express → dễ unit test.

typescriptReady
// repository: chỉ DBexport const todoRepo = {  // lọc theo userId NGAY TRONG query: todo của người khác không khác gì todo không tồn tại  findOwned: (id: string, userId: string) => db.todo.findFirst({ where: { id, userId } }),  create: (data: CreateTodo & { userId: string }) => db.todo.create({ data }),};// service: business rule, không biết req/resexport const todoService = {  async getOwned(id: string, userId: string) {    const todo = await todoRepo.findOwned(id, userId);    if (!todo) throw new AppError(404, "Not found"); // không tồn tại HOẶC của người khác: cùng 404    return todo;  },};// controller: cầu nối HTTPexport const getTodo: RequestHandler = async (req, res) => {  const todo = await todoService.getOwned(req.params.id, req.user!.id);  res.json(todo);};// route: chỉ khai báorouter.get("/:id", auth, getTodo);

Pitfall. Đừng "over-engineer" ngay từ đầu (KISS/YAGNI): với Todo API nhỏ, controller → service là đủ; repository chỉ cần khi query DB phức tạp/nhiều nơi. Nhưng tuyệt đối không để service import express hay đụng res — mất khả năng test và tái dùng.


8. Auth JWT middleware#

Định nghĩa. JWT (JSON Web Token) là token tự chứa (self-contained), ký bằng secret. Access token chứng minh danh tính người dùng trong mỗi request. Middleware auth verify token, giải mã payload, gắn req.user để các handler sau dùng.

Tại sao quan trọng. HTTP stateless — mỗi request độc lập, server không nhớ ai là ai. JWT giải bài toán đó: client gửi kèm token ở header Authorization: Bearer <token>; server verify chữ ký (không cần query DB session) → biết user id.

Cơ chế. Flow cơ bản: login → server ký JWT (jwt.sign(payload, secret)) → client lưu và gửi lại ở mỗi request → middleware jwt.verify(token, secret):

  • Chữ ký sai / token hết hạn → throw → trả 401.
  • Hợp lệ → gán req.user = payload, next().
typescriptReady
import jwt from "jsonwebtoken";// mở rộng type của req (TS)declare global {  namespace Express {    interface Request { user?: { id: string; email: string }; }  }}export const auth: RequestHandler = (req, res, next) => {  const header = req.headers.authorization; // "Bearer xxx"  const token = header?.startsWith("Bearer ") ? header.slice(7) : null;  if (!token) return res.status(401).json({ error: "No token" });  try {    // luôn pin `algorithms`: chỉ chấp nhận đúng thuật toán bạn dùng để ký    const payload = jwt.verify(token, process.env.JWT_SECRET!, { algorithms: ["HS256"] });    if (typeof payload === "string" || !payload.sub) throw new Error("bad payload");    req.user = { id: payload.sub, email: String(payload.email) }; // id nằm ở `sub`    next();  } catch {    return res.status(401).json({ error: "Invalid or expired token" }); // 401  }};// protect route:router.post("/todos", auth, validate(createTodoSchema), createTodo);

Pitfall.

  • Không pin algorithms trong jwt.verify là lỗi hay gặp: server chấp nhận token do chính bên gửi chọn thuật toán. Với secret dạng chuỗi, jsonwebtoken 9 vẫn nhận HS256/HS384/HS512; token ký HS512 bằng đúng secret đi qua nếu không pin (đã chạy: thiếu algorithms thì test "token HS512" ra 200 thay vì 401; riêng alg: none thì jsonwebtoken 9 đã từ chối khi có secret). Khi chuyển sang khoá bất đối xứng (RS256/ES256), pin sai kiểu là đường vào của tấn công nhầm kiểu khoá.
  • Đừng nhét dữ liệu nhạy cảm (password) vào JWT payload — nó chỉ encode base64, ai cũng đọc được, chỉ không sửa được nhờ chữ ký.
  • Access token nên hết hạn ngắn (expiresIn: "15m"); dùng refresh token cho phiên dài (nâng cao, GĐ sau).
  • Phân biệt 401 (chưa xác thực / token sai) vs 403 (đã xác thực nhưng không đủ quyền, vd thiếu role) — hai tầng khác nhau: auth gác 401, kiểm quyền gác 403. Với tài nguyên của người khác (todo của user khác) trả 404, không phải 403: 403 xác nhận "id này có thật" và cho kẻ dò id biết nên thử tiếp. Quy ước xuyên suốt lộ trình, lập luận ở GĐ12 mục 7.

9. Phân trang (pagination)#

Định nghĩa. Kỹ thuật trả một phần danh sách thay vì toàn bộ. Kiểu phổ biến nhất: offset/limit — limit (số item mỗi trang) + offset/page (bỏ qua bao nhiêu).

Tại sao quan trọng. SELECT * FROM todos với 1 triệu dòng sẽ giết cả DB và response. Pagination bảo vệ server và cho client tải dần. Không thể có API list production nào mà thiếu nó.

Cơ chế. offset = (page - 1) * limit. Query LIMIT limit OFFSET offset. Kèm một query COUNT(*) để trả metadata (total, totalPages) giúp client render UI phân trang.

typescriptReady
const querySchema = z.object({  page: z.coerce.number().int().min(1).default(1),  limit: z.coerce.number().int().min(1).max(100).default(20), // chặn limit lố});// repository: sort phải ổn định (thêm id làm tie-breaker), không thì trang chồng/lặpexport const todoRepo = {  list: ({ userId, limit, offset }: { userId: string; limit: number; offset: number }) =>    db.todo.findMany({      where: { userId },      orderBy: [{ createdAt: "desc" }, { id: "asc" }],      skip: offset,      take: limit,    }),  count: (userId: string) => db.todo.count({ where: { userId } }),};export const listTodos: RequestHandler = async (req, res) => {  const { page, limit } = querySchema.parse(req.query);  const offset = (page - 1) * limit;  const [items, total] = await Promise.all([    todoRepo.list({ userId: req.user!.id, limit, offset }),    todoRepo.count(req.user!.id),  ]);  res.json({    data: items,    meta: { page, limit, total, totalPages: Math.ceil(total / limit) },  });};

Pitfall.

  • Thiếu orderBy hoặc sort không duy nhất (hai todo cùng createdAt): DB được phép trả thứ tự khác nhau giữa hai lần gọi, nên một item có thể xuất hiện ở hai trang hoặc biến mất. Luôn sort theo cột ổn định kèm id làm tie-breaker (khung lời giải Dự án 1 làm vậy).
  • Luôn clamp limit (.max(100)) — client gửi limit=999999 = DoS.
  • Offset lớn (page 5000) chậm dần vì DB vẫn phải quét qua. Với dataset khổng lồ dùng cursor pagination (WHERE id > lastId) — nâng cao.
  • Nhớ đính kèm điều kiện userId khi count, nếu không total sai (đếm cả của người khác).

10. Config & secrets#

Định nghĩa. Config là các giá trị thay đổi theo môi trường (port, DB URL, JWT secret). Secrets là config nhạy cảm. dotenv nạp file .env vào process.env.

Tại sao quan trọng. Hardcode secret trong code = rò rỉ khi push GitHub = thảm họa bảo mật. Tách config theo môi trường (dev/staging/prod) là chuẩn 12-factor. Việc validate env lúc boot giúp app fail fast: thiếu JWT_SECRET thì crash ngay khi khởi động, không phải lúc request đầu tiên vào production.

Cơ chế. Nạp .env → validate bằng Zod → export object env đã typed. App chỉ import từ module này, không đọc process.env rải rác.

typescriptReady
// config/env.tsimport "dotenv/config";import { z } from "zod";const schema = z.object({  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),  PORT: z.coerce.number().default(3000),  DATABASE_URL: z.url(),  JWT_SECRET: z.string().min(32), // ép secret đủ mạnh});const parsed = schema.safeParse(process.env);if (!parsed.success) {  console.error("Invalid env:", z.flattenError(parsed.error).fieldErrors);  process.exit(1); // fail fast ngay lúc boot}export const env = parsed.data; // typed, an toàn dùng khắp app

Pitfall.

  • Commit .env là lỗi chết người — luôn .gitignore nó, cung cấp .env.example (không giá trị thật) để đồng đội biết cần key gì.
  • Đọc process.env.X trực tiếp trong code → mất type + mất validate. Luôn qua env.
  • Trên Railway/Render, không upload .env; nhập biến qua dashboard của platform.

11. Logging request cơ bản#

Định nghĩa. Ghi log mỗi HTTP request (method, path, status, thời gian xử lý). morgan (đơn giản) hoặc pino-http (JSON có cấu trúc, nhanh) là middleware phổ biến.

Tại sao quan trọng. Production không có DevTools Network tab. Khi user báo "API lỗi lúc 3h chiều", log là bằng chứng duy nhất để điều tra: request nào, status gì, mất bao lâu. console.log rải rác không đủ — cần format nhất quán, có timestamp, có request id.

Cơ chế. Đăng ký như global middleware, đặt sớm trong stack để bắt mọi request. Ở prod dùng structured JSON (dễ đẩy vào Datadog/Grafana); ở dev dùng format người-đọc-được.

typescriptReady
import morgan from "morgan";// dev: gọn, có màu; prod: 'combined' (chuẩn Apache, đầy đủ)app.use(morgan(env.NODE_ENV === "production" ? "combined" : "dev"));// hoặc pino-http (JSON, production-grade):// import pinoHttp from "pino-http";// app.use(pinoHttp());

Pitfall.

  • Đừng log body chứa secret (password, token) — log cũng là nơi rò rỉ dữ liệu. Redact các field nhạy cảm.
  • Đặt logger sau body parser nếu muốn log body, nhưng trước route để đo đúng thời gian. Với error, để error handler log riêng (mức error).

12. Header bảo mật với helmet#

Định nghĩa. helmet là middleware đặt một bộ header HTTP phòng thủ cho mọi response (X-Content-Type-Options: nosniff, Strict-Transport-Security, Content-Security-Policy, Referrer-Policy...) và bỏ header X-Powered-By để không khoe "Express".

Tại sao quan trọng. Đây là lớp rẻ nhất trong "bảo mật cấu hình" (OWASP A02): một dòng code, chặn được cả một họ lỗi (trình duyệt đoán sai kiểu nội dung, nhúng trang vào iframe lạ, hạ cấp xuống HTTP). Nó không thay thế auth, CORS hay rate limit, chỉ bổ sung. Chi tiết các lớp còn lại ở GĐ09.

Cơ chế. app.use(helmet()) gọi từng middleware con với mặc định an toàn. Đăng ký đầu tiên, trước log và parser, để cả response lỗi và 404 cũng có header.

typescriptReady
import helmet from "helmet";app.use(helmet());                // đặt trước morgan / express.json// API JSON thuần: mặc định là đủ. Chỉ chỉnh khi có lý do cụ thể, ví dụ:// app.use(helmet({ contentSecurityPolicy: false })); // nếu server cũng phục vụ HTML có script riêng

Kết quả (đã chạy, helmet 8.3.0, curl -i localhost:3000/health/live trên server thật): response có Content-Security-Policy, Strict-Transport-Security: max-age=31536000; includeSubDomains, X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy: no-referrer, và không có X-Powered-By.

Pitfall.

  • Strict-Transport-Security chỉ có tác dụng khi trình duyệt truy cập qua HTTPS. Sau reverse proxy hoặc CDN đã tự thêm HSTS thì đừng cấu hình hai nơi lệch nhau.
  • helmet không bật CORS và không chặn request nào: curl vẫn gọi được như thường. CORS cấu hình riêng (GĐ03 mục 4).
  • Tắt contentSecurityPolicy cho cả app chỉ vì "bị chặn script" là sửa sai chỗ: chỉ nới đúng nguồn cần dùng.
  • X-XSS-Protection: 0 là cố ý (bộ lọc XSS cũ của trình duyệt từng gây lỗ hổng); đừng "sửa" thành 1.

Dự án 1 — Todo/Notes API#

Mục tiêu: ráp toàn bộ 11 khái niệm trên thành một REST API chạy được và deploy lên internet.

Yêu cầu chức năng.

  • Auth JWT: POST /auth/register, POST /auth/login → trả access token. Middleware auth bảo vệ mọi route /todos.
  • CRUD todos (scoped theo user — chỉ thấy/sửa todo của mình):
    • POST /todos — tạo (validate title).
    • GET /todos — list + pagination (?page=&limit=) + meta.
    • GET /todos/:id — chi tiết (404 nếu không có hoặc của người khác — không lộ id nào có thật). id là UUID dạng chuỗi (crypto.randomUUID() nếu lưu in-memory; @default(uuid()) ở GĐ05), không phải số tự tăng.
    • PATCH /todos/:id — cập nhật (title/done).
    • DELETE /todos/:id — xóa.
  • Validation: mọi body/query qua Zod middleware → 422 khi sai.
  • Error handling tập trung: AppError + global handler (Express 5 tự chuyển lỗi async; xử lý cả ZodError và JSON hỏng).
  • Deploy: Railway hoặc Render (Postgres managed + env qua dashboard).

Cấu trúc gợi ý.

textReady
src/  config/env.ts          # validate env lúc boot  middlewares/    auth.ts              # JWT verify -> req.user    validate.ts          # Zod middleware    error-handler.ts     # AppError + global handler  modules/    auth/  (route, controller, service)    todos/ (route, controller, service, repository, schema)  utils/async-handler.ts # chỉ cần nếu dùng Express 4  app.ts                 # ráp middleware + mount router  server.ts              # http listen

Thứ tự ráp middleware trong app.ts (quan trọng):

  1. helmet (header bảo mật) → 2. morgan (log) → 3. express.json() → 4. mount routers →
  2. 404 handler → 6. errorHandler (CUỐI cùng).
Lời giải và cách kiểm tra: Dự án 1 — Todo/Notes API

Đã chạy: khung dưới đây (lưu in-memory) chạy với Node 24.21, Express 5.2.1, Zod 4.6, jsonwebtoken 9, helmet 8.3, bcryptjs 3, tsc --strict không lỗi; mọi kết quả ở "Kết quả" (kể cả dòng morgan không log password) là quan sát thật. Sáu test supertest ở cuối lời giải chạy xanh (node --import tsx --test). Phần deploy chưa chạy (cần tài khoản Railway/Render).

Hướng làm. (1) env.ts validate biến môi trường, thoát ngay nếu sai. (2) Dựng app.ts đúng thứ tự: log, express.json, router, 404, errorHandler. (3) Làm module auth (register hash bằng bcryptjs, login ký JWT 15 phút), rồi module todos có userId ở mọi truy vấn. (4) Chạy kịch bản curl ở dưới đối chiếu từng dòng Done khi. (5) Khi sang GĐ05, chỉ thay phần lưu trữ trong *.service.ts; route và middleware giữ nguyên.

Sơ đồ.

textReady
client --HTTP--> app.ts (log -> json -> router -> 404 -> errorHandler)                    |        auth.route / todo.route   (validate(zod) -> auth -> controller)                    |          todo.service  (AppError 404/409, không biết req/res)                    |          store (Map ở GĐ04; Prisma ở GĐ05)

Code tham chiếu (rút gọn; thư mục như "Cấu trúc gợi ý" ở trên, import dùng đuôi .js theo NodeNext của GĐ02):

typescriptReady
// src/modules/todos/todo.service.ts: nơi duy nhất quyết định "không thấy" = 404export const todoService = {  getOwned(id: string, userId: string): Todo {    const todo = todos.get(id);    if (!todo || todo.userId !== userId) throw new AppError(404, "Not found"); // của người khác = không tồn tại    return todo;  },  list(userId: string, page: number, limit: number) {    const mine = [...todos.values()].filter((t) => t.userId === userId); // total cũng chỉ đếm của mình    return {      data: mine.slice((page - 1) * limit, page * limit),      meta: { page, limit, total: mine.length, totalPages: Math.ceil(mine.length / limit) },    };  },};
typescriptReady
// src/modules/todos/todo.route.ts: Express 5, handler đồng bộ/async đều không cần wrapperexport const todoRouter = Router();todoRouter.use(auth); // mọi route /todos cần JWT -> 401todoRouter.post("/", validate(createTodoSchema), (req, res) => {  res.status(201).json(todoService.create(req.user!.id, req.body));});todoRouter.get("/", (req, res) => {  const { page, limit } = listQuerySchema.parse(req.query); // ZodError -> errorHandler -> 422  res.json(todoService.list(req.user!.id, page, limit));});todoRouter.get("/:id", (req, res) => res.json(todoService.getOwned(req.params.id, req.user!.id)));
typescriptReady
// src/modules/auth/auth.service.ts: register + login (lưu in-memory; GĐ05 đổi sang bảng User)const DUMMY_HASH = bcrypt.hashSync("khong-co-nguoi-dung", 10); // so sanh gia khi email laconst users = new Map<string, User>();        // id -> { id, email, passwordHash }const byEmail = new Map<string, string>();    // email -> idexport const authService = {  async register(email: string, password: string) {    email = email.toLowerCase();    if (byEmail.has(email)) throw new AppError(409, "Email already registered");    const user = { id: randomUUID(), email, passwordHash: await bcrypt.hash(password, 10) };    users.set(user.id, user);    byEmail.set(email, user.id);    return { id: user.id, email };            // không bao giờ trả passwordHash  },  async login(email: string, password: string) {    const user = users.get(byEmail.get(email.toLowerCase()) ?? "");    // luôn gọi compare để thời gian phản hồi không lộ email nào có tài khoản    const ok = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH);    if (!user || !ok) throw new AppError(401, "Invalid credentials"); // một thông báo cho cả hai trường hợp    const accessToken = jwt.sign({ email: user.email }, env.JWT_SECRET, {      subject: user.id, expiresIn: "15m", algorithm: "HS256",    });    return { accessToken };  },};// src/middlewares/auth.ts: jwt.verify(token, secret, { algorithms: ["HS256"] }) rồi req.user = { id: payload.sub, email }
typescriptReady
// src/modules/auth/auth.route.ts: cùng một schema cho register và loginconst credentials = z.object({ email: z.email(), password: z.string().min(8).max(72) });authRouter.post("/register", validate(credentials), async (req, res) => {  res.status(201).json(await authService.register(req.body.email, req.body.password));});authRouter.post("/login", validate(credentials), async (req, res) => {  res.json(await authService.login(req.body.email, req.body.password));});
typescriptReady
// src/modules/todos/todo.service.ts (phần còn thiếu): PATCH và DELETE đều đi qua getOwnedupdate(id: string, userId: string, patch: { title?: string; done?: boolean }): Todo {  return Object.assign(this.getOwned(id, userId), patch);},remove(id: string, userId: string): void {  this.getOwned(id, userId);                  // 404 nếu không có hoặc của người khác  todos.delete(id);},// src/modules/todos/todo.route.tsconst updateTodoSchema = z.object({ title: z.string().min(1).max(200), done: z.boolean() })  .partial().refine((v) => Object.keys(v).length > 0, { message: "Cần ít nhất một field" });todoRouter.patch("/:id", validate(updateTodoSchema), (req, res) => {  res.json(todoService.update(String(req.params.id), req.user!.id, req.body));});todoRouter.delete("/:id", (req, res) => {  todoService.remove(String(req.params.id), req.user!.id);  res.sendStatus(204);});
typescriptReady
// src/app.ts: đúng thứ tự; export hàm để test không phải listenexport function createApp() {  const app = express();  app.use(helmet());  if (env.NODE_ENV !== "test") app.use(morgan(env.NODE_ENV === "production" ? "combined" : "dev"));  app.use(express.json({ limit: "1mb" }));  app.get("/health/live", (_req, res) => res.json({ ok: true }));  app.use("/auth", authRouter);  app.use("/todos", todoRouter);  app.use((_req, res) => res.status(404).json({ error: "Not found" })); // 404 cho route không tồn tại  app.use(errorHandler);                                                // CUỐI cùng  return app;}

Test tự động (node:test có sẵn trong Node, cộng supertest; GĐ13 mới giới thiệu Vitest nên ở đây chưa cần). createApp() không listen, supertest tự mở cổng tạm:

typescriptReady
// test/todo-api.test.tsimport { test } from "node:test";import assert from "node:assert/strict";import request from "supertest";import jwt from "jsonwebtoken";import { createApp } from "../src/app.js";const app = createApp();const creds = (email: string) => ({ email, password: "password123" });async function login(email: string) {  await request(app).post("/auth/register").send(creds(email)).expect(201);  const res = await request(app).post("/auth/login").send(creds(email)).expect(200);  return `Bearer ${res.body.accessToken}`;}test("todo của người khác giống hệt todo không tồn tại: 404", async () => {  const alice = await login("alice@example.com");  const bob = await login("bob@example.com");  const t = await request(app).post("/todos").set("authorization", alice).send({ title: "x" }).expect(201);  const theirs = await request(app).get(`/todos/${t.body.id}`).set("authorization", bob).expect(404);  const missing = await request(app).get("/todos/00000000-0000-4000-8000-000000000000")    .set("authorization", bob).expect(404);  assert.deepEqual(theirs.body, missing.body);});test("đúng secret nhưng khác thuật toán (HS512) bị từ chối", async () => {  const hs512 = jwt.sign({ email: "x@y.z" }, process.env.JWT_SECRET!, { subject: "x", algorithm: "HS512" });  await request(app).get("/todos").set("authorization", `Bearer ${hs512}`).expect(401);});test("không token 401, JSON hỏng 400, query sai 422, limit quá lớn 422", async () => {  await request(app).get("/todos").expect(401);  const token = await login("a@example.com");  await request(app).post("/todos").set("authorization", token)    .set("content-type", "application/json").send("{bad").expect(400);  await request(app).get("/todos?page=abc").set("authorization", token).expect(422);  await request(app).get("/todos?limit=999").set("authorization", token).expect(422);});

Chạy: JWT_SECRET=<32+ ký tự> NODE_ENV=test node --import tsx --test test/todo-api.test.ts. Bản đầy đủ đã chạy gồm thêm ca đăng ký trùng email (409), sai mật khẩu và email lạ ra cùng một thân 401, PATCH/DELETE/phân trang, 404 cho route lạ và kiểm header helmet: 6 test, 0 lỗi. Gỡ algorithms: ["HS256"] khỏi jwt.verify thì test HS512 fail (token đi qua với 200 thay vì 401), nên test này bảo vệ đúng điều nó nói.

Ghi chú: id là crypto.randomUUID(); schema đăng nhập dùng z.email() (Zod 4, z.string().email() đã deprecated); mật khẩu giới hạn max(72) vì bcrypt chỉ dùng 72 byte đầu.

Kết quả (.env có PORT=4420 và JWT_SECRET dài 32+ ký tự; chạy npx tsx src/server.ts, rồi curl localhost:4420):

LệnhKết quả quan sát
GET /todos không token401
POST /todos với {"title":""}422 {"error":"ValidationError","details":{"title":[...]}}
POST /todos không body, không Content-Type422 (req.body là undefined, ?? {} cứu)
GET /todos?page=abc422, details.page
GET /todos?limit=999422, details.limit: Too big (quy định giới hạn bằng từ chối, không lẳng lặng cắt xuống 100)
POST /todos với -d '{bad'400 Malformed JSON body
POST /todos body JSON 2 MB413 request entity too large
User B GET /todos/<id của A>404 {"error":"Not found"}, byte-for-byte giống GET /todos/<uuid lạ>
Token sai chữ ký, và token đã hết hạnđều 401 Invalid or expired token
POST /todos hợp lệ201, id dạng UUID; GET /todos?page=1&limit=10 có meta { page, limit, total, totalPages }
Lỗi bất ngờ (throw new TypeError("...password=xyz") trong handler async)500 {"error":"Internal Server Error"}; chi tiết chỉ ở log server
Thiếu hoặc ngắn JWT_SECRETprocess in Invalid env: {...} rồi exit=1

morgan("dev") chỉ log method, path, status, thời gian: grep password trong log ra 0 dòng.

Lỗi hay gặp. Đặt errorHandler trước router (không bao giờ chạy). Quên đuôi .js trong import khi dùng NodeNext. Dùng req.params.id như string trong TypeScript qua chuỗi middleware có thể báo string | string[] với @types/express 5: bọc String(req.params.id). Trả 403 cho todo của người khác (lộ id có thật). Đăng ký trùng email mà không trả 409, hoặc login trả thông báo khác nhau cho "email lạ" và "sai mật khẩu" (cho kẻ dò biết email nào có tài khoản). jwt.verify không pin algorithms. list quên lọc userId khi đếm: total đếm cả của người khác. Deploy: nhập JWT_SECRET qua dashboard của nền tảng, không đẩy .env; kiểm bằng curl https://<app>/health/live.

Done khi#

  • express.json() đăng ký trước route; POST đọc được req.body.

    Đáp án

    Thứ tự đăng ký là thứ tự chạy; parser đăng ký sau route thì route đó thấy req.body === undefined. Tự kiểm: curl -X POST -H 'content-type: application/json' -d '{"a":1}' tới route echo ra {a:1}; không có header Content-Type thì undefined. Sai thường gặp: quên rằng Express 5 không còn gán {} mặc định. Xem GĐ04 mục 4.

  • Mọi input (body + query) validate bằng Zod; sai → 422 kèm chi tiết field.

    Đáp án

    Dùng safeParse trong middleware validate (body) và schema.parse(req.query) (ném ZodError, handler cuối map sang 422). Kết quả mong đợi: {"error":"ValidationError","details":{"title":[...]}}. Dùng z.coerce.number() cho query vì mọi giá trị query là string. Xem GĐ04 mục 5.

  • Async route throw ở service → global handler bắt (Express 5 không cần wrapper; Express 4 thì bọc asyncHandler).

    Đáp án

    Express 5 tự gọi next(err) khi promise reject, nên không cần wrapper. Tự kiểm: route async () => { throw new AppError(409, "x") } trả 409, route ném TypeError trả 500 (không treo). Sai thường gặp: vẫn bọc asyncHandler trên Express 5 (vô hại nhưng thừa), hoặc quên bọc trên Express 4 (request treo). Xem GĐ04 mục 6.

  • GET /todos?page=abc → 422 (không phải 500); POST body JSON hỏng → 400; body vượt limit → 413. Kiểm bằng curl -i.

    Đáp án

    curl -i "localhost:4420/todos?page=abc" ra 422; -d '{bad' ra 400 Malformed JSON body (nhánh err.type === "entity.parse.failed"); body trên limit ra 413 (nhánh err.status 4xx). Thiếu hai nhánh cuối thì cả hai rơi xuống 500. Xem GĐ04 mục 6.

  • AppError phân biệt operational (lộ message) vs 500 (giấu chi tiết, log).

    Đáp án

    isOperational = true (404, 409...) thì lộ message; mọi thứ khác trả đúng Internal Server Error và chi tiết chỉ vào log. Tự kiểm: ném new TypeError("password=xyz"); response không chứa xyz, log server có. Xem GĐ04 mục 6.

  • /todos chỉ truy cập được khi có JWT hợp lệ; sai/hết hạn → 401; truy cập todo người khác → 404 (giống hệt todo không tồn tại).

    Đáp án

    Không token, token sai chữ ký, token hết hạn đều 401. Todo của user A đọc bằng token B ra 404 {"error":"Not found"}, giống từng byte với id không tồn tại: truy vấn luôn lọc userId rồi mới quyết định. Sai thường gặp: trả 403 (xác nhận id có thật). Xem GĐ04 mục 8 và GĐ12 mục 7.

  • GET /todos phân trang, trả data + meta { page, limit, total, totalPages }; limit bị clamp .max(100).

    Đáp án

    offset = (page - 1) * limit, hai truy vấn (danh sách và đếm) cùng điều kiện userId, trả meta { page, limit, total, totalPages }. limit=999 bị schema .max(100) từ chối với 422 (clamp bằng từ chối), không phải âm thầm cắt xuống. Sai thường gặp: đếm không lọc userId nên total sai. Xem GĐ04 mục 9.

  • Không hardcode secret; .env trong .gitignore; env validate lúc boot, thiếu key → crash ngay.

    Đáp án

    grep -rn JWT_SECRET src chỉ thấy config/env.ts và nơi dùng env.JWT_SECRET, không có chuỗi secret. git check-ignore .env in ra .env. Xoá JWT_SECRET khỏi môi trường rồi chạy server: phải thấy Invalid env: {...} và exit code 1 ngay lúc khởi động. Xem GĐ04 mục 10.

  • Layer tách bạch: service không import express, không đụng req/res.

    Đáp án

    grep -rn "from \"express\"" src/modules/*/*.service.ts ra rỗng (đã chạy: "no express in service"); service nhận tham số thuần (userId, page), ném AppError, không đụng req/res. Nhờ vậy test service không cần dựng HTTP. Xem GĐ04 mục 7.

  • Request logging bật; không log field nhạy cảm.

    Đáp án

    morgan chỉ ghi method, path, status, thời gian, không ghi body. Tự kiểm: gửi POST /auth/register với password, rồi grep password trong log phải ra 0 dòng. Nếu chuyển sang pino-http, đặt redact cho req.headers.authorization. Xem GĐ04 mục 11.

  • Deploy Railway/Render chạy được; test bằng URL public (Postman/curl).

    Đáp án

    Chưa chạy (cần tài khoản nền tảng). Cách tự kiểm: biến môi trường nhập qua dashboard, rồi curl -i https://<app>/health/live ra 200, curl -i https://<app>/todos ra 401. Nền tảng thường truyền PORT qua biến môi trường nên env.PORT phải được đọc, không hardcode.

  • Đăng ký trùng email → 409; login sai mật khẩu và login email lạ trả cùng một thân 401; passwordHash không nằm trong response nào.

    Đáp án

    Đã chạy trong bộ test của lời giải: register lần hai cùng email ra 409; hai lần login lỗi (sai mật khẩu, email không tồn tại) cho deepEqual thân {"error":"Invalid credentials"}; register chỉ trả { id, email }. Lý do: thông báo khác nhau cho phép kẻ tấn công liệt kê email đã đăng ký. Mật khẩu băm bằng bcryptjs (giới hạn max(72) vì bcrypt chỉ dùng 72 byte đầu). Xem GĐ04 mục 8 và hashing ở GĐ09.

  • Có ít nhất 3 test supertest xanh (todo của người khác → 404, token sai thuật toán → 401, JSON hỏng/query sai → 400/422); jwt.verify pin algorithms; helmet đăng ký đầu tiên.

    Đáp án

    JWT_SECRET=<32+ ký tự> NODE_ENV=test node --import tsx --test test/*.test.ts phải báo fail 0. Kiểm test có giá trị: tạm gỡ { algorithms: ["HS256"] } khỏi jwt.verify, test token HS512 phải đỏ (đã chạy: đỏ), trả lại thì xanh. curl -i localhost:3000/health/live thấy X-Content-Type-Options: nosniff và không có X-Powered-By. Xem GĐ04 mục 12 và lời giải Dự án 1.


Câu hỏi mở#

  • Chọn ORM nào (Prisma vs Drizzle vs knex thô) cho repository layer? — ảnh hưởng cách viết repo ở GĐ sau.

    Hướng trả lời hiện tại

    (Chưa khẳng định.) lộ trình dùng Prisma ở GĐ05, nên Dự án 1 giữ repository mỏng và chỉ đổi phần lưu trữ. Drizzle hay Kysely hợp lý nếu bạn muốn SQL gần hơn; chưa có số đo trong tài liệu này để so sánh.

  • Refresh token / logout / token revoke: để GĐ nâng cao hay đưa vào ngay Dự án 1?

    Hướng trả lời hiện tại

    (Chưa khẳng định.) để sau. Dự án 1 chỉ cần access token hết hạn ngắn; refresh token kéo theo bảng lưu phiên và xoay vòng token, gấp đôi phạm vi. Xem phần auth ở GĐ07.

  • DB thật (Postgres) hay in-memory array để tập trung học Express trước? — nếu mục tiêu là "cơ chế Express", có thể bắt đầu bằng array rồi thay bằng Postgres sau.

    Hướng trả lời hiện tại

    (Chưa khẳng định.) bắt đầu bằng Map in-memory (khung trong lời giải làm đúng như vậy), vì mọi Done khi ở GĐ04 kiểm được mà không cần DB; chuyển sang Postgres ở GĐ05 mà route không đổi.