Lập trình 21 PHÚT ĐỌC 86 lượt xem

Xây Dựng REST API với Node.js và Express từ A-Z (2026) | Hướng Dẫn Senior

Author
Lê Minh Trung
Tác giả
22/04/2026

1. Bức tranh toàn cảnh 2026 - Stack nào, tại sao?

Năm 2026, hệ sinh thái Node.js đã thay đổi đáng kể. Node.js 22 LTS ra mắt với native ESM ổn định, built-in fetch, WebSocket, và test runner trưởng thành. Express 5.1 cuối cùng đã stable sau nhiều năm - điểm thay đổi lớn nhất là async error propagation gốc, không cần express-async-handler wrapper nữa.

Tại sao không dùng Fastify hay Hono? Đây là câu hỏi hợp lý. Fastify nhanh hơn ~40%, Hono chạy được trên Edge Runtime. Nhưng Express 5 vẫn là lựa chọn hàng đầu khi bạn ưu tiên ecosystem, onboarding nhanh, và team không cần squeeze mọi millisecond. Bài này dùng Express 5 - kiến thức áp dụng được cho Fastify/Hono với minimal refactor.

Framework Perf (req/s) Ecosystem Learning Curve Edge Runtime Phù hợp
Express 5 ~80k ⭐⭐⭐⭐⭐ Thấp  ❌ API truyền thống, team lớn
Fastify 5 ~120k ⭐⭐⭐⭐ Trung bình  ❌ Performance-critical API
Hono 4 ~200k ⭐⭐⭐ Thấp Edge/Cloudflare Workers
Elysia (Bun) ~300k ⭐⭐ Trung bình  ❌ Bun-native, greenfield

📌 Context bài viết

Node.js 22.14 LTS · Express 5.1.0 · TypeScript 5.8 · Zod 4.0 · Prisma 6 · Pino 9 · Vitest 3. Tất cả code sử dụng ESM native với "type": "module".

2. Khởi tạo dự án & cấu trúc Feature-first

# Khởi tạo project với ESM
mkdir my-api && cd my-api
npm init -y

# Runtime dependencies
npm i express@5 prisma @prisma/client zod@4 \
  bcryptjs jsonwebtoken helmet cors pino pino-http \
  express-rate-limit @scalar/express-api-reference \
  dotenv uuid

# Dev dependencies
npm i -D typescript @types/express @types/node @types/bcryptjs \
  @types/jsonwebtoken tsx vitest supertest @types/supertest \
  prettier eslint @typescript-eslint/parser

Cấu trúc thư mục - Feature-first Architecture

my-api/
├── src/
│   ├── features/               # Domain modules
│   │   ├── auth/
│   │   │   ├── auth.schema.ts  # Zod schemas (source of truth)
│   │   │   ├── auth.service.ts
│   │   │   ├── auth.controller.ts
│   │   │   └── auth.routes.ts
│   │   └── users/
│   │       ├── user.schema.ts
│   │       ├── user.repository.ts
│   │       ├── user.service.ts
│   │       ├── user.controller.ts
│   │       └── user.routes.ts
│   ├── middleware/
│   │   ├── auth.middleware.ts
│   │   ├── error.middleware.ts
│   │   ├── validate.middleware.ts
│   │   └── correlationId.middleware.ts
│   ├── config/
│   │   ├── env.ts              # Zod-validated env
│   │   └── prisma.ts
│   ├── lib/
│   │   ├── logger.ts           # Pino structured logger
│   │   ├── apiResponse.ts      # Consistent response shape
│   │   └── errors.ts           # AppError class
│   ├── openapi/
│   │   └── spec.ts             # OpenAPI 3.1 definition
│   ├── types/
│   │   └── express.d.ts        # Augment Express types
│   └── app.ts
├── prisma/
│   └── schema.prisma
├── tests/
│   ├── integration/
│   └── unit/
├── .env.example
├── docker-compose.yml
├── Dockerfile
├── tsconfig.json
└── package.json

✅ Senior Pattern

Cấu trúc theo feature/domain thay vì theo layer (controllers/, services/...). Mỗi feature là một unit hoàn chỉnh - team có thể own độc lập, tránh merge conflicts khi scale. Khi feature phát triển đủ lớn, extract thành microservice chỉ cần copy folder ra.

