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".

Nếu API của bạn có nhiều hành động kiểu này hơn là CRUD, có thể REST không phải hình dạng đúng cho bài toán. RPC — gRPC, hoặc chỉ đơn giản là POST tới các endpoint đặt tên theo hành động — trung thực hơn. Đừng bẻ cong nghiệp vụ cho vừa một phong cách API.

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.