BlogBlog · api-design

Thiết kế REST API dễ dùng, khó dùng sai: naming, status code, lỗi, pagination, idempotency và versioning

Bộ quy ước REST API mình áp cho mọi service Node.js: đặt tên resource, status code đúng, lỗi chuẩn RFC 9457, cursor pagination, Idempotency-Key, ETag/If-Match và báo deprecation bằng Sunset header.

Part of Developer Tips series
March 10, 2026
28 min read
By Leo Pham
api-designrestbackendhttpnodejs
Share this post:

Mình từng review một API đặt hàng mà mobile app gọi POST /createOrder. Request timeout trên 4G, app tự retry, và khách bị trừ tiền hai lần. Cùng API đó trả 200 OK kèm { "success": false } khi lỗi, mỗi endpoint một kiểu message. Còn khi hai nhân viên kế toán cùng sửa một hóa đơn, người lưu sau ghi đè người lưu trước mà không ai hay biết.

Không lỗi nào ở trên là bug logic. Đó là lỗi thiết kế contract: API cho phép client dùng sai một cách rất tự nhiên. Bài này là bộ quy ước mình áp cho mọi REST API mới. Mỗi quy ước đi kèm code chạy được với Hono, zod và PostgreSQL, kèm lý do, cái giá phải trả và edge case hay gây sự cố.

Bài chỉ bàn về contract (những gì client nhìn thấy). Phần implementation với MongoDB như schema, index và transaction nằm ở bài REST API với Node.js và MongoDB. Bài đó dùng lại đúng các quy ước ở đây.

TL;DR

  • URL dùng danh từ số nhiều, nesting tối đa một cấp, method đúng ngữ nghĩa. Status code cần thuộc: 201 + Location, 204, 400/422, 401/403, 404, 409, 412, 429.
  • Cả API chỉ có một format lỗi: application/problem+json theo RFC 9457.
  • Dùng cursor pagination với cursor opaque và limit có trần.
  • Mọi POST tạo tiền hoặc đơn hàng đều nhận Idempotency-Key. Server lưu response đầu tiên và replay khi client retry.
  • ETag + If-Match để chặn lost update. Gửi Deprecation + Sunset rất lâu trước khi tắt version cũ.

Đặt tên resource và dùng method đúng ngữ nghĩa

Bắt đầu bằng bản đồ endpoint. Nếu bản đồ này đọc lên thấy tự nhiên, phần lớn tranh cãi về sau sẽ biến mất.

GET    /v1/orders                          # list: filter, sort, pagination
POST   /v1/orders                          # tạo đơn → 201 + Location
GET    /v1/orders/{orderId}                # chi tiết, trả ETag
PATCH  /v1/orders/{orderId}                # sửa một phần, bắt buộc If-Match
DELETE /v1/orders/{orderId}                # xóa → 204
GET    /v1/customers/{customerId}/orders   # list trong ngữ cảnh customer (1 cấp)
POST   /v1/orders/{orderId}/cancellation   # hành động = tạo sub-resource
POST   /v1/invoices/{invoiceId}/payments   # thanh toán = tạo payment

Các quy tắc đằng sau:

  • Collection dùng danh từ số nhiều: /orders, không phải /order hay /getOrders. Động từ đã nằm trong HTTP method, viết thêm động từ vào path là nói hai lần.
  • Nesting tối đa một cấp. Với /customers/{id}/orders/{orderId}/items/{itemId}, client phải biết cả chuỗi cha mới chạm được item, và mỗi tầng là thêm một chỗ phải kiểm tra quyền. Item đã có ID duy nhất thì cho nó URL riêng hoặc embed luôn trong order.
  • Hành động không khớp CRUD (hủy đơn, gửi hóa đơn) thì mô hình hóa thành sub-resource được tạo ra: POST /orders/{id}/cancellation thay vì POST /cancelOrder. Cancellation khi đó có body (lý do hủy), có thể GET lại để xem trạng thái, và tuân theo cùng quy tắc idempotency như mọi POST khác.
  • Path dùng kebab-case, field JSON dùng camelCase (hoặc snake_case). Chọn một kiểu và áp cho toàn bộ API, kể cả query param.
Method Safe Idempotent Dùng cho
GET, HEAD Có Có Đọc. Không bao giờ thay đổi state.
POST Không Không Tạo resource, kích hoạt hành động
PUT Không Có Thay thế toàn bộ resource
PATCH Không Không đảm bảo Sửa một phần
DELETE Không Có Xóa. Gọi lần hai vẫn ra cùng trạng thái cuối

Cột idempotent quan trọng hơn bạn nghĩ. Proxy, HTTP client và SDK có thể tự retry các method idempotent khi mất kết nối. Vì vậy một GET /orders/{id}/confirm có side effect sẽ bị gọi lại mà bạn không kiểm soát được. PUT nghĩa là thay toàn bộ: client gửi thiếu field note thì note phải bị xóa. Nếu bạn chỉ muốn sửa vài field, hãy dùng PATCH với ngữ nghĩa JSON Merge Patch (RFC 7396): field có mặt thì ghi đè, null nghĩa là xóa field, field vắng mặt thì giữ nguyên.