3. Cấu hình Express 5 và Middleware Pipeline

src/config/env.ts

import { z } from 'zod/v4'; // Zod v4 import path

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  PORT: z.coerce.number().min(1024).max(65535).default(3000),
  DATABASE_URL: z.url(),
  JWT_ACCESS_SECRET: z.string().min(64),  // 512-bit minimum 2026
  JWT_REFRESH_SECRET: z.string().min(64),
  JWT_ACCESS_TTL: z.string().default('15m'),
  JWT_REFRESH_TTL: z.string().default('7d'),
  CORS_ORIGINS: z.string().default('*'),
  LOG_LEVEL: z.enum(['fatal','error','warn','info','debug']).default('info'),
});

const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
  console.error('❌ Invalid environment variables:', parsed.error.format());
  process.exit(1);
}
export const env = parsed.data;

src/app.ts - Express 5 application

import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { pinoHttp } from 'pino-http';
import rateLimit from 'express-rate-limit';
import { apiReference } from '@scalar/express-api-reference';
import { correlationId } from './middleware/correlationId.middleware.js';
import { errorHandler } from './middleware/error.middleware.js';
import { openApiSpec } from './openapi/spec.js';
import { userRoutes } from './features/users/user.routes.js';
import { authRoutes } from './features/auth/auth.routes.js';
import { logger } from './lib/logger.js';
import { env } from './config/env.js';

export function createApp() {
  const app = express();

  // -- Security --
  app.use(helmet({
    crossOriginResourcePolicy: { policy: 'cross-origin' },
    contentSecurityPolicy: env.NODE_ENV === 'production',
  }));
  app.disable('x-powered-by');
  app.set('trust proxy', 1); // Quan trọng khi đứng sau reverse proxy

  // -- CORS --
  const origins = env.CORS_ORIGINS === '*'
    ? '*'
    : env.CORS_ORIGINS.split(',').map(o => o.trim());
  app.use(cors({ origin: origins, credentials: true }));

  // -- Correlation ID (phải đặt trước logger) --
  app.use(correlationId);

  // -- Structured Logging --
  app.use(pinoHttp({
    logger,
    customLogLevel: (_, res) => res.statusCode >= 500 ? 'error' : 'info',
    serializers: {
      req: (req) => ({ method: req.method, url: req.url, id: req.id }),
    },
  }));

  // -- Body Parsing --
  app.use(express.json({ limit: '50kb' }));
  app.use(express.urlencoded({ extended: true, limit: '50kb' }));

  // -- Global Rate Limit --
  app.use('/api', rateLimit({
    windowMs: 60_000,
    max: 120,
    standardHeaders: 'draft-8', // RateLimit header RFC draft 8 (2026)
    legacyHeaders: false,
    keyGenerator: (req) => req.ip ?? 'unknown',
  }));

  // -- Health --
  app.get('/health', (_, res) => res.json({
    status: 'ok',
    version: process.env.npm_package_version,
    ts: new Date().toISOString(),
  }));

  // -- OpenAPI Docs (dev + staging only) --
  if (env.NODE_ENV !== 'production') {
    app.get('/openapi.json', (_, res) => res.json(openApiSpec));
    app.use('/docs', apiReference({ spec: { url: '/openapi.json' } }));
  }

  // -- Routes --
  app.use('/api/v1/auth', authRoutes);
  app.use('/api/v1/users', userRoutes);

  // -- 404 --
  app.use((_, res) => res.status(404).json({
    status: 'fail', code: 'NOT_FOUND', message: 'Route không tồn tại'
  }));

  // -- Error Handler (Express 5: tự nhận async errors) --
  app.use(errorHandler);

  return app;
}

✅ Express 5 - Điểm khác biệt quan trọng

Express 5 tự động catch lỗi từ async route handlers và forward vào error middleware mà không cần try/catch hay wrapper. Đây là thay đổi lớn nhất so với Express 4 - code sạch hơn đáng kể.

4. Database Layer với Prisma ORM

Năm 2026, Prisma 6 với Prisma Pulse và Accelerate đã trở thành lựa chọn phổ biến hơn Mongoose cho các dự án TypeScript - type safety end-to-end, migration chuẩn, và query engine được viết lại bằng Rust. Dưới đây là cách setup chuẩn.

prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"  // hoặc mongodb
  url      = env("DATABASE_URL")
}

model User {
  id           String    @id @default(cuid())
  name         String
  email        String    @unique
  passwordHash String    @map("password_hash")
  role         Role      @default(USER)
  refreshTokens RefreshToken[]
  createdAt    DateTime  @default(now()) @map("created_at")
  updatedAt    DateTime  @updatedAt @map("updated_at")

  @@map("users")
  @@index([email])
}

model RefreshToken {
  id        String   @id @default(cuid())
  token     String   @unique
  userId    String   @map("user_id")
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime @map("expires_at")
  revokedAt DateTime? @map("revoked_at")
  createdAt DateTime @default(now()) @map("created_at")

  @@map("refresh_tokens")
  @@index([token])
}

enum Role { USER ADMIN }

src/config/prisma.ts

import { PrismaClient } from '../generated/prisma/index.js';
import { logger } from '../lib/logger.js';

const prisma = new PrismaClient({
  log: [
    { emit: 'event', level: 'query' },
    { emit: 'event', level: 'error' },
  ],
});

// Log slow queries (> 500ms) trong development
prisma.$on('query', (e) => {
  if (e.duration > 500) {
    logger.warn({ query: e.query, duration: e.duration }, 'Slow query detected');
  }
});

export { prisma };

5. CRUD API hoàn chỉnh theo Layered Pattern

src/features/users/user.repository.ts

import { prisma } from '../../config/prisma.js';
import { type Prisma } from '../../generated/prisma/index.js';

export class UserRepository {
  private readonly select = {
    id: true, name: true, email: true,
    role: true, createdAt: true,
  } satisfies Prisma.UserSelect;

  findById(id: string) {
    return prisma.user.findUnique({ where: { id }, select: this.select });
  }

  findByEmail(email: string) {
    return prisma.user.findUnique({
      where: { email },
      select: { ...this.select, passwordHash: true },
    });
  }

  async findAll(page: number, limit: number) {
    const skip = (page - 1) * limit;
    const [users, total] = await prisma.$transaction([
      prisma.user.findMany({ skip, take: limit, select: this.select,
        orderBy: { createdAt: 'desc' } }),
      prisma.user.count(),
    ]);
    return { users, total, page, limit, pages: Math.ceil(total / limit) };
  }

  create(data: Prisma.UserCreateInput) {
    return prisma.user.create({ data, select: this.select });
  }

  update(id: string, data: Prisma.UserUpdateInput) {
    return prisma.user.update({ where: { id }, data, select: this.select });
  }

  delete(id: string) {
    return prisma.user.delete({ where: { id }, select: { id: true } });
  }
}

src/features/users/user.controller.ts - Express 5 async (no try/catch!)

import { type Request, type Response } from 'express';
import { UserService } from './user.service.js';
import { ok, paginated } from '../../lib/apiResponse.js';

const svc = new UserService();

// Express 5: async lỗi tự forward → errorHandler, không cần try/catch
export const userController = {
  async list(req: Request, res: Response) {
    const page = Number(req.query.page) || 1;
    const limit = Math.min(Number(req.query.limit) || 20, 100);
    const result = await svc.listUsers(page, limit);
    res.json(paginated(result));
  },

  async getOne(req: Request, res: Response) {
    const user = await svc.getUserById(req.params.id);
    res.json(ok(user));
  },

  async update(req: Request, res: Response) {
    const user = await svc.updateUser(req.params.id, req.body);
    res.json(ok(user));
  },

  async remove(req: Request, res: Response) {
    await svc.deleteUser(req.params.id, req.user!);
    res.status(204).send();
  },
};

6. Validation với Zod v4 - Schema as Source of Truth

Zod v4 (ra mắt 2025) cải thiện performance 14x so với v3 nhờ compiler mới. Quan trọng hơn, Zod v4 hỗ trợ JSON Schema 2020-12 tích hợp sẵn - bạn có thể dùng cùng schema để validate request và generate OpenAPI spec mà không cần tool phụ.

src/features/users/user.schema.ts

import { z } from 'zod/v4';

