Bài này về những quyết định thiết kế API mà bạn phải sống chung nhiều năm.
Tài nguyên, không phải hành động
✗ POST /taoDonHang ✓ POST /don-hang
✗ GET /layDonTheoId?id=5 ✓ GET /don-hang/5
✗ POST /xoaDonHang ✓ DELETE /don-hang/5
URL đặt tên sự vật, method nói làm gì. Danh từ số nhiều cho tập hợp.
Quy tắc này có ích thật: nó cắt bớt tranh luận đặt tên, và làm API đoán được — biết /don-hang/5 thì đoán được /don-hang/5/muc.
Nhưng nó có giới hạn, và tôi sẽ nói ở cuối bài.
Mã trạng thái đo được
POST /don -> HTTP/1.1 201
Location: /don/9999
DELETE /don/5 -> HTTP/1.1 204 (không có Content-Length)
201 kèm Location cho biết tài nguyên vừa tạo nằm ở đâu. Client không phải đoán, không phải phân tích thân để lấy id.
return ResponseEntity.created(URI.create("/don/" + d.id())).body(d);
204 No Content cho xoá và cho cập nhật không trả gì. Chú ý: 204 không được có thân — trả JSON kèm 204 là vi phạm đặc tả và nhiều client sẽ bỏ qua nó.
Bảng tôi dùng:
| Việc | Thành công | Ghi chú |
|---|---|---|
GET một tài nguyên |
200 | 404 nếu không có |
GET danh sách |
200 | danh sách rỗng vẫn 200, không phải 404 |
POST tạo |
201 + Location |
|
POST hành động |
200 hoặc 202 | 202 khi xử lý bất đồng bộ |
PUT thay toàn bộ |
200 hoặc 204 | 201 nếu tạo mới |
PATCH sửa một phần |
200 hoặc 204 | |
DELETE |
204 | 404 nếu không có, hoặc 204 cho gọn |
Dòng "danh sách rỗng vẫn 200" đáng nhắc: 404 nghĩa là endpoint không tồn tại, không phải "không có dữ liệu". Trả 404 cho kết quả tìm kiếm rỗng làm client không phân biệt được lỗi đường dẫn với kết quả rỗng.
ETag: một dòng, phản hồi còn 0 byte
@Bean ShallowEtagHeaderFilter etag() { return new ShallowEtagHeaderFilter(); }
lần 1: 200 ETag: "0733ad74f1bbe855c3621d131d9748408" Content-Length: 42
lần 2: 304 ETag: "0733ad74f1bbe855c3621d131d9748408" thân = 0 byte
Client gửi lại If-None-Match với ETag đã có, dữ liệu không đổi thì máy chủ trả 304 và không gửi thân.
Một dòng @Bean, và mọi endpoint được cache có điều kiện.
Cần biết rõ giới hạn của bản "shallow" này: nó vẫn chạy toàn bộ logic controller, vẫn truy vấn CSDL, rồi mới băm kết quả để so. Nó tiết kiệm băng thông, không tiết kiệm công máy chủ.
Muốn tiết kiệm cả công thì tự sinh ETag từ một trường rẻ tiền — số phiên bản hoặc thời điểm sửa:
return ResponseEntity.ok().eTag("\"" + don.version() + "\"").body(don);
hoặc dừng sớm hẳn:
@GetMapping("/don/{id}")
ResponseEntity<Don> mot(@PathVariable String id, WebRequest req) {
long sua = svc.thoiDiemSua(id); // truy vấn rẻ
if (req.checkNotModified(sua)) return null; // trả 304, không tải dữ liệu
return ResponseEntity.ok(svc.lay(id));
}
checkNotModified trả true thì Spring đã ghi 304 vào phản hồi, và bạn return null.
ETag còn dùng cho việc thứ hai, ít người dùng nhưng rất giá trị: khoá lạc quan qua HTTP. Client gửi If-Match khi PUT; dữ liệu đã đổi từ lúc họ đọc thì máy chủ trả 412 Precondition Failed thay vì âm thầm ghi đè thay đổi của người khác.
Định dạng lỗi thống nhất
Bài 14 đã nói kỹ. Điểm cần nhất ở đây: mọi lỗi trả về cùng một hình dạng, để client viết một chỗ xử lý.
ProblemDetail (RFC 7807) là lựa chọn tôi khuyên cho API mới.
Ba chỗ tranh cãi không đáng bỏ thời gian
HATEOAS. Về lý thuyết rất hay: phản hồi kèm link cho hành động tiếp theo, client không cần biết cấu trúc URL. Thực tế: gần như không client nào dùng những link đó, và nó làm phản hồi phình lên. Bỏ qua, trừ khi bạn có lý do cụ thể.
PUT hay PATCH. Tranh luận này tốn nhiều giờ hơn nó đáng. Quy tắc: PUT thay toàn bộ, PATCH sửa một phần. Trong thực tế phần lớn API chỉ cần PATCH.
Nhiều-nhiều và hành động không phải CRUD. "Huỷ đơn hàng" không phải là tạo, đọc, sửa hay xoá. Ba cách, và tôi không thấy cách nào sai:
POST /don-hang/5/huy <- tôi dùng cách này
PATCH /don-hang/5 {"trangThai":"HUY"}
POST /lenh-huy {"maDon":"5"}
Cách một rõ ràng nhất và kiểm quyền dễ nhất, dù nó không "thuần REST".
Bốn thứ nhỏ tạo khác biệt lớn
Đặt tên nhất quán. Chọn snake_case hay camelCase rồi giữ nguyên. Trộn hai kiểu trong cùng một API là thứ khách hàng nhớ rất lâu.
Ngày giờ luôn ISO-8601 kèm múi giờ. 2026-08-13T10:00:00Z. Đừng gửi timestamp Unix, đừng gửi dd/MM/yyyy. Bài 62 sê-ri Java đã kể chuyện lệch giờ.
Tiền không dùng số thực. Gửi số nguyên đơn vị nhỏ nhất ("soTien": 150000) kèm mã tiền tệ, hoặc gửi chuỗi. Bài 4 sê-ri Java đã cho thấy 0.1 + 0.2 != 0.3.
Enum gửi bằng chuỗi, đừng gửi số. "trangThai": "DA_GIAO" đọc được trong log; "trangThai": 3 thì phải tra bảng, và chèn giá trị mới vào giữa là đổi ý nghĩa của mọi bản ghi cũ.
Thử ba mươi giây
curl -si -X POST http://localhost:8080/api/... -d '...' | grep -iE "^HTTP|^location"
Ra 200 thay vì 201, hoặc không có dòng Location — client của bạn đang phải đoán tài nguyên vừa tạo nằm ở đâu. Hai thay đổi một dòng mỗi cái.
Ngày mai: phân trang và đánh phiên bản API — kèm một cái bẫy 406 của versioning bằng header.