Node.js Production Engineering 03 — Express 5 và Request Pipeline
Thiết kế Express 5 như một request pipeline có thứ tự, validation boundary, error contract, proxy trust và graceful shutdown kiểm chứng được.
Một endpoint có thể validate đúng, authenticate đúng và vẫn rò dữ liệu nếu middleware authorization được đăng ký sau handler. Trong Express, kiến trúc không bắt đầu từ folder; nó bắt đầu từ thứ tự request đi qua pipeline và contract của từng middleware.
Bài này dùng Node.js 24 LTS, TypeScript và Express 5. Sau khi đọc, bạn có thể:
- lần theo request qua app middleware, router, handler và error middleware;
- đặt validation, authentication, authorization và rate limit đúng boundary;
- tách HTTP translation khỏi business logic mà không tạo abstraction hình thức;
- chuẩn hóa error response cho frontend và giữ nguyên cause cho observability;
- cấu hình proxy trust, request id, body limit và graceful shutdown theo topology thật.
Kiến thức cần có: HTTP semantics, stream, timeout và error contract ở Phần 2. Express giảm boilerplate nhưng không thay các invariant đó.
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'));
So với Phase 2: không createServer, không parse URL, không tự set status/header. Express là tầng mỏng trên http server: một router, một pipeline middleware, và helper tiện dụng. 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).
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
Mental model quan trọng: middleware là một chuỗi có thứ tự, nhưng mỗi phần tử có thể kết thúc response, chuyển tiếp hoặc chuyển sang error pipeline. Bug phổ biến xuất hiện khi contract đó bị phá: middleware đứng sai vị trí, không gọi next(), gọi next() sau khi đã gửi response, hoặc giữ công việc async ngoài Promise mà Express không quan sát được.
Đào sâu middleware
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`
);
});
res.on('close', () => {
if (!res.writableFinished)
console.warn(`${req.method} ${req.path} client disconnected`);
});
next(); // forget this → the request hangs forever
});
Năm điều cần thấm:
- Thứ tự quan trọng — chạy trên xuống; đăng ký body parser và auth trước route cần chúng.
- Phạm vi — toàn cục / theo tiền tố path / theo route.
- Cắt mạch — có thể kết thúc request mà không gọi
next(). next('route')bỏ phần còn lại của stack route;next(err)nhảy thẳng tới error handler.- 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.
Khi nhiều middleware cùng cần request context, mở rộng type một lần thay vì cast rải rác. Chỉ đặt dữ liệu đã được server xác minh; không sao chép nguyên header client vào req.user hay requestId.
// types/express.d.ts
declare global {
namespace Express {
interface Request {
user?: { id: string; role: string };
requestId?: string;
}
}
}
export {};
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, req.query và req.body đến từ ba nguồn khác nhau nhưng đều là input không tin cậy. Giá trị query có thể là chuỗi, mảng hoặc object tùy parser; schema phải parse và validate trước khi service sử dụng.
Router — tổ chức app đang lớn
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
Thay đổi routing Express 5: đ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.
Kiến trúc phân tầng — giữ HTTP ở biên
Một hình dạng hữu ích là route → controller → use case/service → repository. Controller dịch HTTP sang input của use case; service giữ policy nghiệp vụ; repository che chi tiết truy cập dữ liệu khi sự trừu tượng đó thực sự có giá trị.
// 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;
},
};
Service test được không cần HTTP và có thể được gọi từ CLI, queue worker hoặc transport khác. Tuy nhiên không cần tạo một class cho mỗi hàm CRUD: thêm layer khi nó tạo boundary, policy hoặc test seam rõ ràng; abstraction không có trách nhiệm chỉ làm tăng navigation cost.
Chiến lược xử lý lỗi và API contract
Tập trung lỗi dự kiến bằng type có machine-readable code; giữ lỗi bất ngờ cùng cause để log, nhưng không phơi implementation detail cho client:
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
options?: ErrorOptions
) {
super(message, options);
this.name = this.constructor.name;
Error.captureStackTrace?.(this, this.constructor); // clean stack, omits this ctor
}
}
export class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404, 'RESOURCE_NOT_FOUND');
}
}
export class ValidationError extends AppError {
constructor(message = 'Invalid input', options?: ErrorOptions) {
super(message, 422, 'VALIDATION_ERROR', options);
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'AUTHENTICATION_REQUIRED');
}
}
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) => {
// Header/body đã bắt đầu (ví dụ stream lỗi giữa chừng): giao cho default handler đóng connection.
if (res.headersSent) return next(err);
const isApp = err instanceof AppError;
const status = isApp ? err.statusCode : 500;
if (!isApp || status >= 500) console.error({ requestId: req.requestId, err });
res
.status(status)
.type('application/problem+json')
.json({
type: `https://api.example.com/problems/${isApp ? isApp.code.toLowerCase() : 'internal-error'}`,
title: isApp ? isApp.message : 'Internal Server Error',
status,
code: isApp ? isApp.code : 'INTERNAL_ERROR',
requestId: req.requestId,
});
});
Lỗi dự kiến cần status/code ổn định; lỗi bất ngờ cần log object lỗi thật, gồm stack và cause, rồi map thành 500 chung. Nếu response streaming đã gửi header, không thể đổi sang JSON error nữa; chuyển tiếp để Express đóng connection và ghi nhận failure.
Xử lý lỗi async
Express 5 tự chuyển rejected Promise từ handler/middleware sang next(error):
// 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);
});
Vì vậy project thuần Express 5 không cần catchAsync cho handler trả Promise. Helper dưới chỉ hữu ích trong giai đoạn cùng codebase còn chạy Express 4; giữ nó sau migration tạo thêm một abstraction không cần thiết:
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);
};
Validation
Validation là boundary giữa representation không tin cậy và input đã có type của use case. Hai cách phổ biến:
Validator dạng chuỗi phù hợp với rule ngắn ngay 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
);
Schema-first phù hợp khi cùng schema phục vụ runtime validation, type inference và API documentation. Thay vì mutate req.body rồi tuyên bố nó đã có type, wrapper dưới chuyển giá trị đã parse trực tiếp vào handler:
import { z } from 'zod';
import type { Request, RequestHandler, Response } 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
});
type ValidatedHandler<T> = (
input: T,
req: Request,
res: Response
) => void | Promise<void>;
function withBody<S extends z.ZodTypeAny>(
schema: S,
handler: ValidatedHandler<z.infer<S>>
): RequestHandler {
return async (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return next(
new ValidationError('Request body is invalid', { cause: result.error })
);
}
await handler(result.data, req, res);
};
}
app.post(
'/register',
withBody(RegisterSchema, async (input, _req, res) => {
const user = await userService.register(input); // input is z.infer<typeof RegisterSchema>
res.status(201).json(user);
})
);
Parse và validate representation ở biên transport. Domain vẫn phải bảo vệ invariant của chính nó, vì use case có thể được gọi từ queue, CLI hoặc code nội bộ không đi qua route Express.
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');
// Chỉ dùng 1 khi mọi đường vào production đi qua đúng một trusted proxy.
app.set('trust proxy', 1);
app.use(helmet());
app.use(
cors({
origin: process.env.FRONTEND_URL,
credentials: true,
})
);
app.use(compression()); // gzip/br responses
app.use(express.json({ limit: '1mb', strict: true }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Baseline abuse control; distributed deployment needs a shared/external store.
app.use(
'/api/',
rateLimit({ windowMs: 60_000, limit: 100, standardHeaders: 'draft-7' })
);
trust proxylà quyết định security dựa trên topology, không phải dòng cấu hình mặc định. Nếu client có đường kết nối trực tiếp hoặc số proxy thay đổi, cấu hình1có thể khiến Express tin header do client giả. Dùng subnet/function trust cụ thể và testreq.ip,req.protocol,req.hostnamequa mọi ingress path. Rate limiter trong memory cũng không tạo một quota chung khi chạy nhiều replica.
Context request & log có cấu trúc
Gắn request id ở biên và truyền context bằng AsyncLocalStorage để truy vết một request qua nhiều dòng log:
import { randomUUID } from 'node:crypto';
const REQUEST_ID = /^[A-Za-z0-9_-]{8,128}$/;
app.use((req, res, next) => {
const incoming = req.get('x-request-id');
req.requestId =
incoming && REQUEST_ID.test(incoming) ? incoming : randomUUID();
res.setHeader('X-Request-Id', req.requestId);
next();
});
Header correlation id vẫn là input: giới hạn format/độ dài trước khi đưa vào log. Ở production, dùng AsyncLocalStorage và logger JSON để mọi log con tự mang requestId; không log token, cookie hoặc body nhạy cảm.
Tắt êm với Express
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));
server.closeIdleConnections();
setTimeout(() => {
server.closeAllConnections();
process.exit(1);
}, 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Failure modes và quyết định kiến trúc
Thứ tự pipeline đề xuất
Một API điển hình có thể dùng thứ tự sau; route cụ thể vẫn phải đặt authorization sát use case thay vì chỉ ở global middleware:
request id / context
→ access log
→ security headers + CORS
→ body parser có limit
→ router
→ authentication
→ validation
→ authorization theo resource
→ handler/use case
→ 404
→ error mapper
| Failure mode | Triệu chứng | Nguyên nhân thường gặp | Cách xác nhận |
|---|---|---|---|
| Request treo | client timeout, không có status | middleware quên next() và chưa gửi response | integration test với deadline ngắn |
ERR_HTTP_HEADERS_SENT | log 500 sau khi client đã nhận body | gọi next(err) hoặc gửi lần hai sau res.json() | test từng branch và kiểm return |
| Mất error async | unhandled rejection hoặc response treo | callback async không được return/await, EventEmitter lỗi ngoài Promise | fault injection đúng boundary |
| Rate limit sai user | mọi user chung quota hoặc attacker bypass | trust proxy sai, store riêng từng replica | test qua ingress thật với nhiều IP/replica |
| Error contract lệch | frontend phải parse message | route tự trả lỗi thay vì qua mapper | contract test trên toàn bộ 4xx/5xx |
| Shutdown cắt request | deploy sinh socket reset | grace period ngắn hơn request/dependency deadline | gửi SIGTERM khi request đang chạy |
Express hay abstraction khác?
| Lựa chọn | Phù hợp khi | Chi phí cần chấp nhận |
|---|---|---|
node:http trực tiếp | protocol server nhỏ, proxy/stream chuyên biệt | tự sở hữu routing, validation, error pipeline |
| Express 5 | cần ecosystem middleware và control trực tiếp trên pipeline | ít convention; team phải thống nhất architecture |
| Framework có module/DI | nhiều team cần convention, lifecycle và tooling thống nhất | abstraction, learning curve và migration cost cao hơn |
Quyết định không nên dựa trên benchmark hello-world đơn lẻ. Đo workload thật, nhưng ưu tiên correctness, ecosystem, năng lực team và chi phí vận hành trước khi throughput framework trở thành bottleneck đã chứng minh.
Hợp đồng với frontend
- Một error envelope thống nhất theo
application/problem+json, cócode,status,requestIdvà field errors có path ổn định. 401cho phép client bắt đầu login/refresh flow;403không được tự động refresh vô hạn.- Validation code không phụ thuộc câu tiếng Anh hiển thị. Frontend map
codesang bản dịch và UI phù hợp. - CORS allowlist và cookie credentials phải được test cùng origin deploy thật, không chỉ Postman.
- Request id được trả lại để UI/support cung cấp mã tra cứu, nhưng không lộ stack hay internal identifier nhạy cảm.
Dự án thực hành
-
REST API đầy đủ (CRUD): dựng lại API
tasksbằng Express 5, táchroute → controller → service. Để ý nó ngắn và rõ hơn. -
Bộ middleware production: ráp helmet, cors, compression, morgan, giới hạn JSON, rate-limit,
trust proxy, middleware request-id, và logger đo thời gian. -
Hệ thống lỗi có type: 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. -
Tầng validation Zod: dựng factory
validate(schema)dùng lại; validate body và query trên route tạo; chứng minhreq.bodycó type khi đi tiếp.
Bài tập thêm: viết route /{*splat} theo cú pháp Express 5; 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.
Tiêu chí hoàn thành: Supertest hoặc client tương đương phải chứng minh middleware chạy đúng thứ tự, malformed body không vào use case, rejected Promise tới error mapper, stream lỗi sau khi gửi header không tạo response thứ hai, 401/403/422/500 cùng schema, và hai request đồng thời không dùng nhầm requestId.
Checklist trước khi ship
- Controller mỏng; logic ở service.
- Thứ tự middleware đúng; mỗi nhánh hoặc
next()hoặc kết thúc response một lần. - Một cây lỗi + một handler; không lộ stack.
- Mọi input validate ở biên.
- Đủ bộ bảo mật/giới hạn.
- App tắt êm.
trust proxykhớp mọi ingress path và rate-limit dùng shared store khi có nhiều replica.- Không có middleware async “fire-and-forget” trong request lifecycle.
Nếu chỉ nhớ năm điều
- Express là pipeline có thứ tự; thứ tự middleware chính là một phần kiến trúc.
- Express 5 chuyển rejected Promise được return từ handler sang error middleware.
- Validation transport không thay invariant ở domain.
- Error mapper phải giữ chi tiết trong log nhưng chỉ trả contract an toàn cho client.
- Proxy trust, rate limit và graceful shutdown phải được test qua topology production thật.
Phần tiếp theo
Request pipeline đã có boundary rõ, nhưng dữ liệu vẫn nằm trong bộ nhớ và biến mất khi restart. Ở Phần 4, ta thiết kế data layer với transaction, pool, query shape và failure contract thay vì chỉ thay một object bằng ORM.
Đọc tiếp: Thiết kế Data Layer.