// Reusable primitives
const cuid = z.string().regex(/^c[^\s-]{24}$/, 'ID không hợp lệ');
const email = z.email('Email không hợp lệ');
const password = z.string()
  .min(8, 'Tối thiểu 8 ký tự')
  .max(72, 'Tối đa 72 ký tự (bcrypt limit)')
  .regex(/[A-Z]/, 'Cần ít nhất 1 chữ hoa')
  .regex(/[0-9]/, 'Cần ít nhất 1 chữ số')
  .regex(/[^A-Za-z0-9]/, 'Cần ít nhất 1 ký tự đặc biệt');

export const CreateUserSchema = z.object({
  body: z.object({
    name:  z.string().min(2).max(100).trim(),
    email,
    password,
  }),
});

export const UpdateUserSchema = z.object({
  params: z.object({ id: cuid }),
  body: z.object({
    name: z.string().min(2).max(100).trim().optional(),
  }).strict(), // Reject unknown fields
});

export const PaginationSchema = z.object({
  query: z.object({
    page:  z.coerce.number().int().positive().default(1),
    limit: z.coerce.number().int().min(1).max(100).default(20),
  }),
});

// Types inferred từ schema - không cần viết tay
export type CreateUserDto = z.infer<typeof CreateUserSchema>['body'];
export type UpdateUserDto = z.infer<typeof UpdateUserSchema>['body'];

src/middleware/validate.middleware.ts

import { type RequestHandler } from 'express';
import { z } from 'zod/v4';

type Schema = z.ZodObject<{
  body?:   z.ZodTypeAny;
  params?: z.ZodTypeAny;
  query?:  z.ZodTypeAny;
}>;

export function validate(schema: Schema): RequestHandler {
  return (req, _, next) => {
    const result = schema.safeParse({
      body:   req.body,
      params: req.params,
      query:  req.query,
    });
    if (!result.success) {
      const err = Object.assign(new Error('Validation failed'), {
        statusCode: 422,
        issues: z.treeifyError(result.error), // Zod v4: treeifyError
        isValidation: true,
      });
      return next(err);
    }
    Object.assign(req, result.data); // Coerced, safe values
    next();
  };
}

7. Centralized Error Handling

src/lib/errors.ts + src/middleware/error.middleware.ts

// ── errors.ts ──
export class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    message: string,
    public readonly code?: string,
    public readonly isOperational = true,
  ) {
    super(message);
    this.name = 'AppError';
    Error.captureStackTrace(this, this.constructor);
  }
}

export const NotFound    = (msg = 'Không tìm thấy')    => new AppError(404, msg, 'NOT_FOUND');
export const Unauthorized = (msg = 'Chưa xác thực')   => new AppError(401, msg, 'UNAUTHORIZED');
export const Forbidden    = (msg = 'Không có quyền')  => new AppError(403, msg, 'FORBIDDEN');
export const Conflict     = (msg = 'Dữ liệu đã tồn tại') => new AppError(409, msg, 'CONFLICT');

// -- error.middleware.ts --
import { type ErrorRequestHandler } from 'express';
import { logger } from '../lib/logger.js';

export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
  // Validation error (từ Zod middleware)
  if (err.isValidation) {
    return res.status(422).json({
      status: 'fail', code: 'VALIDATION_ERROR',
      message: 'Dữ liệu không hợp lệ',
      errors: err.issues,
    });
  }
  // Prisma unique constraint
  if (err.code === 'P2002') {
    return res.status(409).json({
      status: 'fail', code: 'CONFLICT',
      message: 'Dữ liệu đã tồn tại',
    });
  }
  // AppError (operational)
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      status: err.statusCode < 500 ? 'fail' : 'error',
      code: err.code ?? 'APP_ERROR',
      message: err.message,
    });
  }
  // Unknown - log full, hide from client
  logger.error({ err, correlationId: req.headers['x-correlation-id'] });
  res.status(500).json({
    status: 'error', code: 'INTERNAL_ERROR',
    message: 'Đã xảy ra lỗi. Vui lòng thử lại sau.',
  });
};

8. JWT Rotation - Access + Refresh Token chuẩn 2026

Pattern chuẩn 2026 sử dụng Refresh Token Rotation: mỗi lần refresh, token cũ bị revoke và token mới được cấp. Nếu token cũ được dùng lại (dấu hiệu bị đánh cắp), toàn bộ refresh tokens của user đó bị revoke ngay lập tức - đây gọi là reuse detection.

