Skip to content

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 /b khai 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ếu next thì 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ỗi ERR_HTTP_HEADERS_SENT. Sau res.send() thì return, không gọi next().
  • 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 async né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ằng try/catch rồi gọi next(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ả 500 qua 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ầnDùng đểVí dụ
MiddlewareViệc chung, chưa biết route nào sẽ xử lýLog, CORS, request id
GuardCó cho request đi tiếp khôngXác thực JWT, kiểm tra role
InterceptorBọc quanh handler: chạy trước và sauĐo thời gian, bọc response, cache, đổi lỗi
PipeBiến đổi và kiểm tra tham sốParseIntPipe, ValidationPipe theo DTO
Exception filterĐổi exception thành responseMap 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 ​

ExpressNestJS
Kiến trúcTự quyết: middleware và router, còn lại tuỳ teamCó sẵn: module, DI, decorator, vòng đời request
HọcNhanhLâu hơn: DI, decorator, RxJS trong interceptor
Hợp vớiService nhỏ, team quen tự tổ chức, cần ít lớp trừu tượngCodebase lớn, nhiều người, cần cấu trúc thống nhất; team quen Angular hay Spring
Hiệu năngOverhead 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ặc forbidNonWhitelisted: 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ớ

  1. 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.
  2. 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.
  3. NestJS: module, provider, DI (provider mặc định là singleton); vòng đời: middleware → guard → interceptor → pipe → controller → interceptor → exception filter.
  4. 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.
  5. 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ồnDùng cho phần
Express docs, Error handlingError 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 5Express 5 chuyển promise bị reject từ middleware và handler tới error middleware
NestJS docs, Request lifecycleThứ 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 middlewareClient 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ỗi500 {"error":"DB lỗi"} qua error middleware
Middleware auth đặt sau route /aGET /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ếpClient nhận response đầu; error middleware nhận ERR_HTTP_HEADERS_SENT
zod: body hợp lệ / qty: "2" / thêm isAdmin201 / 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()
}