Những status code thật sự cần dùng

Một request tạo đơn hàng đúng chuẩn trông như sau:

POST /v1/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: "5f0c2e1a-8d7b-4c47-9a53-0b6f1f7e2c11"

{"customerId":"0b9d2c7e-3f1a-4e55-9a10-2f4c8e6d1a77","currency":"VND","items":[{"sku":"TSHIRT-M","quantity":2}]}

HTTP/1.1 201 Created
Location: /v1/orders/7f3e9a52-1c4b-4d8e-b6a1-5e2f0c9d8b34
Content-Type: application/json
ETag: "1"

{"id":"7f3e9a52-1c4b-4d8e-b6a1-5e2f0c9d8b34","status":"pending","totalAmount":398000,"currency":"VND"}

Danh sách status code mình dùng, kèm quy tắc chọn:

  • 200: GET hoặc PATCH thành công và có body.
  • 201 + Location: tạo resource đồng bộ. Trả luôn resource trong body để client khỏi phải GET lại.
  • 202 Accepted: đã nhận việc và sẽ xử lý nền, ví dụ export báo cáo. Trả Location trỏ tới resource job để client poll.
  • 204: thành công, không có body (DELETE). Đừng gửi body kèm 204, nhiều client và proxy sẽ bỏ hoặc báo lỗi.
  • 400 vs 422: mình dùng 400 khi request không parse được (JSON hỏng, thiếu header bắt buộc, cursor không giải mã được) và 422 khi parse được nhưng vi phạm schema hoặc business rule (quantity âm, currency không hỗ trợ). Nhiều team dùng 400 cho tất cả cũng không sai, miễn nhất quán và body lỗi chỉ rõ field nào hỏng.
  • 401 vs 403: 401 là chưa xác thực hoặc token hết hạn, và theo RFC 9110 phải kèm header WWW-Authenticate. Client nhận 401 thì refresh token hoặc đăng nhập lại. 403 là server biết bạn là ai nhưng không cho làm việc này, refresh token cũng vô ích. Trả 403 khi token hết hạn sẽ khiến app không biết phải refresh.
  • 404: không tồn tại, và cả khi resource tồn tại nhưng thuộc tenant khác. Trả 403 trong trường hợp này chẳng khác gì xác nhận với attacker rằng ID đó có thật.
  • 409: xung đột với trạng thái hiện tại, ví dụ email đã đăng ký, hủy một đơn đã giao, hay Idempotency-Key đang được xử lý.
  • 412 / 428: If-Match không khớp, hoặc server bắt buộc gửi If-Match mà client không gửi (chi tiết ở phần ETag).
  • 429: vượt rate limit, kèm Retry-After tính bằng giây. SDK của bạn nên đợi đúng khoảng đó thay vì retry ngay.
  • 500 vs 503: 500 là bug của bạn. 503 là sự cố tạm thời (bảo trì, dependency sập), kèm Retry-After nếu biết trước.

Một format lỗi cho cả API: RFC 9457 problem+json

RFC 9457 (07/2023, thay thế RFC 7807) định nghĩa media type application/problem+json với các member type, title, status, detail, instance, và cho phép thêm extension member. Dùng chuẩn này nghĩa là client chỉ cần viết một error handler duy nhất, và tooling như OpenAPI generator hay API gateway đều đã hiểu sẵn format này.

// src/http/problem.ts
export const PROBLEM_BASE = 'https://api.acme.vn/problems'

export type ProblemDetails = {
  type: string // URI ổn định, client dùng để rẽ nhánh
  title: string // mô tả ngắn, không đổi giữa các lần xảy ra
  status: number
  detail?: string // chi tiết của lần xảy ra này
  instance?: string
  errors?: Array<{ pointer?: string; parameter?: string; detail: string }>
  [extension: string]: unknown
}

export class ApiProblem extends Error {
  constructor(
    readonly problem: ProblemDetails,
    readonly headers: Record<string, string> = {},
  ) {
    super(problem.title)
  }
}

export function problemResponse(
  instance: string,
  problem: ProblemDetails,
  headers: Record<string, string> = {},
): Response {
  return new Response(JSON.stringify({ ...problem, instance: problem.instance ?? instance }), {
    status: problem.status,
    headers: { 'Content-Type': 'application/problem+json', ...headers },
  })
}

Gắn vào app để mọi lỗi, kể cả route không tồn tại và exception không lường trước, đều ra cùng một format:

// src/app.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { HTTPException } from 'hono/http-exception'
import { authenticate } from './auth.js' // trả về accountId hoặc null
import { ApiProblem, PROBLEM_BASE, problemResponse } from './http/problem.js'

export type AppEnv = { Variables: { accountId: string } }

export const app = new Hono<AppEnv>()

app.use(
  '/v1/*',
  cors({
    origin: ['https://app.acme.vn'],
    allowHeaders: ['Authorization', 'Content-Type', 'Idempotency-Key', 'If-Match'],
    exposeHeaders: ['ETag', 'Location', 'Retry-After', 'Deprecation', 'Sunset', 'Link'],
  }),
)