import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { prisma } from '../../config/prisma.js';
import { env } from '../../config/env.js';
import { Unauthorized, Conflict } from '../../lib/errors.js';

export class AuthService {
  private signAccess(userId: string, role: string) {
    return jwt.sign(
      { sub: userId, role },
      env.JWT_ACCESS_SECRET,
      { expiresIn: env.JWT_ACCESS_TTL, algorithm: 'HS512' }
    );
  }

  private async createRefreshToken(userId: string) {
    const token = jwt.sign(
      { sub: userId },
      env.JWT_REFRESH_SECRET,
      { expiresIn: env.JWT_REFRESH_TTL, algorithm: 'HS512' }
    );
    const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
    await prisma.refreshToken.create({ data: { token, userId, expiresAt } });
    return token;
  }

  async login(email: string, password: string) {
    const user = await prisma.user.findUnique({
      where: { email }, select: { id: true, role: true, passwordHash: true, name: true }
    });
    if (!user || !(await bcrypt.compare(password, user.passwordHash)))
      throw Unauthorized('Email hoặc mật khẩu không đúng');

    const [accessToken, refreshToken] = await Promise.all([
      this.signAccess(user.id, user.role),
      this.createRefreshToken(user.id),
    ]);
    return { accessToken, refreshToken, user: { id: user.id, name: user.name, role: user.role } };
  }

  async refresh(oldToken: string) {
    let payload: jwt.JwtPayload;
    try {
      payload = jwt.verify(oldToken, env.JWT_REFRESH_SECRET) as jwt.JwtPayload;
    } catch { throw Unauthorized('Refresh token không hợp lệ'); }

    const stored = await prisma.refreshToken.findUnique({ where: { token: oldToken } });

    // Reuse detection: token đã bị revoke -> có thể bị đánh cắp
    if (!stored || stored.revokedAt) {
      await prisma.refreshToken.updateMany({
        where: { userId: payload.sub!, revokedAt: null },
        data: { revokedAt: new Date() },
      });
      throw Unauthorized('Token reuse detected - tất cả sessions đã bị revoke');
    }

    // Rotation: revoke cũ, cấp mới
    const user = await prisma.user.findUniqueOrThrow({ where: { id: payload.sub! } });
    await prisma.refreshToken.update({ where: { id: stored.id }, data: { revokedAt: new Date() } });

    return {
      accessToken: this.signAccess(user.id, user.role),
      refreshToken: await this.createRefreshToken(user.id),
    };
  }

  async logout(token: string) {
    await prisma.refreshToken.updateMany({
      where: { token, revokedAt: null },
      data: { revokedAt: new Date() },
    });
  }
}

🔴 Security Critical

Không bao giờ lưu Access Token vào localStorage - dễ bị XSS. Lưu Refresh Token trong httpOnly + Secure + SameSite=Strict cookie. Access Token chỉ tồn tại trong memory (React state/Zustand). Đây là security best practice 2026 theo OWASP ASVS 4.0.

9. OpenAPI 3.1 - Document tự động sinh từ code

Thay vì viết tay YAML, dùng Zod schemas để generate OpenAPI 3.1 spec tự động. Scalar UI (thay thế Swagger UI năm 2025) render docs đẹp hơn và hỗ trợ dark mode, request playground.

src/openapi/spec.ts

import { z } from 'zod/v4';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { CreateUserSchema } from '../features/users/user.schema.js';

export const openApiSpec = {
  openapi: '3.1.0',
  info: {
    title: 'My API',
    version: '1.0.0',
    description: 'REST API chuẩn production 2026',
  },
  servers: [{ url: '/api/v1' }],
  components: {
    securitySchemes: {
      bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
    },
    schemas: {
      CreateUser: zodToJsonSchema(CreateUserSchema.shape.body, { $refStrategy: 'none' }),
      // ... thêm schemas khác
    },
  },
  paths: {
    '/users': {
      get: {
        summary: 'Danh sách users',
        tags: ['Users'],
        security: [{ bearerAuth: [] }],
        responses: { '200': { description: 'OK' } },
      },
    },
  },
};

10. Bảo mật API - OWASP Top 10 thực chiến

