Skip to content

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

GĐ3 — 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 cái API đó — bạn nhận request thô, tự parse, tự validate, tự quyết định response và status code.


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ự.
ts
import express from "express";
import http from "node:http";

const app = express();
app.get("/health", (_req, res) => res.json({ ok: true }));

// 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 phải lấy được server = http.createServer(app) để share cùng cổng — nếu chỉ dùng app.listen() bạn sẽ không có tham chiếu server.


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ồ.
ts
// routes/todos.route.ts
import { 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.ts
import todosRouter from "./routes/todos.route";
app.use("/todos", todosRouter); // GET /todos/:id

Pitfall.

  • req.params.idreq.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 đó.
ts
// global logger
app.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 }));

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.bodyundefined.

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.

ts
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 đó:

ts
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);
  }
);

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.

ts
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ụng
const validate =
  (schema: z.ZodTypeAny): RequestHandler =>
  (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(422).json({
        error: "ValidationError",
        details: result.error.flatten().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).
ts
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. Async wrapper — Express không tự bắt lỗi từ async handler. Nếu một async route throw (hoặc promise reject), Express không thấy → request treo / crash. Phải bọc để chuyển reject thành next(err):
ts
import type { RequestHandler } from "express";
export 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):
ts
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 });
  }
  console.error("UNEXPECTED:", err); // bug -> log, KHÔNG lộ chi tiết
  res.status(500).json({ error: "Internal Server Error" });
};

// dùng:
app.get("/todos/:id", asyncHandler(async (req, res) => {
  const todo = await service.findById(req.params.id);
  if (!todo) throw new AppError(404, "Todo not found");
  res.json(todo);
}));
app.use(errorHandler); // PHẢI đăng ký sau cùng

Pitfall.

  • Quên asyncHandler là bug #1 với async route: throw biến mất, request treo.
  • 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.

ts
// repository: chỉ DB
export const todoRepo = {
  findById: (id: string) => db.todo.findUnique({ where: { id } }),
  create: (data: CreateTodo & { userId: string }) => db.todo.create({ data }),
};

// service: business rule, không biết req/res
export const todoService = {
  async getOwned(id: string, userId: string) {
    const todo = await todoRepo.findById(id);
    if (!todo) throw new AppError(404, "Not found");
    if (todo.userId !== userId) throw new AppError(403, "Forbidden");
    return todo;
  },
};

// controller: cầu nối HTTP
export const getTodo = asyncHandler(async (req, res) => {
  const todo = await todoService.getOwned(req.params.id, req.user!.id);
  res.json(todo);
});

// route: chỉ khai báo
router.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().
ts
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 {
    req.user = jwt.verify(token, process.env.JWT_SECRET!) as any;
    next();
  } catch {
    return res.status(401).json({ error: "Invalid or expired token" }); // 401
  }
};

// protect route:
router.post("/todos", auth, validate(createTodoSchema), createTodo);

Pitfall.

  • Đừ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) — hai tầng khác nhau: auth gác 401, service gác 403 (ownership).

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/limitlimit (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.

ts
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ố
});

export const listTodos = asyncHandler(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.

  • 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.

ts
// config/env.ts
import "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.string().url(),
  JWT_SECRET: z.string().min(32), // ép secret đủ mạnh
});

const parsed = schema.safeParse(process.env);
if (!parsed.success) {
  console.error("❌ Invalid env:", parsed.error.flatten().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.

ts
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).

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ó, 403 nếu của người khác).
    • 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 + asyncHandler + global handler.
  • Deploy: Railway hoặc Render (Postgres managed + env qua dashboard).

Cấu trúc gợi ý.

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
  app.ts                 # ráp middleware + mount router
  server.ts              # http listen

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

  1. morgan (log) → 2. express.json() → 3. mount routers →
  2. 404 handler → 5. errorHandler (CUỐI cùng).

Done khi

  • [ ] express.json() đăng ký trước route; POST đọc được req.body.
  • [ ] Mọi input (body + query) validate bằng Zod; sai → 422 kèm chi tiết field.
  • [ ] Async route bọc asyncHandler; throw ở service → được global handler bắt.
  • [ ] AppError phân biệt operational (lộ message) vs 500 (giấu chi tiết, log).
  • [ ] /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 → 403.
  • [ ] GET /todos phân trang, trả data + meta { page, limit, total, totalPages }; limit bị clamp .max(100).
  • [ ] Không hardcode secret; .env trong .gitignore; env validate lúc boot, thiếu key → crash ngay.
  • [ ] Layer tách bạch: service không import express, không đụng req/res.
  • [ ] Request logging bật; không log field nhạy cảm.
  • [ ] Deploy Railway/Render chạy được; test bằng URL public (Postman/curl).

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.
  • Refresh token / logout / token revoke: để GĐ nâng cao hay đưa vào ngay Dự án 1?
  • 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ọc bằng cách build. Chứng minh, đừng tin.