Xây dựng RESTful API hiệu quả với Node.js và Express: Thiết kế, xác thực và quản lý phiên bản

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Sau 6 tháng chạy một API service trên production với hàng nghìn request mỗi ngày, mình nhận ra rằng phần khó nhất không phải là viết code — mà là thiết kế API đúng từ đầu. Một endpoint đặt tên sai, một response structure không nhất quán, hay quên versioning từ v1 — tất cả đều trở thành nợ kỹ thuật rất đau khi scale.

Bài này ghi lại những gì mình đã áp dụng sau khi vừa code vừa refactor cái API đó — từ cách đặt route, validate input, xác thực JWT cho đến quản lý phiên bản API một cách bền vững.

Vấn đề thực tế với REST API “viết cho xong”

Rất nhiều dự án bắt đầu với Express theo kiểu: tạo một file server.js, thêm vài route, chạy được là thôi. Cách đó không sai khi prototype — nhưng khi dự án lớn lên, mọi thứ bắt đầu vỡ:

  • Route lẫn lộn, không biết endpoint nào đang làm gì
  • Không có validation → client gửi gì lên cũng nhận
  • Khi cần thay đổi response format, phải break tất cả client đang dùng
  • Authentication copy-paste khắp nơi, sửa một chỗ bỏ sót chỗ kia

Mình từng refactor codebase 50K lines. Bài học lớn nhất: phải có test coverage tốt trước khi bắt đầu. Bài học thứ hai, đau không kém: phải thiết kế API structure đúng từ đầu. Refactor API khi client đang dùng là cực kỳ rủi ro — một breaking change nhỏ có thể khiến toàn bộ mobile app của người dùng crash.

Khái niệm cốt lõi cần nắm

REST không phải chỉ là HTTP + JSON

Nhiều API tự gọi là “RESTful” nhưng thực ra chỉ là JSON over HTTP. REST (Representational State Transfer) có mấy nguyên tắc mà hay bị bỏ qua nhất:

  • Resource-based URL: /users/123 thay vì /getUser?id=123
  • HTTP verb đúng nghĩa: GET để đọc, POST để tạo, PUT/PATCH để cập nhật, DELETE để xóa
  • Stateless: Mỗi request phải tự chứa đủ thông tin, không phụ thuộc vào session server-side
  • Consistent response structure: Luôn trả về cùng một format dù thành công hay lỗi

Versioning — thứ hay bị bỏ qua nhất

Ba cách phổ biến để version API: URL path (/api/v1/), query string (?version=1), và HTTP header (Accept: application/vnd.api+json;version=1). Mình dùng URL path. Lý do đơn giản: dễ nhìn, dễ debug, và các tool như Postman hay curl hoạt động ngay mà không cần config thêm.

Thực hành: Xây dựng API chuẩn từ đầu

Cấu trúc project

Trước tiên, tổ chức folder rõ ràng — đây là cấu trúc mình hay dùng:

project/
├── src/
│   ├── routes/
│   │   └── v1/
│   │       ├── users.js
│   │       └── posts.js
│   ├── middleware/
│   │   ├── auth.js
│   │   └── validate.js
│   ├── controllers/
│   ├── models/
│   └── app.js
├── package.json
└── .env

Setup Express với versioning ngay từ đầu

// src/app.js
const express = require('express');
const app = express();

app.use(express.json());

// Mount v1 routes
const v1Router = require('./routes/v1');
app.use('/api/v1', v1Router);

// Khi có breaking change, thêm v2 mà không ảnh hưởng v1
// const v2Router = require('./routes/v2');
// app.use('/api/v2', v2Router);

module.exports = app;
// src/routes/v1/index.js
const router = require('express').Router();

router.use('/users', require('./users'));
router.use('/posts', require('./posts'));

module.exports = router;

Xác thực input với middleware

Đừng validate trong controller — tách ra thành middleware riêng để tái sử dụng. Mình hay dùng thư viện joi hoặc zod:

npm install joi
// src/middleware/validate.js
const Joi = require('joi');