API Security Top 10 (OWASP 2023) vẫn còn hiệu lực năm 2026. Các lỗ hổng phổ biến nhất:

BOLA - Broken Object Level Authorization (Top 1)

// ❌ Sai: user có thể truy cập resource của người khác
app.get('/users/:id/orders', authenticate, async (req, res) => {
  const orders = await getOrdersByUserId(req.params.id); // BOLA!
  res.json(orders);
});

// ✅ Đúng: chỉ trả resource thuộc về user đang login
app.get('/users/:id/orders', authenticate, async (req, res) => {
  if (req.user!.sub !== req.params.id && req.user!.role !== 'ADMIN')
    throw Forbidden();
  const orders = await getOrdersByUserId(req.params.id);
  res.json(orders);
});

Mass Assignment Prevention

// .strict() reject bất kỳ field nào không có trong schema
const UpdateSchema = z.object({
  body: z.object({ name: z.string() }).strict(),
  // User không thể tự đổi role, email, password qua endpoint này
});

Rate Limiting Per Endpoint

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // 5 lần / 15 phút
  skipSuccessfulRequests: true, // Chỉ đếm failed requests
  message: { code: 'TOO_MANY_ATTEMPTS', message: 'Thử lại sau 15 phút' },
});

router.post('/login', loginLimiter, validate(LoginSchema), authController.login);

⚠️ Checklist bảo mật tối thiểu

  • HTTPS bắt buộc - TLS 1.3, HSTS enabled
  • Không log passwords, tokens, PII trong bất kỳ trường hợp nào
  • Body size limit (50kb) để phòng DoS
  • Rotate secrets định kỳ (JWT secrets, DB passwords)
  • Dependency audit: npm audit --omit=dev trong CI pipeline

11. Logging chuẩn Production với Pino

Pino thay thế Winston làm tiêu chuẩn logging trong Node.js 2025-2026 - nhanh hơn 5x, JSON-first, và tích hợp tốt với log aggregators (Datadog, Grafana Loki, CloudWatch).

src/lib/logger.ts

import pino from 'pino';
import { env } from '../config/env.js';

export const logger = pino({
  level: env.LOG_LEVEL,
  // Dev: pretty print. Production: JSON cho log aggregator
  transport: env.NODE_ENV === 'development'
    ? { target: 'pino-pretty', options: { colorize: true, ignore: 'pid,hostname' } }
    : undefined,
  formatters: {
    level: (label) => ({ level: label }), // "info" thay vì số 30
  },
  redact: {
    paths: ['password', 'token', 'authorization', '*.password'],
    censor: '[REDACTED]',
  },
  base: { service: 'my-api', version: process.env.npm_package_version },
});

12. Testing Strategy 2026

Strategy 2026: Vitest cho unit tests (fast, ESM-native), Node.js built-in test runner cho integration, và k6 cho load testing trong CI pipeline.

tests/integration/users.test.ts - Vitest + Supertest

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../../src/app.js';
import { prisma } from '../../src/config/prisma.js';

describe('Users API', () => {
  const app = createApp();
  let adminToken: string;

  beforeAll(async () => {
    // Seed test DB, get auth token
    const res = await request(app).post('/api/v1/auth/login')
      .send({ email: '[email protected]', password: 'Admin@123' });
    adminToken = res.body.data.accessToken;
  });

  afterAll(() => prisma.$disconnect());

  it('GET /users -> 401 without token', async () => {
    const res = await request(app).get('/api/v1/users');
    expect(res.status).toBe(401);
    expect(res.body.code).toBe('UNAUTHORIZED');
  });

  it('GET /users -> 200 with valid admin token', async () => {
    const res = await request(app).get('/api/v1/users')
      .set('Authorization', `Bearer ${adminToken}`);
    expect(res.status).toBe(200);
    expect(res.body.data.users).toBeInstanceOf(Array);
    expect(res.body.data).toHaveProperty('pagination');
  });

  it('POST /users -> 422 on invalid body', async () => {
    const res = await request(app).post('/api/v1/users')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({ email: 'not-an-email', password: 'weak' });
    expect(res.status).toBe(422);
    expect(res.body.code).toBe('VALIDATION_ERROR');
  });
});

13. Deploy Container-native với Docker & CI/CD

