REST resource và API contract: thiết kế URL trước khi viết code
Bắt đầu chương API Design nhé. shop-app giờ phải mở public API cho web, mobile và cả partner. Thiết kế URL thế nào cho chuẩn?
Chương này nhìn API ở tầng thiết kế, không phụ thuộc framework. Chi tiết HTTP đã có ở bài HTTP; phần Spring chỉ dùng khi cần minh họa.
1. Bài toán: shop-app có 3 loại client
Web (React) ──┐
Mobile app ──┼──> https://api.shop.example/v1/...
Partner (seller) ──┘
Web và mobile do team mình làm, sửa được. Partner thì không: họ viết code một lần, chạy nhiều năm. Một khi URL đã public, đổi là làm hỏng hệ thống người khác.
2. Vấn đề: API mọc tự phát
POST /createOrder
GET /getOrdersByUser?uid=5
POST /order/cancel?id=10
GET /api/products/list?pageNo=1&size=20
GET /api/v1/Product-Search?page=0&limit=20
- Động từ nằm trong URL, mỗi dev đặt một kiểu.
- Phân trang chỗ
pageNotừ 1, chỗpagetừ 0. - Không có tài liệu chính thức. Partner hỏi qua email.
3. Giải pháp: resource + quy ước thống nhất + contract
Resource là danh từ, HTTP method là động từ:
| Ý định | Đúng | Tránh |
|---|---|---|
| Danh sách order | GET /orders | GET /getOrders |
| Một order | GET /orders/{id} | GET /order?id= |
| Tạo order | POST /orders | POST /createOrder |
| Item của order | GET /orders/{id}/items | GET /orderItems?orderId= |
| Order của tôi | GET /me/orders | GET /orders?userId=5 (dễ BOLA, xem 11.6) |
Quy tắc: danh từ số nhiều, chữ thường, gạch nối (/product-reviews), lồng tối đa 2 cấp.
Hành động không phải CRUD (hủy, thanh toán, hoàn tiền) thì mô hình thành sub-resource hoặc state:
POST /orders/{id}/cancellation // tạo "sự hủy"
POST /orders/{id}/payments // tạo một lần thanh toán
POST /orders/{id}/refunds
// hoặc nếu chỉ đổi state:
PATCH /orders/{id} {"status":"CANCELLED"}
Sub-resource tốt hơn khi hành động có dữ liệu riêng (lý do hủy, người hủy) và có quy tắc nghiệp vụ, không chỉ là gán field.
Pagination / filter / sort: chọn một kiểu và dùng ở mọi endpoint.
GET /products?category=shoes&minPrice=100&sort=-createdAt,name&limit=20&cursor=eyJpZCI6MTAwfQ
{
"data": [ ... ],
"page": { "nextCursor": "eyJpZCI6MTIwfQ", "limit": 20 }
}
4. Code từng bước
Bước 1: viết contract trước (OpenAPI)
openapi: 3.1.0
info: { title: shop-app public API, version: 1.0.0 }
paths:
/orders/{orderId}:
get:
operationId: getOrder
parameters:
- { name: orderId, in: path, required: true, schema: { type: string } }
responses:
"200": { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Order' } } } }
"404": { $ref: '#/components/responses/NotFound' }
components:
schemas:
Order:
type: object
required: [id, status, total]
properties:
id: { type: string, example: ord_8f3k2 }
status: { type: string, enum: [PENDING, PAID, SHIPPED, CANCELLED] }
total: { type: string, example: "199000.00" } # tiền là string/decimal, không dùng float
Bước 2: sinh interface bằng openapi-generator-maven-plugin (interfaceOnly=true), controller chỉ implement.
Bước 3: Code Java
@RestController
public class OrderController implements OrdersApi { // OrdersApi sinh từ YAML
private final OrderService service;
public OrderController(OrderService service) { this.service = service; }
@Override
public ResponseEntity<OrderDto> getOrder(String orderId) {
return ResponseEntity.ok(service.find(orderId));
}
}
Bước 4: test
curl -s https://api.shop.example/v1/orders/ord_8f3k2 -H "Authorization: Bearer $TOKEN"
curl -s "https://api.shop.example/v1/products?limit=2&sort=-createdAt"
5. Cách hoạt động: contract-first
openapi.yaml (review như code, PR)
│
├──> generate server interface ──> Spring controller
├──> generate client SDK ──> mobile / partner
├──> mock server (Prism) ──> frontend làm song song
└──> lint (Spectral) + breaking-change check trong CI
6. So sánh: contract-first vs code-first
| Contract-first | Code-first (springdoc) | |
|---|---|---|
| Nguồn sự thật | File YAML | Annotation trong code |
| Tốc độ ban đầu | Chậm hơn | Nhanh |
| Client làm song song | Có (mock) | Phải chờ code |
| Rủi ro | YAML lệch code nếu không generate | Đổi field Java là vô tình đổi API |
| Hợp với | Public/partner API | API nội bộ, prototype |
7. Khi nào nên dùng / không nên dùng
- Public, partner API: contract-first, lint quy ước, check breaking change.
- Service nội bộ một team: code-first + springdoc là đủ.
- Không ép mọi thứ thành REST thuần: tìm kiếm phức tạp có thể
POST /products/search.
?page=3&size=20 dễ hiểu hơn cursor nhiều. Sao bạn lại chọn cursor cho danh sách product?
Offset không sai, nhưng có hai điểm yếu khi dữ liệu lớn và thay đổi liên tục:
Offset: SELECT ... ORDER BY created_at DESC LIMIT 20 OFFSET 100000
-> DB vẫn phải đọc và bỏ 100000 dòng
Trang 1 đọc xong, có 1 product mới chèn lên đầu
Trang 2 (OFFSET 20) -> dòng cuối trang 1 xuất hiện lại (trùng)
Cursor: WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC LIMIT 20
-> dùng index, không trùng, không sót
| Offset | Cursor (keyset) | |
|---|---|---|
| Nhảy tới trang 50 | Được | Không |
| Tổng số trang | Có (tốn COUNT) | Thường không |
| Dữ liệu sâu | Chậm | Ổn định |
| Feed / infinite scroll / partner sync | Dễ trùng/sót | Phù hợp |
Admin panel cần "trang 7/42" thì dùng offset. Mobile feed và partner kéo dữ liệu thì cursor. Cursor nên là chuỗi opaque (base64) để client không tự chế và mình được đổi cách mã hóa.
Team có 8 dev, mỗi người một service. Làm sao giữ mọi API cùng một kiểu đặt tên, phân trang, lỗi?
Đúng rồi, quy ước chỉ nằm trong wiki thì sẽ bị quên. Phải biến thành thứ máy kiểm tra được:
- API style guide ngắn: tên resource, camelCase cho JSON, format ngày ISO-8601 UTC, tiền dạng string, envelope phân trang, error format (11.3).
- Linter (Spectral) chạy trên OpenAPI trong CI: bắt
/getOrders, thiếuoperationId, thiếu response 4xx. - Breaking-change check (oasdiff): so YAML mới với bản đang chạy, fail build nếu xóa field, đổi type.
- Thư viện chung:
PageResponse<T>, error handler dùng chung cho mọi service.
PR đổi openapi.yaml
└─ CI: spectral lint ──> oasdiff breaking? ──> review ──> merge
Phỏng vấn hỏi về thiết kế REST resource và OpenAPI thì trả lời sao?
Q1. Nguyên tắc đặt URL cho REST API?
🇻🇳 Tiếng Việt:
URL là danh từ số nhiều chỉ resource, method HTTP là động từ. Lồng không quá hai cấp, ví dụ
/orders/{id}/items. Hành động không phải CRUD như hủy hay hoàn tiền thì mô hình thành sub-resourcePOST /orders/{id}/refunds. Phân trang, filter, sort dùng một quy ước chung cho mọi endpoint.
🇬🇧 English:
URLs are plural nouns that identify resources, and HTTP methods are the verbs. I keep nesting to at most two levels, like
/orders/{id}/items. Non-CRUD actions such as cancel or refund become sub-resources, for examplePOST /orders/{id}/refunds. Pagination, filtering and sorting follow one shared convention across all endpoints.
Q2. Offset vs cursor pagination?
🇻🇳 Tiếng Việt:
Offset đơn giản, nhảy trang được, nhưng chậm ở trang sâu và dễ trùng hoặc sót khi dữ liệu thay đổi. Cursor dùng keyset trên cột có index nên ổn định và nhanh, hợp với feed và đồng bộ dữ liệu, nhưng không nhảy tới trang bất kỳ.
🇬🇧 English:
Offset pagination is simple and supports jumping to a page, but it gets slow on deep pages and can duplicate or skip rows when data changes. Cursor pagination uses a keyset on indexed columns, so it is fast and stable, which fits feeds and data sync, but it cannot jump to an arbitrary page.
Q3. Contract-first hay code-first?
🇻🇳 Tiếng Việt:
Với public hoặc partner API em chọn contract-first: OpenAPI được review như code, sinh server interface và client SDK, có mock để frontend làm song song, CI kiểm tra breaking change. Với API nội bộ thay đổi nhanh thì code-first với springdoc nhanh hơn.
🇬🇧 English:
For public or partner APIs I prefer contract-first: the OpenAPI spec is reviewed like code, server interfaces and client SDKs are generated from it, a mock lets frontend teams work in parallel, and CI checks for breaking changes. For fast-moving internal APIs, code-first with springdoc is quicker.
Cách nhớ ngắn gọn
- URL = danh từ, method = động từ, action = sub-resource.
- Offset cho admin, cursor cho feed/partner.
- Public API: contract-first + lint + breaking-change check.
- Nhất quán phải do CI giữ, không phải wiki.
Tự kiểm tra
Thiết kế endpoint hoàn tiền một order thế nào?
POST /orders/{id}/refunds với body lý do, số tiền. Refund là resource có dữ liệu và trạng thái riêng.
Vì sao GET /me/orders an toàn hơn GET /orders?userId=5?
User lấy từ token, client không tự đổi được userId để xem order người khác.
Cursor pagination giải quyết vấn đề gì của offset?
Không phải quét bỏ N dòng ở trang sâu, và không trùng/sót khi có dữ liệu mới chèn vào.
Vì sao tiền nên là string/decimal trong JSON?
Float/double làm tròn sai (0.1 + 0.2). Client parse sang BigDecimal hoặc kiểu decimal.
Lợi ích lớn nhất của contract-first với partner?
Contract được chốt và review trước, sinh SDK, mock, và CI chặn breaking change trước khi tới partner.