ResponseEntity: chọn status code và header cho đúng

4 câu hỏi~25 phútTừ khóa: ResponseEntity, 200, 201 Created, Location, 202 Accepted, 204 No Content, 400, 404, 409, 422, ETag, If-None-Match, 304, Cache-Control, Idempotency-Key

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:

Hiện tại controller trả thẳng object, mọi thứ là 200.

2. Vấn đề

3. Giải pháp

ResponseEntity<T> = status + headers + body. Spring có builder gọn:

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ảƯuNhược
Trả objectGọn, mặc định 200Không set header/status động
@ResponseStatus(CREATED)Status cố định gọnKhông có Location, không đổi theo điều kiện
ResponseEntityToàn quyền status + headerDài hơn một chút
HttpServletResponse trực tiếpCấp thấpKhó test, trộn với converter dễ lỗi

7. Khi nào nên dùng / không nên dùng

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ữ:

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ò:

Ả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


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.