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}.
- Scope —
app.use(fn)runs globally;app.use('/admin', fn)runs for a path prefix;app.get('/x', mw, handler)runsmwonly 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(...)thenreturn) without callingnext(){Cắt mạch — có thể kết thúc request mà không gọinext()}. 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àoreq, không tạo global mới — nó theo request và chết cùng request}.
Typing custom request props: augment Express’s
Requestrather than casting {Gõ type cho prop tùy biến: mở rộngRequestthay 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/*splat→req.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 HTTP và dù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 proxymatters: behind Nginx/a load balancer,req.ipis the proxy’s IP unless you trust theX-Forwarded-Forheader — and rate-limiting by the wrong IP either lets everyone through or blocks everyone {trust proxyquan trọng: sau Nginx/load balancer,req.iplà IP của proxy trừ khi bạn tin headerX-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}
-
Full REST API (CRUD) {REST API đầy đủ (CRUD)}: rebuild the Phase 2
tasksAPI in Express 5 withexpress.Router()mounted at/api/tasks, in aroute → controller → servicesplit. Notice how much shorter and clearer it is {dựng lại APItasksbằng Express 5, táchroute → controller → service. Để ý nó ngắn và rõ hơn}. -
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}. -
Typed error system {Hệ thống lỗi có type}: add
AppError/NotFoundError/ValidationError, the final 404 handler, the global error handler, andcatchAsync. 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}. -
Zod validation layer {Tầng validation Zod}: build the reusable
validate(schema)factory; validate body and query on the create route; provereq.bodyis typed downstream {dựng factoryvalidate(schema)dùng lại; validate body và query trên route tạo; chứng minhreq.bodycó 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ặcnext()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 proxyare 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}.