app.use('/v1/*', async (c, next) => {
  const accountId = await authenticate(c.req.header('authorization'))
  if (!accountId) {
    throw new ApiProblem(
      { type: `${PROBLEM_BASE}/unauthenticated`, title: 'Chưa xác thực hoặc token hết hạn', status: 401 },
      { 'WWW-Authenticate': 'Bearer realm="api"' },
    )
  }
  c.set('accountId', accountId)
  await next()
})

app.notFound((c) =>
  problemResponse(c.req.path, { type: 'about:blank', title: 'Not Found', status: 404 }),
)

app.onError((err, c) => {
  if (err instanceof ApiProblem) return problemResponse(c.req.path, err.problem, err.headers)
  if (err instanceof HTTPException) {
    // ví dụ: Hono validator ném 400 khi body không phải JSON hợp lệ
    return problemResponse(c.req.path, { type: 'about:blank', title: err.message || 'Bad Request', status: err.status })
  }
  console.error(err) // log đầy đủ ở server, không bao giờ trả stack trace cho client
  return problemResponse(c.req.path, { type: 'about:blank', title: 'Internal Server Error', status: 500 })
})

// src/server.ts: import app, import các file route, rồi
// serve({ fetch: app.fetch, port: 3000 }) với serve từ '@hono/node-server'

Vài quy tắc mình giữ chặt:

  • type là contract. Client rẽ nhánh theo type (hoặc status), không bao giờ parse title, vì title có thể được dịch hoặc sửa câu chữ. about:blank dùng khi lỗi không có ngữ nghĩa gì thêm ngoài status code.
  • URI trong type không bắt buộc phải truy cập được, nhưng nếu nó trỏ tới trang tài liệu giải thích lỗi thì người tích hợp sẽ biết ơn bạn.
  • Lỗi 500 chỉ trả thông tin chung. Muốn hỗ trợ debug thì thêm extension traceId để support tra log, thay vì trả message của exception (message đó có thể chứa câu SQL hay đường dẫn file).

Validate input ở boundary với zod (ví dụ Hono)

Validate một lần ở rìa hệ thống. Sau lớp này, code bên trong chỉ làm việc với dữ liệu đã có type chắc chắn. Mình bọc zValidator để lỗi validation luôn ra problem+json, với pointer theo JSON Pointer đúng như ví dụ trong RFC 9457:

// src/http/validate.ts
import { zValidator } from '@hono/zod-validator'
import type { ValidationTargets } from 'hono'
import * as z from 'zod'
import { ApiProblem, PROBLEM_BASE } from './problem.js'

const toPointer = (path: PropertyKey[]) =>
  '#/' + path.map((seg) => String(seg).replaceAll('~', '~0').replaceAll('/', '~1')).join('/')

export const validate = <T extends z.ZodType, Target extends keyof ValidationTargets>(
  target: Target,
  schema: T,
) =>
  zValidator(target, schema, (result) => {
    if (!result.success) {
      throw new ApiProblem({
        type: `${PROBLEM_BASE}/validation-error`,
        title: 'Dữ liệu gửi lên không hợp lệ',
        status: 422,
        errors: result.error.issues.map((issue) =>
          target === 'json'
            ? { pointer: toPointer(issue.path), detail: issue.message }
            : { parameter: issue.path.map(String).join('.'), detail: issue.message },
        ),
      })
    }
  })

export const CreateOrderBody = z.strictObject({
  customerId: z.uuid({ error: 'customerId phải là UUID' }),
  currency: z.enum(['VND', 'USD'], { error: 'currency chỉ nhận VND hoặc USD' }),
  items: z
    .array(
      z.strictObject({
        sku: z.string().min(1).max(64),
        quantity: z.int().positive({ error: 'quantity phải lớn hơn 0' }).max(1000),
      }),
    )
    .min(1)
    .max(100),
  note: z.string().max(500).optional(),
})

Client gửi quantity: 0 và currency: "EUR" sẽ nhận về:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.acme.vn/problems/validation-error",
  "title": "Dữ liệu gửi lên không hợp lệ",
  "status": 422,
  "instance": "/v1/orders",
  "errors": [
    { "pointer": "#/currency", "detail": "currency chỉ nhận VND hoặc USD" },
    { "pointer": "#/items/0/quantity", "detail": "quantity phải lớn hơn 0" }
  ]
}

Vì sao lại là z.strictObject mà không phải z.object? z.object lặng lẽ bỏ qua key lạ, nên client gõ nhầm quantiy vẫn nhận 201 với quantity mặc định. Tệ hơn, nếu bạn từng spread body vào câu insert thì "status": "paid" gửi kèm sẽ thành lỗ hổng mass assignment. Strict object trả lỗi ngay, đúng chỗ.