Dockerfile - Multi-stage, non-root, minimal image

# -- Stage 1: Dependencies --
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --frozen-lockfile

# -- Stage 2: Build --
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build

# -- Stage 3: Production (minimal) --
FROM node:22-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app

# Non-root user - security best practice
RUN addgroup -S api && adduser -S api -G api
USER api

COPY package*.json ./
RUN npm ci --frozen-lockfile --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/generated ./src/generated

EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "--enable-source-maps", "dist/index.js"]

.github/workflows/ci.yml - GitHub Actions

name: CI/CD

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_PASSWORD: test, POSTGRES_DB: testdb }
        options: --health-cmd pg_isready

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci
      - run: npx prisma generate
      - run: npm run lint
      - run: npm test
        env: { DATABASE_URL: postgresql://postgres:test@localhost/testdb }

  build-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

14. Production Go-Live Checklist

1. Security

  • ✅ NODE_ENV=production được set, không dùng default values
  • ✅ JWT secrets ≥ 64 ký tự random - không tái sử dụng giữa access và refresh
  • ✅ HTTPS enforced, HTTP redirect 301 sang HTTPS, HSTS header enabled
  • ✅ CORS whitelist chỉ chứa domains được phép - không dùng * trong production
  • ✅ Rate limiting được cấu hình trên tất cả endpoints, đặc biệt auth
  • ✅ Body size limit để phòng chống DoS attacks
  • ✅ Không có secrets nào hardcode - tất cả qua environment variables hoặc secrets manager
  • ✅ npm audit --omit=dev pass trong CI - không có high/critical vulnerabilities

2. Performance & Reliability

  • ✅ Database connection pool được cấu hình đúng với expected concurrency
  • ✅ Database indexes tồn tại trên các columns thường xuyên query (email, foreign keys)
  • ✅ Graceful shutdown xử lý SIGTERM, drain in-flight requests trước khi exit
  • ✅ Health check endpoint /health trả về 200 và được config trong container orchestrator
  • ✅ Unhandled promise rejections được catch và log - không silent failures
  • ✅ Old/expired refresh tokens được cleanup định kỳ (cron job hoặc scheduled task)

3. Observability

  • ✅ Structured logging (JSON) với log level phù hợp trong production
  • ✅ Logs không chứa sensitive data - Pino redact được config đúng
  • ✅ Correlation ID được propagate qua tất cả log lines của một request
  • ✅ Error tracking (Sentry hoặc tương đương) được tích hợp
  • ✅ Uptime monitoring + alerting được setup

4. API Quality

  • ✅ OpenAPI 3.1 spec đầy đủ, sync với implementation (auto-generated từ code)
  • ✅ Tất cả list endpoints có pagination - không return unlimited records
  • ✅ HTTP status codes được dùng đúng convention (200/201/204/400/401/403/404/409/422/500)
  • ✅ Response format nhất quán: luôn có status + data hoặc code + message
  • ✅ API versioning /v1/ để tránh breaking changes ảnh hưởng client hiện tại
  • ✅ Integration tests cover happy path + error cases của tất cả endpoints quan trọng

Kết luận

Xây dựng REST API production-grade năm 2026 đòi hỏi nhiều hơn chỉ biết Node.js và Express. Stack thay đổi - Express 5, Zod v4, Prisma 6, Pino - nhưng nguyên tắc cốt lõi không thay đổi: validate sớm, fail fast, log có cấu trúc, bảo mật theo chiều sâu, và thiết kế cho operability. Kiến trúc feature-first trong bài này đã được kiểm chứng ở scale từ startup đến enterprise, scale tốt với cả team lẫn traffic. Bước tiếp theo: tích hợp OpenTelemetry để distributed tracing khi move sang microservices.

Author
Lê Minh Trung

Kỹ sư phần mềm

Kỹ sư phần mềm cao cấp & người đam mê công nghệ. Chia sẻ những kinh nghiệm thực tiễn từ hơn 5 năm xây dựng các sản phẩm kỹ thuật số.

Hỗ trợ Zalo Zalo Hỗ trợ Telegram Telegram Gọi cho tôi Phone Gửi Email Email
Bot
Assistant
Online
Hello! I'm the portfolio chatbot. Feel free to ask me anything 😊