jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Node.js Super Senior · Phase 3 — Express.js Mastery

Phase 3: master Express 5 — the request lifecycle, the middleware pipeline, routing and routers, a layered architecture, a typed error strategy with async handling, Zod validation, the production security stack, and graceful shutdown.

This is Phase 3 of the 10-phase Super Senior path {Đây là Phase 3 của lộ trình Super Senior 10 phase}. In Phase 2 you built a raw server and felt every gap {Ở Phase 2 bạn dựng server thô và cảm nhận mọi khoảng trống}. Now Express fills them — and because you know what’s underneath, you’ll master it fast, and you’ll structure it like a senior instead of dumping everything in one file {Giờ Express lấp chúng — và vì bạn biết bên dưới là gì, bạn sẽ thành thạo nhanh, và cấu trúc nó như senior thay vì nhồi tất cả vào một file}.

We use Express 5 (the current production release) {Ta dùng Express 5 (bản production hiện hành)}.


3.1 Express fundamentals {Nền tảng Express}

npm install express@5
npm install -D @types/express
import express from 'express';

const app = express();

app.use(express.json()); // parse JSON bodies → req.body (replaces Phase 2's readJson)

app.get('/users/:id', (req, res) => {
  res.json({ id: req.params.id });
});

app.listen(3000, () => console.log('http://localhost:3000'));

Compare to Phase 2: no createServer, no URL parsing, no manual status/headers {So với Phase 2: không createServer, không parse URL, không tự set status/header}. Express is a thin layer over the http server: a router, a middleware pipeline, and ergonomic req/res helpers (req.params, req.query, res.json, res.status) {Express là tầng mỏng trên http server: một router, một pipeline middleware, và helper tiện dụng}. Under the hood app is still a (req, res) callback you could pass to http.createServer(app) — useful when you need the raw server (for WebSockets or graceful shutdown) {Bên dưới app vẫn là callback (req, res) bạn có thể truyền cho http.createServer(app) — hữu ích khi cần server thô (cho WebSocket hay tắt êm)}.

The request lifecycle {Vòng đời request}

incoming request

app-level middleware (helmet, cors, json, logger)   ── runs in order ──┐
   ▼                                                                    │
router match → route-level middleware → route handler                  │ any of these can
   ▼                                                                    │ end the response
res.json() / res.send() ── response sent ◄──────────────────────────── ┘
   ▼ (if next(err) called anywhere)
error-handling middleware (4 args), registered LAST

Hold this picture in your head — every Express bug is “a middleware ran in the wrong order, didn’t call next(), or ended the response twice” {Giữ bức tranh này trong đầu — mọi bug Express là “một middleware chạy sai thứ tự, không gọi next(), hoặc kết thúc response hai lần”}.


3.2 Middleware deep dive {Đào sâu middleware}

A middleware is (req, res, next) => … — exactly the compose/next pattern you built in Phase 2, now first-class {Một middleware là (req, res, next) => … — đúng mẫu compose/next bạn dựng ở Phase 2, giờ là hạng nhất}.

app.use(express.json());                          // JSON bodies
app.use(express.urlencoded({ extended: true }));  // HTML form posts
app.use(express.static('public'));                // serve files from ./public

// Custom middleware — runs on every request, in registration order
app.use((req, res, next) => {
  const start = performance.now();
  res.on('finish', () => {
    console.log(`${req.method} ${req.path} ${res.statusCode} ${(performance.now() - start).toFixed(1)}ms`);
  });
  next(); // forget this → the request hangs forever
});

Five things to internalize {Năm điều cần thấm}:

  • Order matters — middleware runs top-to-bottom; register body parsers and auth before the routes that need them {Thứ tự quan trọng — chạy trên xuống; đăng ký body parser và auth trước route cần chúng}.
  • Scopeapp.use(fn) runs globally; app.use('/admin', fn) runs for a path prefix; app.get('/x', mw, handler) runs mw only for that route {Phạm vi — toàn cục / theo tiền tố path / theo route}.
  • Short-circuit — a middleware can end the request (res.status(401).json(...) then return) without calling next() {Cắt mạch — có thể kết thúc request mà không gọi next()}.
  • next('route') skips the rest of the current route’s stack; next(err) jumps straight to the error handler {next('route') bỏ phần còn lại của stack route; next(err) nhảy thẳng tới error handler}.
  • Attach derived data to req (e.g. req.user), not new globals — it’s request-scoped and dies with the request {Gắn dữ liệu suy ra vào req, không tạo global mới — nó theo request và chết cùng request}.