Hai edge case với Hono:

  1. Target json chỉ parse body khi request có header Content-Type: application/json. Thiếu header thì validator nhận {} (docs Hono có cảnh báo rõ), và client sẽ thấy một loạt lỗi "thiếu field" rất khó hiểu. Mình thêm một middleware trả 415 cho POST/PUT/PATCH không có Content-Type JSON.
  2. Khi validate target header, key trong schema phải viết thường ('idempotency-key', không phải 'Idempotency-Key').

Query string luôn là chuỗi, nên số phải dùng z.coerce.number(). Phần pagination ngay dưới đây sẽ dùng cách đó.

Pagination: offset vs cursor, filter và sort

LIMIT 20 OFFSET 100000 bắt PostgreSQL đọc rồi vứt đi 100.000 dòng trước khi trả 20 dòng bạn cần, nên trang càng sâu càng chậm. Nó còn không ổn định: có đơn mới chen vào giữa hai lần gọi thì cả danh sách bị đẩy xuống, client thấy một đơn xuất hiện hai lần. Offset chỉ hợp với bảng admin dữ liệu nhỏ, nơi người dùng thật sự cần "nhảy tới trang 37".

Mặc định của mình là cursor (keyset) pagination: nhớ vị trí của dòng cuối cùng rồi lấy tiếp từ đó.

// src/routes/orders.list.ts
import * as z from 'zod'
import { app } from '../app.js'
import { pool } from '../db.js'
import { ApiProblem, PROBLEM_BASE } from '../http/problem.js'
import { validate } from '../http/validate.js'

const ListOrdersQuery = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20), // có trần, vượt trần thì 422
  cursor: z.string().max(512).optional(),
  status: z.enum(['pending', 'paid', 'cancelled']).optional(),
  sort: z.enum(['-createdAt', 'createdAt']).default('-createdAt'), // allowlist, không nhận tên cột tùy ý
})

type Cursor = { t: string; id: string; s: string } // t = created_at dạng text để giữ microsecond

const encodeCursor = (c: Cursor) => Buffer.from(JSON.stringify(c)).toString('base64url')

function decodeCursor(raw: string, sort: string): Cursor {
  try {
    const c = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as Cursor
    if (c.s !== sort || typeof c.t !== 'string' || typeof c.id !== 'string') throw new Error('mismatch')
    return c
  } catch {
    throw new ApiProblem({ type: `${PROBLEM_BASE}/invalid-cursor`, title: 'Cursor không hợp lệ', status: 400 })
  }
}

app.get('/v1/orders', validate('query', ListOrdersQuery), async (c) => {
  const q = c.req.valid('query')
  const desc = q.sort === '-createdAt'
  const params: unknown[] = [c.get('accountId')]
  let where = 'account_id = $1'

  if (q.status) {
    params.push(q.status)
    where += ` AND status = $${params.length}`
  }
  if (q.cursor) {
    const cur = decodeCursor(q.cursor, q.sort)
    params.push(cur.t, cur.id)
    where += ` AND (created_at, id) ${desc ? '<' : '>'} ($${params.length - 1}::timestamptz, $${params.length}::uuid)`
  }
  params.push(q.limit + 1) // lấy dư 1 dòng để biết còn trang sau không
  const dir = desc ? 'DESC' : 'ASC'

  const { rows } = await pool.query(
    `SELECT id, status, total_amount, currency, created_at, created_at::text AS cursor_t
       FROM orders WHERE ${where}
      ORDER BY created_at ${dir}, id ${dir}
      LIMIT $${params.length}`,
    params,
  )

  const hasMore = rows.length > q.limit
  const page = rows.slice(0, q.limit)
  const last = page.at(-1)
  return c.json({
    data: page.map(({ cursor_t, ...order }) => order),
    page: { hasMore, nextCursor: hasMore && last ? encodeCursor({ t: last.cursor_t, id: last.id, s: q.sort }) : null },
  })
})

Index đi kèm: CREATE INDEX orders_account_created_id_idx ON orders (account_id, created_at, id);. PostgreSQL quét được index này theo cả hai chiều nên dùng chung cho cả sort tăng lẫn giảm. Cách đọc EXPLAIN để xác nhận index được dùng có trong bài tối ưu SQL query với PostgreSQL.

Những điểm dễ sai:

  • Cursor phải opaque. Client chỉ được chuyển lại nguyên văn. Mình encode base64url để không ai bị cám dỗ tự chế cursor, nhờ vậy sau này có thể đổi cấu trúc bên trong mà không làm vỡ client. Cursor có chứa cả sort, nên dùng cursor của -createdAt cho createdAt sẽ nhận 400 chứ không ra kết quả sai.
  • Sort phải có tie-breaker duy nhất. Hai đơn trùng created_at mà chỉ sort theo created_at thì sẽ bị mất hoặc lặp dòng ở ranh giới trang. Phải thêm id vào cả ORDER BY lẫn điều kiện so sánh.
  • Độ chính xác thời gian. timestamptz của PostgreSQL lưu tới microsecond, còn Date của JavaScript chỉ có millisecond. Lấy created_at qua Date rồi đưa vào cursor sẽ làm mất 3 chữ số, và điều kiện < sẽ bỏ sót hoặc lặp dòng. Vì vậy cursor ở trên dùng created_at::text.
  • Đừng trả totalCount mặc định. COUNT(*) trên bảng lớn tốn ngang một lần full scan. Nếu UI thật sự cần, làm endpoint riêng hoặc tham số opt-in.
  • Quy ước filter: tên field trực tiếp cho so sánh bằng (?status=paid), hậu tố cho khoảng (?createdFrom=...&createdTo=..., ISO 8601 có timezone), sort là danh sách phân tách bằng dấu phẩy với - cho giảm dần. Mỗi giá trị sort được phép phải có index tương ứng, nên allowlist là bắt buộc.

