Những điểm then chốt khi thiết kế hợp đồng dữ liệu API JSON
Kết luận: Sáu thứ rất khó thay đổi sau khi đã phát hành: vỏ bọc phản hồi, định dạng lỗi, hình dạng phân trang, cách biểu diễn ID và thời gian, ngữ nghĩa của null so với vắng mặt, và tiến hoá phiên bản.

Tóm lại: hãy chốt vỏ bọc phản hồi, định dạng lỗi, cách biểu diễn ID và thời gian, cùng ngữ nghĩa null so với vắng mặt ngay trong hợp đồng trước khi tranh luận chi tiết về phân trang và đặt tên; luôn bọc cấp cao nhất trong một vỏ data để sau này có thể thêm phân trang và ID truy vết mà không làm hỏng người gọi hiện có.
Thiết kế API không có đáp án chuẩn, nhưng có những quyết định "đã chọn là khó rút lại", tốt nhất nên nghĩ kỹ trước khi viết dòng mã đầu tiên.
1. Có nên bọc phản hồi hay không
Ba kiểu:
// Style A: bare response
{ "id": 1, "name": "张三" }
// Style B: data envelope
{ "data": { "id": 1, "name": "张三" } }
// Style C: data + metadata
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
| Kiểu | Ưu điểm | Nhược điểm |
|---|---|---|
| Phản hồi trần | Đơn giản nhất, frontend bớt được một lớp | Không gắn được metadata; mảng ở cấp cao nhất mang rủi ro JSON hijacking và khó tiến hoá |
| Vỏ data | Dễ thêm meta / errors / links |
Thêm một tầng lồng, frontend phải viết res.data.data |
| Data + metadata | Khả năng quan sát tốt nhất | Cần thoả thuận nghiêm ngặt về trường nào thuộc meta |
Khuyến nghị: dùng thống nhất vỏ data cho cả danh sách lẫn chi tiết, và đừng bao giờ để cấp cao nhất là mảng. Như vậy, việc thêm phân trang hay ID truy vết về sau sẽ không phá vỡ hợp đồng.
2. Định dạng lỗi: sao chép RFC 9457
Tự phát minh cấu trúc lỗi là một nguồn chi phí phối hợp rất lớn. RFC 9457 (thay thế RFC 7807) định nghĩa application/problem+json:
{
"type": "https://bbdrug.com/problems/insufficient-balance",
"title": "余额不足",
"status": 422,
"detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
"instance": "/accounts/A1001/payments/p-889",
"balance": "12.00",
"required": "30.00"
}
| Trường | Ý nghĩa | Bắt buộc |
|---|---|---|
type |
URI loại vấn đề ổn định | Khuyến nghị |
title |
Tóm tắt ngắn cho con người đọc | Khuyến nghị |
status |
Mã trạng thái HTTP | Khuyến nghị |
detail |
Giải thích cụ thể lần này chuyện gì đã xảy ra | Tuỳ chọn |
instance |
Instance tài nguyên gặp vấn đề | Tuỳ chọn |
| Trường mở rộng | Trường nghiệp vụ tuỳ ý | Tuỳ chọn |
Đặc tả cho phép dùng trường mở rộng (như balance / required ở trên), và frontend có thể dùng chúng để tạo thông báo chính xác thay vì phân tích văn bản detail.
3. Phân trang: cursor tốt hơn offset
// Offset pagination
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// Cursor pagination
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| Khía cạnh | Phân trang offset | Phân trang cursor |
|---|---|---|
| Độ khó triển khai | Thấp (LIMIT/OFFSET) |
Trung bình (cần một khoá sắp xếp ổn định) |
| Hiệu năng ở trang sâu | Kém (offset càng lớn càng chậm) | Tốt |
| Khi dữ liệu thay đổi | Trùng lặp hoặc thiếu mục | Ổn định |
| Nhảy đến một trang | Có thể | Không thể |
| Cần tổng số | Cần thêm một truy vấn đếm | Không cần |
Khuyến nghị: dùng phân trang offset cho bảng quản trị (cần nhảy trang và tổng số), và phân trang cursor cho luồng vô hạn hướng người dùng.
4. ID và thời gian
4.1 Luôn dùng chuỗi cho ID
Lý do đã được trình bày trong bài viết về kiểu dữ liệu: ở phía JavaScript, giá trị vượt quá 2^53 sẽ âm thầm mất độ chính xác.
Kể cả khi ID hiện tại là số nguyên tự tăng nhỏ, dùng chuỗi vẫn để lại dư địa chuyển sang snowflake ID về sau.
{ "id": "1000000000000000001" }
4.2 Dùng chuỗi RFC 3339 cho thời gian
{
"createdAt": "2026-10-30T10:41:45+08:00",
"updatedAt": "2026-10-30T02:41:45Z"
}
- Đừng dùng timestamp số mili-giây: khó đọc, và đơn vị (giây/mili-giây) rất dễ bị nhầm;
- Chỉ định rõ múi giờ: hoặc bằng
Zhoặc bằng offset; đừng bao giờ dùng "giờ địa phương không có múi giờ"; - Đặt tên trường thống nhất là
xxxAtđể nhìn là biết ngay đó là một thời điểm.
5. null và vắng mặt
Bạn phải chọn một cách và ghi nó vào tài liệu:
| Cách tiếp cận | Biểu đạt "không có giá trị" | Biểu đạt "không sửa" |
|---|---|---|
| Cách A | Giá trị trường là null |
Bỏ trường trong request |
| Cách B | Trường không tồn tại | Bỏ trường trong request |
Vấn đề của cách A là "bỏ qua" và "gửi null" mang nghĩa khác nhau trong một PATCH, rất dễ bị hiểu sai;
vấn đề của cách B là JSON.stringify ở frontend sẽ bỏ các trường có giá trị undefined, tình cờ khớp đúng ngữ nghĩa.
Khuyến nghị thực tế:
- Dùng cách A cho body phản hồi (
nullrõ ràng nghĩa là không có giá trị, và frontend không cần phân biệtundefined); - Dùng cách B cho body request (đặc biệt là
PATCH), kết hợp Merge Patch hoặc JSON Patch để ngữ nghĩa rõ ràng.
6. Đặt tên và cấu trúc trường
| Quyết định | Lựa chọn phổ biến | Lưu ý |
|---|---|---|
| Kiểu đặt tên | camelCase (hệ sinh thái JS) / snake_case (Python, cơ sở dữ liệu) |
Thống nhất trong toàn dự án, đừng trộn lẫn |
| Giá trị enum | Chữ thường có gạch dưới, "pending_review" |
Đừng dùng số; khó đọc |
| Trường boolean | isActive / enabled |
Đừng dùng status: 0/1 |
| Trường mảng | Danh từ số nhiều, items |
Trả về [] cho mảng rỗng, đừng bao giờ trả null |
| Độ sâu lồng | Không quá 3 cấp | Sâu hơn thì tách ra hoặc cung cấp tham số expand |
Việc trả [] thay vì null cho mảng rỗng rất quan trọng: data.items.map(...) ở frontend sẽ không sụp vì một null.
7. Chiến lược tiến hoá
| Thay đổi | Phá vỡ tương thích | Cách xử lý |
|---|---|---|
| Thêm một trường tuỳ chọn | Không | Phát hành trực tiếp |
| Thêm một trường bắt buộc | Có | Thêm giá trị mặc định, hoặc phát hành theo phiên bản |
| Xoá một trường | Có | Đánh dấu deprecated trước, quan sát người gọi, rồi mới xoá |
| Đổi kiểu của một trường | Có | Thêm tên trường mới và ghi kép trong giai đoạn chuyển tiếp |
| Đổi tập giá trị enum | Tuỳ | Client phải có nhánh dự phòng cho giá trị chưa biết |
| Đổi ngữ nghĩa trường | Có | Coi như một trường mới |
Ba quy tắc kỷ luật:
- Client phải chịu được trường chưa biết (đừng tự giam mình bằng
additionalProperties: false); - Client phải có nhánh dự phòng cho giá trị enum chưa biết;
- Giữ trường deprecated ít nhất một phiên bản major, và đánh dấu bằng
Deprecationtrong header phản hồi hoặc tài liệu.
8. Một ví dụ đầy đủ
{
"data": [
{
"id": "1001",
"name": "JSON 权威指南",
"price": "89.50",
"tags": ["json", "spec"],
"publishedAt": "2026-09-01T00:00:00+08:00",
"author": null
}
],
"page": { "nextCursor": null, "hasMore": false },
"meta": { "requestId": "req-7f3a", "tookMs": 12 }
}
Phản hồi này phản ánh toàn bộ các quy ước ở trên: vỏ data, ID dạng chuỗi, số tiền dạng chuỗi, thời gian có kèm múi giờ,
null nghĩa là thiếu, mảng rỗng, phân trang cursor, và meta cho thông tin quan sát.
9. Danh sách kiểm tra
- [ ] Cấp cao nhất không phải mảng; dùng thống nhất vỏ
data; - [ ] Phản hồi lỗi theo RFC 9457 và có
typeổn định; - [ ] Hình dạng phân trang khớp với tình huống nghiệp vụ;
- [ ] ID là chuỗi, và thời gian dùng RFC 3339;
- [ ] Ngữ nghĩa của
nullvà vắng mặt được ghi vào tài liệu; - [ ] Kiểu đặt tên thống nhất trong toàn dự án, và enum dùng chuỗi chữ thường;
- [ ] Client có dự phòng cho trường chưa biết và giá trị enum chưa biết;
- [ ] Thay đổi phá vỡ tương thích đi qua phiên bản hoá hoặc giai đoạn ghi kép.
FAQ
Nên thiết kế API JSON có thể tiến hoá như thế nào?
Bọc thống nhất danh sách và chi tiết trong một vỏ data và đừng bao giờ để cấp cao nhất là mảng, để có thể thêm phân trang và ID truy vết mà không phá vỡ hợp đồng. Dùng ID dạng chuỗi và timestamp RFC 3339, và ghi ngữ nghĩa null so với vắng mặt vào tài liệu. Giữ trường mới là tuỳ chọn và độc lập về ngữ nghĩa, đồng thời trang bị cho client nhánh dự phòng cho trường và giá trị enum chưa biết.
Vì sao không nên tự phát minh định dạng lỗi?
Tự phát minh cấu trúc lỗi là một nguồn chi phí phối hợp rất lớn. Hãy sao chép application/problem+json của RFC 9457, nhận diện vấn đề bằng một type ổn định, và cho phép các trường mở rộng như balance và required, để frontend có thể dựng thông báo chính xác mà không phải phân tích văn bản detail.
Có phải mọi endpoint đều nên dùng phân trang cursor?
Không. Bảng quản trị cần nhảy trang và tổng số, nên phân trang offset phù hợp hơn; luồng vô hạn hướng người dùng nên dùng phân trang cursor, vốn ổn định ở trang sâu và tránh trùng lặp hay khoảng trống khi dữ liệu thay đổi. Hãy chọn dựa trên việc tình huống có cần nhảy trang và tổng số hay không.