Giao diện
Framework: Express & NestJS
Express và NestJS là hai framework backend phổ biến nhất của Node.js. Express nhỏ gọn: một chuỗi middleware và router, còn lại tự quyết. NestJS dựng trên Express (hoặc Fastify) và thêm cả một kiến trúc: module, dependency injection, và một vòng đời request rõ ràng. Trang này giải thích cách mỗi framework xử lý một request, những lỗi hay gặp với middleware và lỗi async, cách tổ chức project, và validation đầu vào.
Express: chuỗi middleware
Mọi thứ trong Express là middleware: một hàm (req, res, next). Express gọi lần lượt các middleware theo đúng thứ tự app.use() và app.get() được khai báo. Mỗi middleware làm một trong hai việc: gửi response rồi dừng, hoặc gọi next() để chuyển sang middleware tiếp theo.
js
const app = express()
app.use(express.json({ limit: '100kb' })) // parse body
app.use(requestLogger) // log
app.use('/api', authMiddleware) // kiểm tra token, gắn req.user
app.get('/api/orders/:id', getOrder) // route handler
app.use((req, res) => res.status(404).json({ error: 'not found' }))
app.use((err, req, res, next) => { // error middleware: 4 tham số, đặt cuối
logger.error({ err })
res.status(err.status ?? 500).json({ error: err.status ? err.message : 'Internal error' })
})Những điểm hay sai:
- Thứ tự là tất cả. Middleware đặt sau một route không chạy cho route đó. Trên lab, một middleware auth khai báo sau
app.get('/a')không chạy khi gọi/a, nhưng chạy khi gọi/bkhai báo sau nó. Đây là cách để một route quan trọng vô tình không được bảo vệ. - Error middleware phải có đúng 4 tham số
(err, req, res, next). Express nhận ra nó bằng số tham số của hàm. Viết thiếunextthì Express coi đó là middleware thường, bỏ qua nó khi có lỗi, và trả trang HTML lỗi mặc định của chính Express. - Gửi response rồi vẫn gọi
next(): middleware sau cố gửi lần nữa và nhận lỗiERR_HTTP_HEADERS_SENT. Saures.send()thìreturn, không gọinext(). - Middleware có thể dừng request bất cứ lúc nào, ví dụ auth trả 401 mà không gọi
next().
Lỗi trong handler async: Express 4 và Express 5
Đây là khác biệt quan trọng nhất giữa hai phiên bản:
- Express 4 không biết gì về Promise. Handler
asyncném lỗi thì promise bị reject mà không ai bắt: client không nhận được response nào, và với Node hiện nay, unhandled rejection làm cả process chết (Promise & async/await). Trên lab, một handler async ném lỗi trên Express 4 làm process thoát với exit code 1, dù đã có error middleware. Phải bọc mọi handler bằngtry/catchrồi gọinext(err), hoặc dùng thư viện nhưexpress-async-errors. - Express 5 (bản ổn định từ 2024) tự xử lý: tài liệu ghi rằng middleware và handler trả về promise bị reject thì giá trị đó được chuyển tới error middleware, như gọi
next(err). Trên lab, cùng handler đó trên Express 5 trả500qua error middleware đúng như mong muốn.
Project mới nên dùng Express 5. Project còn ở Express 4 thì phải kiểm tra mọi handler async.
NestJS: module, provider và dependency injection
NestJS tổ chức code theo module. Mỗi module gom các controller (nhận request, định nghĩa route bằng decorator) và provider (service, repository, bất kỳ class nào được inject). Nest có một container dependency injection (DI): class khai báo nó cần gì trong constructor, Nest tự tạo và truyền vào.
ts
@Injectable()
export class OrdersService {
constructor(private readonly repo: OrdersRepository) {} // Nest tự inject
async findOne(id: number) {
const order = await this.repo.findById(id)
if (!order) throw new NotFoundException(`Không tìm thấy đơn ${id}`)
return order
}
}
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Get(':id')
@UseGuards(AuthGuard)
findOne(@Param('id', ParseIntPipe) id: number) { return this.orders.findOne(id) }
}Lợi ích chính của DI là thay được phụ thuộc khi test: test OrdersService thì truyền vào một repository giả, không cần database. Mặc định mỗi provider là một singleton trong cả ứng dụng, nên không được giữ trạng thái của từng request trong field của service.
Vòng đời một request
NestJS có nhiều loại "móc" chạy ở những chỗ khác nhau. Theo tài liệu Nest, thứ tự là: middleware, guard, interceptor (phần trước), pipe, controller, service, interceptor (phần sau), exception filter.
| Thành phần | Dùng để | Ví dụ |
|---|---|---|
| Middleware | Việc chung, chưa biết route nào sẽ xử lý | Log, CORS, request id |
| Guard | Có cho request đi tiếp không | Xác thực JWT, kiểm tra role |
| Interceptor | Bọc quanh handler: chạy trước và sau | Đo thời gian, bọc response, cache, đổi lỗi |
| Pipe | Biến đổi và kiểm tra tham số | ParseIntPipe, ValidationPipe theo DTO |
| Exception filter | Đổi exception thành response | Map lỗi nghiệp vụ sang HTTP status |
Đi từng bước để thấy request dừng ở đâu khi guard từ chối, khi body không hợp lệ, hay khi service ném lỗi:
Bước 1 / 9
Request vào
→Middleware
→Guard (AuthGuard)
→Interceptor (trước)
→Pipe (ValidationPipe)
→Controller
→Service
→Interceptor (sau)
→Exception filter
→Response
Request tới NestJS (chạy trên Express hoặc Fastify).
Ô gạch là bước request không đi qua với kết quả này. Thứ tự NestJS theo trang Request lifecycle trong tài liệu NestJS.
Express hay NestJS
| Express | NestJS | |
|---|---|---|
| Kiến trúc | Tự quyết: middleware và router, còn lại tuỳ team | Có sẵn: module, DI, decorator, vòng đời request |
| Học | Nhanh | Lâu hơn: DI, decorator, RxJS trong interceptor |
| Hợp với | Service nhỏ, team quen tự tổ chức, cần ít lớp trừu tượng | Codebase lớn, nhiều người, cần cấu trúc thống nhất; team quen Angular hay Spring |
| Hiệu năng | Overhead nhỏ | Thêm vài lớp, thường không đáng kể so với thời gian chờ database; chạy được trên Fastify |
Trong phỏng vấn, câu trả lời tốt thường không phải "cái nào tốt hơn", mà là cái nào hợp với quy mô team và codebase, cộng với hiểu cái giá của lựa chọn: Express cho tự do nhưng mỗi project một kiểu, NestJS cho cấu trúc nhưng thêm độ phức tạp.
Tổ chức project
Hai cách chia thư mục phổ biến:
- Theo tầng (
controllers/,services/,repositories/): dễ hiểu lúc đầu, nhưng một tính năng nằm rải ở nhiều thư mục, và project lớn thì mỗi thư mục có hàng trăm file. - Theo module nghiệp vụ (
orders/,users/,payments/, mỗi module có controller, service, repository riêng): code của một tính năng nằm cạnh nhau, ranh giới giữa các module rõ ràng hơn, dễ tách thành service riêng sau này. NestJS khuyến khích cách này.
Dù chia kiểu nào, nên giữ ba tầng trách nhiệm: controller chỉ đọc request và trả response, service chứa logic nghiệp vụ và không biết gì về HTTP, repository lo truy cập database. Nhờ vậy logic nghiệp vụ test được mà không cần HTTP, và dùng lại được từ job hay consumer của hàng đợi.
Validation đầu vào
Mọi dữ liệu từ client đều phải được kiểm tra trước khi dùng: kiểu, khoảng giá trị, độ dài, và không có trường lạ. Dữ liệu từ req.body là any trong TypeScript, nên kiểu khai báo trong code không kiểm tra gì lúc chạy.
- zod (hay valibot, joi): khai báo schema bằng code, dùng được cả trong Express và NestJS, và suy ra kiểu TypeScript từ schema.
- class-validator với ValidationPipe của NestJS: khai báo bằng decorator trên class DTO. Đặt
whitelist: trueđể bỏ trường lạ, hoặcforbidNonWhitelisted: trueđể báo lỗi khi có trường lạ.
js
const CreateOrder = z.object({
productId: z.number().int().positive(),
qty: z.number().int().min(1).max(100),
note: z.string().max(200).optional()
}).strict() // trường lạ thì lỗi
app.post('/orders', (req, res) => {
const r = CreateOrder.safeParse(req.body)
if (!r.success) return res.status(400).json({ errors: r.error.issues })
// r.data đã đúng kiểu
})Trên lab, qty: "2" (chuỗi) và một trường lạ isAdmin: true đều bị trả 400. Chặn trường lạ quan trọng vì lỗi mass assignment: code lấy nguyên req.body ghi vào database, và client tự thêm isAdmin: true (Bẫy của ORM). Ngoài ra, giới hạn kích thước body ngay ở bước parse (express.json({ limit }), mặc định 100 KB): body lớn hơn bị trả 413 trước khi kịp làm tốn CPU (Chặn event loop).
Ghi nhớ
- Express là chuỗi middleware theo đúng thứ tự khai báo; middleware đặt sau route không chạy cho route đó; error middleware phải có 4 tham số và đặt cuối.
- Express 4 không bắt lỗi của handler async: unhandled rejection làm process chết. Express 5 tự chuyển promise reject tới error middleware.
- NestJS: module, provider, DI (provider mặc định là singleton); vòng đời: middleware → guard → interceptor → pipe → controller → interceptor → exception filter.
- Giữ controller mỏng, logic nghiệp vụ trong service không biết về HTTP; chia thư mục theo module nghiệp vụ khi project lớn.
- Validate mọi input bằng schema (zod, class-validator), chặn trường lạ để tránh mass assignment, giới hạn kích thước body.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-26):
| Nguồn | Dùng cho phần |
|---|---|
| Express docs, Error handling | Error middleware có 4 tham số; handler trả về promise bị reject hay ném lỗi tự gọi next(value) |
| Express docs, Migrating to Express 5 | Express 5 chuyển promise bị reject từ middleware và handler tới error middleware |
| NestJS docs, Request lifecycle | Thứ tự: middleware, guard, interceptor trước, pipe, controller, service, interceptor sau, exception filter |
Kiểm bằng lab: Express 4 và 5 với handler async ném lỗi, thứ tự middleware, error middleware 3 tham số, ERR_HTTP_HEADERS_SENT, validation bằng zod, giới hạn body 100 KB. Từ kiến thức chung, chưa đối chiếu lần này: Express 5 ổn định từ 2024; express-async-errors; provider mặc định là singleton; tuỳ chọn whitelist, forbidNonWhitelisted của ValidationPipe. Phần NestJS chưa chạy thử trong lab.
Số đo chi tiết
Kết quả chạy lab/framework/express.mjs (Express 4.22, Express 5.3, zod 3.25, Node 24.14)
| Phép thử | Kết quả |
|---|---|
| Express 4, handler async ném lỗi, có error middleware | Client không nhận response; process thoát, exit code 1 (unhandled rejection) |
Express 4, tự try/catch rồi next(err) | 500 {"error":"DB lỗi"} |
| Express 5, handler async ném lỗi | 500 {"error":"DB lỗi"} qua error middleware |
Middleware auth đặt sau route /a | GET /a: log → handler (auth không chạy); GET /b: log → auth → handler |
| Error middleware 3 tham số | 500, trang HTML lỗi mặc định của Express |
res.send() rồi next(), handler sau gửi tiếp | Client nhận response đầu; error middleware nhận ERR_HTTP_HEADERS_SENT |
zod: body hợp lệ / qty: "2" / thêm isAdmin | 201 / 400 qty: Expected number, received string / 400 Unrecognized key(s) in object: 'isAdmin' |
Body 200 KB với express.json({ limit: '100kb' }) | 413 |
Code đã dùng để chạy thử
Chạy bằng npm run lab -- framework, chỉ cần Node.js, vài giây. Lab cài express (bản 5) và express4 (Express 4 dưới tên khác) cùng lúc. Xem Lab.
lab/framework/express.mjs
js
// Trang nodejs/framework. Chỉ cần Node.js, vài giây.
// 1. Lỗi trong handler async: Express 4 và Express 5
// 2. Thứ tự middleware: đặt middleware sau route thì không chạy; error middleware phải có 4 tham số và đặt cuối
// 3. Validation bằng zod
import http from 'node:http'
import { spawnSync } from 'node:child_process'
import express5 from 'express'
import express4 from 'express4'
import { z } from 'zod'
const log = (label, v) => console.log(`${label.padEnd(60)} ${v}`)
async function serve(app) {
app.set('env', 'test') // error handler mặc định của Express không in stack trace ra console khi env là 'test'
const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)) })
const req = (method, path, body) => new Promise((resolve) => {
const r = http.request({ host: '127.0.0.1', port: server.address().port, method, path, headers: { 'content-type': 'application/json' }, timeout: 1000 }, (res) => {
let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => resolve(`${res.statusCode} ${b.startsWith('<!DOCTYPE') ? '(trang HTML lỗi mặc định của Express)' : b.slice(0, 90)}`))
})
r.on('timeout', () => { r.destroy(); resolve('không có response sau 1 giây (request treo)') })
r.on('error', (e) => resolve(`lỗi ${e.code}`))
r.end(body ? JSON.stringify(body) : undefined)
})
return { req, close: () => server.close() }
}
const errorMw = (err, req, res, next) => res.status(err.status ?? 500).json({ error: err.message })
console.log('\n=== 1. Handler async ném lỗi (giả lập query database lỗi) ===')
for (const [name, express] of [['Express 4', express4], ['Express 5', express5]]) {
const app = express()
app.get('/orders', async (req, res) => { await new Promise((r) => setTimeout(r, 5)); throw new Error('DB lỗi') })
app.use(errorMw)
// Express 4 không bắt promise reject: chạy trong process con để thấy hành vi mặc định
if (name === 'Express 4') {
const r = spawnSync(process.execPath, ['--input-type=module', '-e', `
import express from 'express4'; import http from 'node:http'
const app = express()
app.get('/orders', async (req, res) => { await new Promise((r) => setTimeout(r, 5)); throw new Error('DB lỗi') })
app.use((err, req, res, next) => res.status(500).json({ error: err.message }))
const s = app.listen(0, '127.0.0.1', () => {
const q = http.get({ host: '127.0.0.1', port: s.address().port, path: '/orders', timeout: 500 }, (res) => { console.log('status', res.statusCode); process.exit(0) })
q.on('timeout', () => { console.log('treo'); process.exit(0) }); q.on('error', (e) => console.log('client lỗi', e.code))
})`], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname })
log(`${name}: async handler throw, có error middleware`, `client: ${r.stdout.trim() || '(không có)'}; process exit code ${r.status}; ${(r.stderr.match(/Error: DB lỗi/) || [''])[0] ? 'stderr: Error: DB lỗi (unhandled rejection)' : ''}`)
const app4 = express()
app4.get('/orders', async (req, res, next) => { try { await new Promise((r) => setTimeout(r, 5)); throw new Error('DB lỗi') } catch (e) { next(e) } })
app4.use(errorMw)
const s = await serve(app4)
log(`${name}: tự try/catch rồi gọi next(err)`, await s.req('GET', '/orders'))
s.close()
} else {
const s = await serve(app)
log(`${name}: async handler throw, có error middleware`, await s.req('GET', '/orders'))
s.close()
}
}
console.log('\n=== 2. Thứ tự middleware ===')
{
const app = express5()
const trace = []
app.use((req, res, next) => { trace.push('log'); next() })
app.get('/a', (req, res) => { trace.push('handler /a'); res.send('a') })
app.use((req, res, next) => { trace.push('auth (đặt sau route)'); next() })
app.get('/b', (req, res) => { trace.push('handler /b'); res.send('b') })
app.use((req, res) => res.status(404).send('not found'))
app.use((err, req, res, next) => res.status(500).send('error mw'))
const s = await serve(app)
trace.length = 0; await s.req('GET', '/a'); log('GET /a', trace.join(' → '))
trace.length = 0; await s.req('GET', '/b'); log('GET /b', trace.join(' → '))
log('GET /khong-co', await s.req('GET', '/khong-co'))
s.close()
}
{
const app = express5()
app.get('/x', (req, res) => { throw new Error('lỗi đồng bộ') })
app.use((err, req, res) => res.status(500).send('mw 3 tham số')) // thiếu next: Express coi là middleware thường
const s = await serve(app)
log('Error middleware chỉ có 3 tham số (err, req, res)', await s.req('GET', '/x'))
s.close()
}
{
const app = express5()
app.get('/x', (req, res, next) => { res.send('lần 1'); next() })
app.get('/x', (req, res) => res.send('lần 2'))
const errs = []
app.use((err, req, res, next) => { errs.push(err.code || err.message); if (!res.headersSent) res.status(500).end() })
const s = await serve(app)
const r = await s.req('GET', '/x')
await new Promise((r) => setTimeout(r, 50))
log('Gửi response rồi vẫn gọi next(), handler sau gửi tiếp', `client nhận "${r}"; error middleware nhận: ${errs[0] ?? 'không có'}`)
s.close()
}
console.log('\n=== 3. Validation bằng zod ===')
{
const CreateOrder = z.object({ productId: z.number().int().positive(), qty: z.number().int().min(1).max(100), note: z.string().max(200).optional() }).strict()
const app = express5()
app.use(express5.json({ limit: '100kb' }))
app.post('/orders', (req, res) => {
const r = CreateOrder.safeParse(req.body)
if (!r.success) return res.status(400).json({ errors: r.error.issues.map((i) => `${i.path.join('.') || '(body)'}: ${i.message}`) })
res.status(201).json(r.data)
})
const s = await serve(app)
log('Body hợp lệ', await s.req('POST', '/orders', { productId: 1, qty: 2 }))
log('qty là chuỗi "2"', await s.req('POST', '/orders', { productId: 1, qty: '2' }))
log('Thêm trường lạ isAdmin (strict)', await s.req('POST', '/orders', { productId: 1, qty: 1, isAdmin: true }))
log('Body 200 KB (vượt limit 100 KB)', await s.req('POST', '/orders', { productId: 1, qty: 1, note: 'x'.repeat(200_000) }))
s.close()
}