Idempotency-Key cho POST tạo đơn và thanh toán

Đây là cách sửa vụ trừ tiền hai lần ở đầu bài. Client sinh một UUID cho mỗi thao tác nghiệp vụ (không phải mỗi lần gửi request) và gửi trong header Idempotency-Key. Server làm theo bốn bước:

  1. Lần đầu gặp key: chạy nghiệp vụ, lưu lại response.
  2. Gặp lại key với cùng payload: trả lại đúng response đã lưu, không chạy lại nghiệp vụ.
  3. Gặp lại key khi request đầu vẫn đang chạy: trả 409.
  4. Gặp lại key với payload khác: trả 422.

Các mã lỗi này theo IETF draft draft-ietf-httpapi-idempotency-key-header, hiện vẫn là Internet-Draft chứ chưa thành RFC. Draft cũng định nghĩa giá trị header là một Structured Field String (có dấu nháy kép). Nhiều API như Stripe lại nhận giá trị không có nháy, nên server của mình chấp nhận cả hai.

CREATE TABLE idempotency_keys (
  account_id       uuid        NOT NULL,
  key              text        NOT NULL,
  request_hash     text        NOT NULL,
  status           text        NOT NULL DEFAULT 'in_progress', -- in_progress | completed
  response_status  int,
  response_headers jsonb,
  response_body    text,
  created_at       timestamptz NOT NULL DEFAULT now(),
  locked_at        timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (account_id, key) -- key thuộc về từng account, không dùng chung toàn hệ thống
);
// src/http/idempotency.ts
import { createHash } from 'node:crypto'
import type { Context } from 'hono'
import type { PoolClient } from 'pg'
import type { AppEnv } from '../app.js'
import { pool } from '../db.js'
import { ApiProblem, PROBLEM_BASE } from './problem.js'

type Result = { status: number; body: unknown; headers?: Record<string, string> }
type StoredRow = { response_status: number; response_headers: Record<string, string>; response_body: string }

const replay = (row: StoredRow) =>
  new Response(row.response_body, {
    status: row.response_status,
    headers: { ...row.response_headers, 'Idempotent-Replayed': 'true' },
  })

export async function withIdempotency(
  c: Context<AppEnv>,
  payload: unknown,
  run: (tx: PoolClient) => Promise<Result>,
): Promise<Response> {
  const raw = c.req.header('idempotency-key')?.trim()
  if (!raw) {
    throw new ApiProblem({ type: `${PROBLEM_BASE}/idempotency-key-required`, title: 'Thiếu header Idempotency-Key', status: 400 })
  }
  const key = raw.replace(/^"(.*)"$/, '$1')
  if (key.length < 16 || key.length > 255) {
    throw new ApiProblem({ type: `${PROBLEM_BASE}/idempotency-key-invalid`, title: 'Idempotency-Key không hợp lệ', status: 400 })
  }
  const accountId = c.get('accountId')
  const hash = createHash('sha256').update(`${c.req.method} ${c.req.path}\n${JSON.stringify(payload)}`).digest('hex')

  // 1. Giành key. PRIMARY KEY đảm bảo chỉ một request thắng.
  const claimed = await pool.query(
    `INSERT INTO idempotency_keys (account_id, key, request_hash) VALUES ($1, $2, $3)
     ON CONFLICT (account_id, key) DO NOTHING`,
    [accountId, key, hash],
  )

  if (claimed.rowCount === 0) {
    const { rows: [row] } = await pool.query(
      `SELECT *, locked_at < now() - interval '30 seconds' AS stale
         FROM idempotency_keys WHERE account_id = $1 AND key = $2`,
      [accountId, key],
    )
    if (row && row.request_hash !== hash) {
      throw new ApiProblem({ type: `${PROBLEM_BASE}/idempotency-key-reused`, title: 'Idempotency-Key đã dùng cho request khác', status: 422 })
    }
    if (row?.status === 'completed') return replay(row)
    // Request đầu đang chạy, hoặc vừa thất bại và xóa key. Có lock quá 30s thì coi như process cũ đã chết, chiếm lại.
    const takeover = row?.stale
      ? await pool.query(
          `UPDATE idempotency_keys SET locked_at = now()
            WHERE account_id = $1 AND key = $2 AND status = 'in_progress' AND locked_at < now() - interval '30 seconds'`,
          [accountId, key],
        )
      : { rowCount: 0 }
    if (takeover.rowCount === 0) {
      throw new ApiProblem(
        { type: `${PROBLEM_BASE}/request-in-progress`, title: 'Request với key này đang được xử lý', status: 409 },
        { 'Retry-After': '2' },
      )
    }
  }

  // 2. Nghiệp vụ và response được lưu trong CÙNG một transaction.
  const tx = await pool.connect()
  try {
    await tx.query('BEGIN')
    const { rows: [locked] } = await tx.query(
      `SELECT * FROM idempotency_keys WHERE account_id = $1 AND key = $2 FOR UPDATE`,
      [accountId, key],
    )
    if (locked?.status === 'completed') {
      await tx.query('ROLLBACK') // request cũ chạy chậm nhưng đã xong trong lúc mình chờ lock
      return replay(locked)
    }
    const result = await run(tx)
    const headers = { 'Content-Type': 'application/json', ...result.headers }
    const body = JSON.stringify(result.body)
    await tx.query(
      `UPDATE idempotency_keys
          SET status = 'completed', response_status = $3, response_headers = $4, response_body = $5
        WHERE account_id = $1 AND key = $2`,
      [accountId, key, result.status, headers, body],
    )
    await tx.query('COMMIT')
    return new Response(body, { status: result.status, headers })
  } catch (err) {
    await tx.query('ROLLBACK')
    // Nghiệp vụ đã rollback, nên xóa key để client retry với chính key đó.
    await pool.query(`DELETE FROM idempotency_keys WHERE account_id = $1 AND key = $2 AND status = 'in_progress'`, [accountId, key])
    throw err
  } finally {
    tx.release()
  }
}

Route tạo đơn chỉ còn vài dòng. insertOrder là hàm repository của bạn, nhận tx để chạy chung transaction:

app.post('/v1/orders', validate('json', CreateOrderBody), (c) =>
  withIdempotency(c, c.req.valid('json'), async (tx) => {
    const order = await insertOrder(tx, c.get('accountId'), c.req.valid('json'))
    return { status: 201, body: order, headers: { Location: `/v1/orders/${order.id}`, ETag: `"${order.version}"` } }
  }),
)

Vì sao phải viết dài như vậy:

  • Key phải gắn với account. Nếu PRIMARY KEY chỉ là key, user A gửi đúng key của user B (đoán được hoặc lộ trong log) sẽ nhận về response của B, tức là lộ dữ liệu. Ghép account_id vào khóa là bắt buộc.
  • Lưu response chung transaction với nghiệp vụ. Nếu ghi đơn hàng rồi mới lưu response ở bước riêng, process chết giữa hai bước sẽ để lại đơn hàng đã tạo nhưng key vẫn in_progress. Lần retry sau sẽ tạo thêm đơn thứ hai.
  • SELECT ... FOR UPDATE chặn cú takeover nhầm. Nếu request đầu chỉ chạy chậm chứ chưa chết, request chiếm key sẽ bị block ở dòng này cho tới khi request đầu commit, sau đó thấy completed và replay thay vì chạy lại.
  • Xóa key cũ. Hãy ghi chính sách hết hạn vào tài liệu (ví dụ 24 giờ) và chạy job DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours'. Client retry sau thời hạn đó sẽ được xử lý như request mới.

ETag + If-Match: chặn lost update

Quay lại vụ hai kế toán cùng sửa hóa đơn. Mỗi hóa đơn có cột version. GET trả ETag: "7". PATCH bắt buộc gửi If-Match: "7", và server chỉ cập nhật khi version trong DB vẫn là 7:

// src/routes/invoices.ts
import * as z from 'zod'
import { app } from '../app.js'
import { pool } from '../db.js'
import { ApiProblem, PROBLEM_BASE } from '../http/problem.js'
import { validate } from '../http/validate.js'

const PatchInvoice = z.strictObject({
  dueDate: z.iso.date().optional(),
  note: z.string().max(500).nullable().optional(), // null = xóa note (ngữ nghĩa merge patch)
})

app.get('/v1/invoices/:id', async (c) => {
  const { rows: [inv] } = await pool.query(
    'SELECT * FROM invoices WHERE id = $1 AND account_id = $2',
    [c.req.param('id'), c.get('accountId')],
  )
  if (!inv) throw new ApiProblem({ type: 'about:blank', title: 'Not Found', status: 404 })
  c.header('ETag', `"${inv.version}"`)
  return c.json(inv)
})