const validate = (schema) => (req, res, next) => {
  const { error, value } = schema.validate(req.body, { abortEarly: false });
  if (error) {
    return res.status(400).json({
      success: false,
      errors: error.details.map(d => d.message)
    });
  }
  req.validatedBody = value;
  next();
};

module.exports = validate;
// src/routes/v1/users.js
const router = require('express').Router();
const Joi = require('joi');
const validate = require('../../middleware/validate');
const auth = require('../../middleware/auth');

const createUserSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
  name: Joi.string().min(2).max(50).required()
});

router.post('/', validate(createUserSchema), async (req, res) => {
  // req.validatedBody đã được sanitize
  const { email, name, password } = req.validatedBody;
  // ... tạo user
});

router.get('/me', auth, async (req, res) => {
  // Route yêu cầu đăng nhập
});

module.exports = router;

Xác thực JWT

npm install jsonwebtoken bcryptjs
// src/middleware/auth.js
const jwt = require('jsonwebtoken');

const auth = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ success: false, message: 'Không có token xác thực' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ success: false, message: 'Token không hợp lệ hoặc đã hết hạn' });
  }
};

module.exports = auth;

Phần tạo token khi login:

// Trong login controller
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

async function login(req, res) {
  const { email, password } = req.validatedBody;
  const user = await User.findOne({ email });

  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ success: false, message: 'Email hoặc mật khẩu sai' });
  }

  const token = jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );

  res.json({
    success: true,
    data: { token, user: { id: user.id, name: user.name, email: user.email } }
  });
}

Response format nhất quán

Frontend team sẽ ghét bạn nếu mỗi endpoint trả về một format khác nhau. Quy tắc đơn giản: luôn có success, luôn có data hoặc message, lỗi validation thì có thêm errors dạng mảng để client hiển thị từng field:

// Thành công
{
  "success": true,
  "data": { ... },
  "meta": { "page": 1, "total": 50 }  // cho pagination
}

// Lỗi
{
  "success": false,
  "message": "Không tìm thấy user",
  "errors": ["email đã tồn tại", "password quá ngắn"]  // optional
}

Tạo helper để không phải viết lại mỗi lần:

// src/utils/response.js
exports.ok = (res, data, meta) =>
  res.json({ success: true, data, ...(meta && { meta }) });

exports.fail = (res, status, message, errors) =>
  res.status(status).json({ success: false, message, ...(errors && { errors }) });

Error handling tập trung

Thêm global error handler vào cuối app.js để không bị unhandled errors:

// Cuối src/app.js
app.use((err, req, res, next) => {
  console.error(err.stack);
  const status = err.status || 500;
  res.status(status).json({
    success: false,
    message: status === 500 ? 'Lỗi server nội bộ' : err.message
  });
});

Những thứ chỉ production mới dạy được

Code chạy được trên local là một chuyện. Đây là mấy điểm mình chỉ hiểu sau khi bị production “giáo dục”:

  • Đừng expose stack trace lên client — log ở server nhưng chỉ trả về message chung cho lỗi 500
  • Rate limiting là bắt buộc — dùng express-rate-limit, cấu hình tối đa 100 request/15 phút cho auth endpoint để chặn brute force
  • Log request/response với morgan từ ngày đầu — khi production có vấn đề lúc 3 giờ sáng, log là thứ duy nhất cứu được bạn
  • Test endpoint bằng Postman collection và commit vào repo — team mới onboard sẽ cảm ơn bạn
  • Viết unit test cho middleware validate và auth trước — đây là phần quan trọng nhất, lỗi ở đây ảnh hưởng toàn bộ API

Còn một điều nữa về versioning: khi thêm v2, đừng xóa v1 ngay. Giữ v1 chạy ít nhất 3–6 tháng, thông báo deprecation rõ ràng, cho client đủ thời gian migrate. Mình đã làm đúng điểm này và tránh được khá nhiều phàn nàn. Đơn giản nhưng dễ bỏ qua.

Node.js + Express vẫn là combo mình tin tưởng cho REST API — đủ linh hoạt, ecosystem lớn, và deploy được ở mọi nơi. Cái tạo ra sự khác biệt không phải framework — mà là thiết kế cẩn thận từ đầu thay vì để refactor sau.

Share: