ResponseEntity: chọn status code và header cho đúng
Tiếp đi: ResponseEntity. API tạo order của mình đang trả 200 kèm body cho mọi trường hợp, như vậy có sao không?
Chạy được, nhưng client (mobile app, payment-service, API gateway) mất thông tin. Status code và header là một phần của "hợp đồng" API, không phải trang trí.
1. Bài toán
shop-app có các API:
POST /api/orders: tạo order.GET /api/orders/{id}: xem order.DELETE /api/orders/{id}: hủy order.POST /api/orders/{id}/invoice: yêu cầu xuất PDF hóa đơn, mất 10–30 giây.
Hiện tại controller trả thẳng object, mọi thứ là 200.
2. Vấn đề
- Client không biết order vừa tạo nằm ở URL nào, phải tự ghép
/api/orders/+ id. - Mobile retry khi mạng chập chờn → tạo 2 order, trừ tiền 2 lần.
- Xuất invoice giữ request 30 giây, gateway timeout (xem bài timeouts).
GETdanh sách sản phẩm tải lại toàn bộ JSON dù không đổi.- Lỗi nghiệp vụ ("hết hàng") trả 200 với
{"success": false}: monitoring đếm 5xx/4xx không thấy gì.
3. Giải pháp
ResponseEntity<T> = status + headers + body. Spring có builder gọn:
ResponseEntity.ok(body),created(uri),accepted(),noContent(),notFound(),status(HttpStatus.CONFLICT)..eTag(...),.cacheControl(CacheControl...),.location(uri),.header(name, value).
Lỗi (400/404/409/422) nên ném exception và để @RestControllerAdvice trả ProblemDetail (bài 4.2.7), không tự build trong từng controller.
4. Code từng bước
Bước 1: Dependency. Chỉ cần spring-boot-starter-web.
Bước 2: Config
spring:
mvc:
problemdetails:
enabled: true # lỗi chuẩn RFC 9457 (application/problem+json)
Bước 3: Code Java
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final InvoiceService invoiceService;
// 201 + Location; idempotent theo Idempotency-Key
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody CreateOrderRequest req) {
CreateResult result = orderService.createOnce(key, req);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(result.order().id()).toUri();
if (result.alreadyExisted()) {
// retry cùng key: trả lại order cũ, không tạo mới
return ResponseEntity.ok().location(location).body(result.order());
}
return ResponseEntity.created(location).body(result.order());
}
// 200 + ETag; 304 nếu client đã có bản mới nhất
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> get(@PathVariable Long id, WebRequest request) {
OrderResponse order = orderService.get(id); // không có → OrderNotFoundException → 404
String etag = "\"" + order.version() + "\"";
if (request.checkNotModified(etag)) {
return null; // Spring đã set 304
}
return ResponseEntity.ok()
.eTag(etag)
.cacheControl(CacheControl.noCache().cachePrivate()) // phải revalidate
.body(order);
}
// 204: thành công, không có body
@DeleteMapping("/{id}")
public ResponseEntity<Void> cancel(@PathVariable Long id) {
orderService.cancel(id); // đã ship → OrderStateException → 409
return ResponseEntity.noContent().build();
}
// 202: đã nhận, xử lý sau
@PostMapping("/{id}/invoice")
public ResponseEntity<Void> requestInvoice(@PathVariable Long id) {
String jobId = invoiceService.enqueue(id);
return ResponseEntity.accepted()
.location(URI.create("/api/invoice-jobs/" + jobId)) // client poll ở đây
.build();
}
}
Service idempotent: lưu key với unique constraint.
@Transactional
public CreateResult createOnce(String key, CreateOrderRequest req) {
return orderRepository.findByIdempotencyKey(key)
.map(o -> new CreateResult(toResponse(o), true))
.orElseGet(() -> {
Order o = orderRepository.save(Order.create(key, req)); // UNIQUE(idempotency_key)
return new CreateResult(toResponse(o), false);
});
}
Hai request song song cùng key: một bên vi phạm unique → bắt DataIntegrityViolationException, đọc lại và trả order đã có.
Exception → status trong advice:
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail notFound(OrderNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(OrderStateException.class) // trạng thái hiện tại không cho phép
ProblemDetail conflict(OrderStateException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}
@ExceptionHandler(OutOfStockException.class) // dữ liệu hợp lệ cú pháp, sai nghiệp vụ
ProblemDetail unprocessable(OutOfStockException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage());
}
Bước 4: Test bằng curl
curl -i -X POST localhost:8080/api/orders \
-H 'Content-Type: application/json' -H 'Idempotency-Key: 7f3a-001' \
-d '{"productId":10,"quantity":2}'
# HTTP/1.1 201
# Location: http://localhost:8080/api/orders/42
# gửi lại y hệt
# HTTP/1.1 200
# Location: http://localhost:8080/api/orders/42 ← cùng order, không tạo mới
curl -i localhost:8080/api/orders/42 # 200, ETag: "3"
curl -i localhost:8080/api/orders/42 -H 'If-None-Match: "3"'
# HTTP/1.1 304 (không body)
curl -i -X DELETE localhost:8080/api/orders/42 # 204
curl -i -X POST localhost:8080/api/orders/42/invoice # 202, Location: /api/invoice-jobs/...
5. Cách hoạt động
Controller trả ResponseEntity
│
▼
HttpEntityMethodProcessor
├─ set status + headers vào HttpServletResponse
├─ ETag/Last-Modified? so với If-None-Match → 304, bỏ body
└─ body != null → HttpMessageConverter (Jackson) ghi JSON
Exception ném ra
│
▼
@RestControllerAdvice → ProblemDetail → 4xx application/problem+json
Chọn status code
POST tạo mới ............ 201 + Location (+ body)
POST retry cùng key ..... 200 + order cũ (hoặc 201 lặp lại, miễn không tạo bản mới)
Nhận việc chạy nền ...... 202 + Location job
Xóa / cập nhật không body 204
JSON sai, thiếu field ... 400
Không tìm thấy .......... 404
Xung đột trạng thái ..... 409 (hủy order đã ship, version cũ)
Sai nghiệp vụ ........... 422 (hết hàng, coupon hết hạn)
6. So sánh
| Cách trả | Ưu | Nhược |
|---|---|---|
| Trả object | Gọn, mặc định 200 | Không set header/status động |
@ResponseStatus(CREATED) | Status cố định gọn | Không có Location, không đổi theo điều kiện |
ResponseEntity | Toàn quyền status + header | Dài hơn một chút |
HttpServletResponse trực tiếp | Cấp thấp | Khó test, trộn với converter dễ lỗi |
7. Khi nào nên dùng / không nên dùng
- Dùng
ResponseEntitykhi status/header phụ thuộc kết quả: create, conditional GET, 202, download (bài sau). - GET đơn giản luôn 200: trả object là đủ.
- Không build lỗi bằng
ResponseEntity.badRequest().body(...)rải rác; để advice lo. - Không trả 200 kèm
"success": false.
400 với 422 mình hay lẫn. Hết hàng thì trả 400, 409 hay 422 mới đúng?
Bạn phân vân đúng chỗ, vì các team làm khác nhau. Một quy ước dễ giữ:
- 400: request sai hình thức. JSON hỏng, thiếu field,
quantity = -1(lỗi@Valid). - 422: request đúng hình thức, nhưng không xử lý được theo nghiệp vụ. Hết hàng, coupon hết hạn.
- 409: xung đột với trạng thái hiện tại của resource. Hủy order đã ship, cập nhật với version cũ (optimistic lock).
Nhiều team chỉ dùng 400 cho cả validation lẫn nghiệp vụ, và phân biệt bằng field type/code trong ProblemDetail. Cũng được, miễn nhất quán và có tài liệu. Điều quan trọng nhất: lỗi do client là 4xx, lỗi server là 5xx.
ETag với Cache-Control khác nhau chỗ nào? Dùng một cái là đủ không?
Gần đúng là chúng đều về cache, chỉ cần tách vai trò:
- Cache-Control: nói client/CDN được giữ response bao lâu, có cần hỏi lại không.
max-age=600→ 10 phút không gọi server. - ETag: "dấu vân tay" phiên bản. Khi phải hỏi lại, client gửi
If-None-Match; không đổi → 304, không gửi body.
Ảnh sản phẩm (không đổi theo URL có hash): Cache-Control: public, max-age=31536000, immutable
Danh sách sản phẩm: Cache-Control: public, max-age=60 + ETag
Order của user: Cache-Control: private, no-cache + ETag
Dữ liệu nhạy cảm (thông tin thanh toán): Cache-Control: no-store
Muốn ETag cho mọi GET mà lười tự tính: ShallowEtagHeaderFilter hash body. Nó tiết kiệm băng thông nhưng server vẫn chạy query và serialize. ETag từ version của Entity mới tiết kiệm cả CPU/DB. ETag còn dùng với If-Match cho PUT để chống ghi đè (412 Precondition Failed).
Phỏng vấn hỏi về ResponseEntity và status code thì trả lời sao?
Q1. Khi nào dùng ResponseEntity thay vì trả object?
🇻🇳 Tiếng Việt:
Khi status hoặc header phụ thuộc vào kết quả xử lý: tạo mới trả 201 kèm Location, việc chạy nền trả 202, xóa trả 204, GET có điều kiện trả ETag hoặc 304, download cần Content-Disposition. Nếu luôn là 200 với JSON thì trả object cho gọn. Lỗi em không build trong controller mà ném exception cho ControllerAdvice trả ProblemDetail.
🇬🇧 English:
When the status or headers depend on the outcome: 201 with a Location header for creation, 202 for background work, 204 for deletes, ETag or 304 for conditional GETs, Content-Disposition for downloads. If it is always 200 with JSON, I just return the object. Errors are not built in the controller; I throw exceptions and let a ControllerAdvice return a ProblemDetail.
Q2. Làm sao để POST tạo order an toàn khi client retry?
🇻🇳 Tiếng Việt:
POST không idempotent nên em yêu cầu client gửi header Idempotency-Key. Server lưu key với unique constraint cùng order. Request lặp lại cùng key thì trả lại order đã tạo, không tạo mới. Hai request song song thì unique constraint chặn một bên, bên đó đọc lại kết quả.
🇬🇧 English:
POST is not idempotent, so the client sends an Idempotency-Key header. The server stores the key with a unique constraint alongside the order. A repeated request with the same key returns the existing order instead of creating a new one. If two requests race, the unique constraint rejects one and that one reads back the stored result.
Q3. Phân biệt 400, 404, 409, 422.
🇻🇳 Tiếng Việt:
400 là request sai cú pháp hoặc không qua validation. 404 là resource không tồn tại. 409 là xung đột với trạng thái hiện tại, như hủy order đã giao hoặc version cũ. 422 là request hợp lệ về hình thức nhưng vi phạm quy tắc nghiệp vụ, như hết hàng. Quan trọng nhất là nhất quán trong toàn hệ thống.
🇬🇧 English:
400 means the request is malformed or fails validation. 404 means the resource does not exist. 409 means a conflict with the current state, like cancelling a shipped order or sending a stale version. 422 means the request is well-formed but breaks a business rule, like insufficient stock. What matters most is being consistent across the system.
Cách nhớ ngắn gọn
- 201 + Location (tạo), 202 + Location job (chạy nền), 204 (không body).
- 400 hình thức, 422 nghiệp vụ, 409 trạng thái, 404 không có.
- Cache-Control = giữ bao lâu; ETag = đã đổi chưa (304).
- Retry an toàn = Idempotency-Key + unique constraint.
Tự kiểm tra
POST tạo order thành công nên trả gì?
201 Created, header Location trỏ tới /api/orders/{id}, có thể kèm body.
Xuất PDF hóa đơn mất 30 giây, API nên trả gì?
202 Accepted kèm Location của job để client poll (hoặc nhận thông báo qua SSE, bài 4.2.12).
Client gửi If-None-Match: "3" và order vẫn version 3 thì sao?
304 Not Modified, không có body.
Hủy order đã giao: status nào?
409 Conflict (xung đột trạng thái hiện tại).
ShallowEtagHeaderFilter tiết kiệm được gì và không tiết kiệm được gì?
Tiết kiệm băng thông (304); không tiết kiệm query DB và serialize vì phải tạo body để hash.