app.patch('/v1/invoices/:id', validate('json', PatchInvoice), async (c) => {
  const ifMatch = c.req.header('if-match')
  if (!ifMatch) {
    throw new ApiProblem({ type: `${PROBLEM_BASE}/precondition-required`, title: 'Cần gửi If-Match với ETag mới nhất', status: 428 })
  }
  const m = /^"(\d+)"$/.exec(ifMatch.trim()) // If-Match so sánh strong: W/"7" không bao giờ khớp
  const patch = c.req.valid('json')
  const { rows: [updated] } = await pool.query(
    `UPDATE invoices
        SET due_date = CASE WHEN $4::boolean THEN $5::date ELSE due_date END,
            note     = CASE WHEN $6::boolean THEN $7 ELSE note END,
            version  = version + 1,
            updated_at = now()
      WHERE id = $1 AND account_id = $2 AND version = $3
      RETURNING *`,
    [c.req.param('id'), c.get('accountId'), m ? Number(m[1]) : -1,
     'dueDate' in patch, patch.dueDate ?? null, 'note' in patch, patch.note ?? null],
  )
  if (!updated) {
    const exists = await pool.query('SELECT 1 FROM invoices WHERE id = $1 AND account_id = $2', [c.req.param('id'), c.get('accountId')])
    if (exists.rowCount === 0) throw new ApiProblem({ type: 'about:blank', title: 'Not Found', status: 404 })
    throw new ApiProblem({ type: `${PROBLEM_BASE}/version-mismatch`, title: 'Hóa đơn đã bị người khác sửa, hãy tải lại', status: 412 })
  }
  c.header('ETag', `"${updated.version}"`)
  return c.json(updated)
})

Điều kiện version = $3 nằm ngay trong câu UPDATE, nên phép kiểm tra và phép ghi là một thao tác atomic. Không cần lock, không có race giữa lúc đọc và lúc ghi. Người lưu sau nhận 412, UI hiện "dữ liệu đã thay đổi, tải lại?" thay vì âm thầm ghi đè.

Hai cái bẫy. Thứ nhất, browser chỉ đọc được header ETag khi CORS khai báo nó trong exposeHeaders, và chỉ gửi được If-Match khi header này nằm trong allowHeaders. Cấu hình cors() ở đầu bài đã có cả hai. Thứ hai, If-Match luôn so sánh kiểu strong, nên ETag dạng weak W/"7" sẽ không bao giờ khớp. ETag dùng cho concurrency phải là strong ETag.

Versioning và báo deprecation bằng Sunset

Chiến lược Ví dụ Ưu Nhược
URL path /v1/orders Dễ thấy, dễ route ở gateway, dễ cache Major version mới kéo theo cả bộ URL mới
Header Api-Version: 2026-03-01 hoặc Accept: application/vnd.acme.v2+json URL ổn định, version được theo từng thay đổi nhỏ Khó debug bằng trình duyệt, cache phải Vary theo header
Query param /orders?version=2 Dễ thử Dễ bị quên, lẫn lộn với filter

Mặc định của mình là /v1 trong path. Phần lớn API nội bộ và partner không cần gì hơn. Version kiểu ngày trong header (như Stripe) đáng làm khi bạn có nhiều client bên ngoài nâng cấp với tốc độ rất khác nhau.

Quy tắc quan trọng hơn cách đặt version: thay đổi additive thì không cần version mới, ví dụ thêm field optional trong response, thêm endpoint, thêm query param optional. Thay đổi breaking thì cần: xóa hoặc đổi tên field, đổi kiểu dữ liệu, thêm input bắt buộc, đổi sort mặc định, đổi ngữ nghĩa status code. Thêm giá trị enum mới nằm giữa hai loại. Hãy ghi rõ trong tài liệu rằng client phải chấp nhận giá trị enum chưa biết, ngay từ v1.

Khi quyết định bỏ một endpoint, hãy báo bằng header chuẩn chứ đừng chỉ báo qua email: Deprecation (RFC 9745) cho biết endpoint bị deprecated từ khi nào, Sunset (RFC 8594) cho biết thời điểm nó ngừng hoạt động.

// src/http/deprecation.ts
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../app.js'
import { ApiProblem, PROBLEM_BASE } from './problem.js'

export const deprecated = (opts: { since: Date; sunset: Date; docs: string }) =>
  createMiddleware<AppEnv>(async (c, next) => {
    if (Date.now() >= opts.sunset.getTime()) {
      throw new ApiProblem({ type: `${PROBLEM_BASE}/endpoint-retired`, title: 'Endpoint đã ngừng hoạt động', status: 410, detail: `Xem hướng dẫn: ${opts.docs}` })
    }
    console.warn({ msg: 'deprecated endpoint called', path: c.req.path, accountId: c.get('accountId') })
    await next()
    c.header('Deprecation', `@${Math.floor(opts.since.getTime() / 1000)}`) // RFC 9745: Unix timestamp
    c.header('Sunset', opts.sunset.toUTCString()) // RFC 8594: HTTP-date
    c.header('Link', `<${opts.docs}>; rel="deprecation"; type="text/html"`)
  })

// app.get('/v1/reports/sales', deprecated({ since: new Date('2026-09-01'), sunset: new Date('2027-03-01'), docs: 'https://docs.acme.vn/migrate/sales-report-v2' }), handler)

Dòng log deprecated endpoint called có giá trị hơn mọi email thông báo. Gần tới ngày sunset, bạn query log theo accountId là biết chính xác ai còn gọi, rồi liên hệ trực tiếp từng người.

OpenAPI-first và bảng quyết định

Toàn bộ quy ước ở trên chỉ sống sót khi được ghi vào contract mà máy đọc được. Mình viết openapi.yaml trước khi viết handler, review nó như review code, rồi generate type cho cả server lẫn client.

openapi: 3.1.0
info: { title: Acme Orders API, version: 1.0.0 }
paths:
  /v1/orders:
    post:
      operationId: createOrder
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateOrder' }
      responses:
        '201':
          description: Đã tạo đơn
          headers:
            Location: { schema: { type: string } }
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '409': { $ref: '#/components/responses/Problem' }
        '422': { $ref: '#/components/responses/Problem' }
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, minLength: 16, maxLength: 255 }
  responses:
    Problem:
      description: Lỗi theo RFC 9457
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
  schemas:
    Problem:
      type: object
      required: [type, title, status]
      properties:
        type: { type: string, format: uri-reference }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              pointer: { type: string }
              parameter: { type: string }
              detail: { type: string }
    # CreateOrder, Order: khai báo tương tự schema zod ở trên
npx @redocly/cli lint openapi.yaml                              # bắt lỗi contract trong CI
npx openapi-typescript openapi.yaml -o src/generated/api.d.ts   # type cho client (dùng với openapi-fetch)

Bảng quyết định mình dùng khi review thiết kế:

Quyết định Mặc định của mình Đổi sang lựa chọn khác khi
Pagination Cursor, limit ≤ 100 Bảng admin nhỏ, cần nhảy trang thì dùng offset
Lỗi validation 422 + errors[].pointer Team đã quen dùng 400 cho mọi lỗi thì giữ 400, miễn nhất quán
Idempotency Bắt buộc với POST có tiền hoặc đơn hàng POST ít rủi ro (gửi feedback) thì để optional
Concurrency ETag + If-Match, 428 khi thiếu Resource chỉ một người sửa thì có thể bỏ
Versioning /v1 trong path Nhiều client ngoài nâng cấp lệch nhau thì dùng version theo ngày trong header
Contract OpenAPI viết tay trước, generate type Monorepo full-stack TypeScript thì cân nhắc tRPC
totalCount Không trả UI bắt buộc phải có thì dùng endpoint riêng hoặc ước lượng

Những lỗi thường gặp

Trả 200 cho lỗi. Monitoring đếm 5xx sẽ thấy mọi thứ xanh trong lúc client lỗi hàng loạt, còn retry policy của SDK cũng không phân biệt được thành công với thất bại.

// Sai
return c.json({ success: false, message: 'Hết hàng' }, 200)
// Đúng
throw new ApiProblem({ type: `${PROBLEM_BASE}/out-of-stock`, title: 'Sản phẩm đã hết hàng', status: 409, sku: 'TSHIRT-M' })

Nhận tên cột sort tùy ý từ client. Đây là SQL injection nếu bạn nối chuỗi, và là full table scan nếu cột đó không có index.

// Sai
const sql = `SELECT * FROM orders ORDER BY ${c.req.query('sort')}`
// Đúng: allowlist bằng z.enum, map sang mệnh đề ORDER BY viết sẵn
const SORTS = { '-createdAt': 'created_at DESC, id DESC', createdAt: 'created_at ASC, id ASC' } as const

Idempotency-Key unique toàn cục. Như đã phân tích ở trên, cách này làm lộ response của account khác. Khóa phải là (account_id, key).

limit không có trần. ?limit=1000000 là cách DoS rẻ nhất, không cần botnet. Đặt max(100) ngay trong schema.

Trả 403 khi token hết hạn. Interceptor của frontend chỉ refresh khi nhận 401, nên người dùng bị kẹt ở màn hình "không có quyền". 401 kèm WWW-Authenticate mới đúng.

Tạo version mới cho mọi thay đổi. /v2 chỉ vì thêm một field optional sẽ khiến bạn phải duy trì hai codebase. Thay đổi additive không cần version.

Checklist

  • Rà lại bản đồ endpoint: danh từ số nhiều, nesting tối đa một cấp, không có động từ trong path, không GET nào có side effect.
  • Mọi response lỗi, kể cả 404 route và 500, đều là application/problem+json với type ổn định.
  • Body được validate bằng z.strictObject, lỗi trả errors[].pointer. Request không có Content-Type JSON nhận 415.
  • List endpoint dùng cursor opaque, sort có tie-breaker id, limit có trần, có index khớp với từng sort.
  • POST tạo đơn hoặc thanh toán bắt buộc Idempotency-Key, khóa (account_id, key), response lưu chung transaction với nghiệp vụ.
  • Resource nhiều người cùng sửa có ETag strong, PATCH trả 428 khi thiếu If-Match và 412 khi lệch version.
  • CORS exposeHeaders có ETag, Location, Retry-After. allowHeaders có Idempotency-Key, If-Match.
  • Endpoint sắp bỏ gửi Deprecation + Sunset + Link, log lại ai còn gọi, sau ngày sunset trả 410.
  • openapi.yaml được lint trong CI và là nguồn để generate type cho client.

Đọc thêm

Last updated: March 10, 2026

Comments