Typing custom request props: augment Express’s Request rather than casting {Gõ type cho prop tùy biến: mở rộng Request thay vì cast}:

// types/express.d.ts
declare global {
  namespace Express {
    interface Request { user?: { id: string; role: string }; requestId?: string }
  }
}
export {};

3.3 Routing patterns {Mẫu routing}

app.get('/users', listUsers);              // collection
app.post('/users', createUser);
app.get('/users/:id', getUser);            // req.params.id
app.put('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);

// Chain handlers for one path with app.route to avoid repeating it
app.route('/posts')
  .get(listPosts)
  .post(createPost);

req.params (path), req.query (after ?), req.body (parsed body), req.headers — know which is which {biết rõ cái nào là cái nào}. Query values are always strings; coerce and validate them yourself {Giá trị query luôn là chuỗi; tự ép kiểu và validate}.

Routers — organize a growing app {Router — tổ chức app đang lớn}

express.Router() is a mini-app: it groups related routes and their own middleware into a module {express.Router() là một mini-app: gom route liên quan và middleware riêng của chúng vào module}:

// routes/users.ts
import { Router } from 'express';
const router = Router();

router.use(requireAuth);          // applies to every route in THIS router only
router.get('/', listUsers);
router.post('/', createUser);
router.get('/:id', getUser);

export default router;
// app.ts
import usersRouter from './routes/users.js';
app.use('/api/users', usersRouter); // every route is prefixed with /api/users

Express 5 routing changes {Thay đổi routing Express 5}: optional segments use {} (/users/:id{/:action}), wildcards must be named (/files/*splatreq.params.splat), and inline regex strings in paths were removed (ReDoS hardening). If a route copied from an old tutorial misbehaves, this is why {đoạn tùy chọn dùng {}, wildcard phải có tên, chuỗi regex nội tuyến bị bỏ. Nếu route copy từ tutorial cũ chạy sai, đây là lý do}.


3.4 Layered architecture — don’t fat your controllers {Kiến trúc phân tầng — đừng để controller phình}

The senior shape: route → controller → service → repository {Hình dạng senior: route → controller → service → repository}. Controllers only translate HTTP to/from the domain; services hold business logic; repositories hold data access {Controller chỉ dịch HTTP qua lại domain; service giữ business logic; repository giữ truy cập dữ liệu}.

// controller — thin: parse input, call service, shape the HTTP response
export const getUser: RequestHandler = async (req, res) => {
  const user = await userService.getById(req.params.id); // throws NotFoundError if missing
  res.json(user);
};

// service — business rules, framework-agnostic (no req/res in here)
export const userService = {
  async getById(id: string) {
    const user = await userRepo.findById(id);
    if (!user) throw new NotFoundError('User not found');
    return user;
  },
};

Why bother? The service is testable without HTTP and reusable from a CLI, a queue worker, or another route {Vì sao? Service test được không cần HTTPdùng lại từ CLI, queue worker, hay route khác}. We formalize this in Phase 6 and Phase 10 {Ta chính thức hóa ở Phase 6 và Phase 10}.


3.5 Error-handling strategy {Chiến lược xử lý lỗi}

A senior centralizes errors with a typed error hierarchy and one global handler {Senior tập trung lỗi bằng một cây lỗi có type và một handler toàn cục}:

export class AppError extends Error {
  constructor(message: string, public readonly statusCode: number) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace?.(this, this.constructor); // clean stack, omits this ctor
  }
}
export class NotFoundError extends AppError { constructor(m = 'Not Found') { super(m, 404); } }
export class ValidationError extends AppError { constructor(m = 'Invalid input') { super(m, 422); } }
export class UnauthorizedError extends AppError { constructor(m = 'Unauthorized') { super(m, 401); } }

The error handler is recognized by its four arguments and registered last {Error handler được nhận diện qua bốn tham số và đăng ký cuối cùng}:

import type { Request, Response, NextFunction } from 'express';

// 404 for anything no route matched — registered AFTER all routes, BEFORE the error handler
app.use((req: Request, _res: Response, next: NextFunction) => {
  next(new NotFoundError(`Cannot ${req.method} ${req.path}`));
});

app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
  const isApp = err instanceof AppError;
  const status = isApp ? err.statusCode : 500;
  if (!isApp || status >= 500) console.error(`[${req.requestId}]`, err); // log real causes
  res.status(status).json({
    error: {
      message: isApp ? err.message : 'Internal Server Error',
      status,
      // never leak stack traces in production
      ...(process.env.NODE_ENV !== 'production' && err instanceof Error ? { stack: err.stack } : {}),
    },
  });
});

The senior principle {Nguyên tắc senior}: expected errors are typed and explicit; unexpected errors are logged and hidden behind a generic 500 {lỗi dự kiến thì có type và rõ ràng; lỗi bất ngờ thì log lại và giấu sau 500 chung} — never leak stack traces to clients {đừng để lộ stack trace cho client}.


3.6 Async error handling {Xử lý lỗi async}

Express 5 automatically catches rejected promises in async handlers and forwards them to your error handler — a huge improvement over Express 4, where a rejection silently hung the request {Express 5 tự bắt promise bị reject trong async handler và chuyển tới error handler — cải tiến lớn so với Express 4}:

// Express 5: just throw or let it reject — the framework forwards it.
app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('User not found');
  res.json(user);
});

For explicit intent (and identical behavior across versions/middleware), wrap async handlers with a tiny helper {Để rõ ý định và hành vi đồng nhất, bọc async handler bằng một helper nhỏ}:

import type { Request, Response, NextFunction, RequestHandler } from 'express';

const catchAsync =
  (fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>): RequestHandler =>
  (req, res, next) => { fn(req, res, next).catch(next); };

3.7 Validation {Validation}

Never trust input {Đừng bao giờ tin input}. Two solid approaches {Hai cách vững}.

express-validator for inline route chains {cho chuỗi nội tuyến trên route}:

import { body, validationResult } from 'express-validator';

app.post('/register',
  body('email').isEmail().normalizeEmail(),
  body('password').isLength({ min: 8 }).matches(/^(?=.*[A-Za-z])(?=.*\d)/),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
    next();
  },
  registerHandler,
);

Zod for schema-first validation that also gives you the parsed, typed value — my preference for new code {cho validation schema-first còn cho bạn giá trị đã parse, có type — lựa chọn của tôi cho code mới}:

import { z } from 'zod';
import type { RequestHandler } from 'express';

const RegisterSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  age: z.coerce.number().int().positive().optional(), // coerce query strings → number
});

// Reusable validator factory — replaces req.body with the typed, parsed result
const validate =
  (schema: z.ZodType): RequestHandler =>
  (req, _res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) return next(new ValidationError(result.error.issues[0].message));
    req.body = result.data; // now strongly typed downstream
    next();
  };

app.post('/register', validate(RegisterSchema), registerHandler);

Validate at the edge (the route), then trust the data inward. Don’t re-validate the same shape in every service {Validate ở biên (route), rồi tin dữ liệu khi đi vào trong. Đừng validate lại cùng hình dạng ở mọi service}.


3.8 The production middleware stack {Bộ middleware production}

npm install morgan cors helmet compression express-rate-limit
import morgan from 'morgan';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import rateLimit from 'express-rate-limit';

app.disable('x-powered-by');                         // don't advertise Express
app.set('trust proxy', 1);                           // correct req.ip behind a proxy/LB
app.use(helmet());                                   // ~15 security headers (Phase 5)
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(compression());                              // gzip/br responses
app.use(express.json({ limit: '1mb' }));             // body-size guard (the Phase 2 lesson)
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));

// Blunt brute-force / abuse at the edge
app.use('/api/', rateLimit({ windowMs: 60_000, limit: 100, standardHeaders: 'draft-7' }));

trust proxy matters: behind Nginx/a load balancer, req.ip is the proxy’s IP unless you trust the X-Forwarded-For header — and rate-limiting by the wrong IP either lets everyone through or blocks everyone {trust proxy quan trọng: sau Nginx/load balancer, req.ip là IP của proxy trừ khi bạn tin header X-Forwarded-For — rate-limit theo sai IP sẽ thả hết hoặc chặn hết}.


3.9 Request context & structured logging {Context request & log có cấu trúc}

Attach a request id at the edge and thread it through logs with AsyncLocalStorage (Phase 1) — invaluable for tracing one request across many log lines {Gắn request id ở biên và xuyên nó qua log bằng AsyncLocalStorage — vô giá để truy vết một request qua nhiều dòng log}:

import { randomUUID } from 'node:crypto';

app.use((req, _res, next) => {
  req.requestId = (req.headers['x-request-id'] as string) ?? randomUUID();
  next();
});

We swap console for structured JSON logging (pino/Winston) in Phase 6 {Ta đổi console sang log JSON có cấu trúc (pino/Winston) ở Phase 6}.


3.10 Graceful shutdown with Express {Tắt êm với Express}

Keep a handle to the underlying server so you can drain on deploy {Giữ tham chiếu tới server bên dưới để xả khi deploy}:

const server = app.listen(3000);

function shutdown(signal: string): void {
  console.log(`${signal} — draining`);
  server.close(() => process.exit(0));   // stop accepting, finish in-flight
  setTimeout(() => process.exit(1), 10_000).unref(); // hard cap
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

4. Hands-on projects {Dự án thực hành}

  1. Full REST API (CRUD) {REST API đầy đủ (CRUD)}: rebuild the Phase 2 tasks API in Express 5 with express.Router() mounted at /api/tasks, in a route → controller → service split. Notice how much shorter and clearer it is {dựng lại API tasks bằng Express 5, tách route → controller → service. Để ý nó ngắn và rõ hơn}.

  2. Production middleware stack {Bộ middleware production}: wire helmet, cors, compression, morgan, JSON limit, rate-limit, trust proxy, a request-id middleware, and a timing logger {ráp helmet, cors, compression, morgan, giới hạn JSON, rate-limit, trust proxy, middleware request-id, và logger đo thời gian}.

  3. Typed error system {Hệ thống lỗi có type}: add AppError/NotFoundError/ValidationError, the final 404 handler, the global error handler, and catchAsync. Trigger 404/422/500 and confirm the consistent { error: { message, status } } shape {thêm cây lỗi, handler 404 cuối, error handler toàn cục, và catchAsync. Kích 404/422/500 và xác nhận hình dạng nhất quán}.

  4. Zod validation layer {Tầng validation Zod}: build the reusable validate(schema) factory; validate body and query on the create route; prove req.body is typed downstream {dựng factory validate(schema) dùng lại; validate body và query trên route tạo; chứng minh req.body có type khi đi tiếp}.

Extra drills {Bài tập thêm}: write an Express 5 GET /files/*splat route reading req.params.splat; protect a router with a requireAuth middleware returning 401; add graceful shutdown and confirm in-flight requests finish on SIGTERM {viết route *splat; bảo vệ router bằng requireAuth; thêm tắt êm và xác nhận request đang chạy hoàn tất khi SIGTERM}.


5. Senior checklist {Checklist senior}

  • Controllers are thin; business logic lives in services {Controller mỏng; logic ở service}.
  • Middleware order is correct; every path either next()s or ends the response once {Thứ tự middleware đúng; mỗi nhánh hoặc next() hoặc kết thúc response một lần}.
  • One typed error hierarchy + one global handler; no stack traces leak {Một cây lỗi + một handler; không lộ stack}.
  • All input is validated at the edge (Zod/express-validator) {Mọi input validate ở biên}.
  • helmet, cors, compression, rate-limit, body limit, trust proxy are set {Đủ bộ bảo mật/giới hạn}.
  • The app shuts down gracefully {App tắt êm}.

What’s next {Phần tiếp theo}

You’ve mastered Express 5: the request lifecycle, the middleware pipeline, routing and routers, a layered architecture, a typed central error strategy with async handling, Zod validation, the production security stack, request context, and graceful shutdown {Bạn đã thành thạo Express 5 từ vòng đời request tới tắt êm}.

But our data still lives in memory and vanishes on restart {Nhưng dữ liệu vẫn nằm trong bộ nhớ và biến mất khi restart}. In Phase 4, we add a real database — SQL with an ORM and NoSQL with Mongoose — covering schema design, relationships, migrations, indexes, connection pooling, transactions, the N+1 problem, and a Redis cache layer {Ở Phase 4, ta thêm database thật — SQL với ORM và NoSQL với Mongoose — gồm thiết kế schema, quan hệ, migration, index, connection pooling, transaction, vấn đề N+1, và tầng cache Redis}.