Spring Boot/Response handling — ResponseEntity, status code, header, content negotiation
~22 phútREST API với Spring MVCMiễn phí

Response handling — ResponseEntity, status code, header, content negotiation

Spring có 3 cách trả response: return DTO trực tiếp, ResponseEntity, ResponseBodyAdvice. Bài này bóc HTTP status semantics, ResponseEntity builder, custom header, file download/streaming, ResponseBodyAdvice cho global wrapping, và HATEOAS link với Spring HATEOAS.

Bài 03 đã bóc binding request vào method argument. Bài này song song — bóc cách Spring convert return value thành HTTP response. Câu hỏi cốt lõi: khi controller return orderDto, Spring chọn status code nào? Header gì? Format body ra sao? Khi nào dùng ResponseEntity thay return DTO trực tiếp?

Sau bài này, bạn không bao giờ trả "200 OK với body cho mọi case" — bạn biết khi nào 201 Created (POST mới), 204 No Content (DELETE), 202 Accepted (async), 304 Not Modified (cache). HTTP status code không phải decoration — là contract.

1. 3 cách trả response

// Cach 1: return DTO truc tiep (default 200 OK)
@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable Long id) {
    return orderService.findById(id);
}

// Cach 2: ResponseEntity (full control)
@GetMapping("/orders/{id}")
public ResponseEntity<OrderDto> get(@PathVariable Long id) {
    OrderDto order = orderService.findById(id);
    return ResponseEntity.ok()
        .header("X-Cache", "HIT")
        .lastModified(order.updatedAt())
        .body(order);
}

// Cach 3: ResponseEntity.notFound() / ResponseEntity.status(...)
@GetMapping("/orders/{id}")
public ResponseEntity<OrderDto> get(@PathVariable Long id) {
    return orderService.findOptional(id)
        .map(ResponseEntity::ok)
        .orElseGet(() -> ResponseEntity.notFound().build());
}
CáchKhi dùng
Return DTOĐơn giản, default 200 OK, không custom header
ResponseEntityCustom status, header, optional body
void + @ResponseStatusDELETE, action không trả data

2. HTTP status code — ngữ nghĩa quan trọng

Nhiều dev trả 200 cho mọi success — sai. HTTP status code có ngữ nghĩa specific. Bảng cốt lõi:

2.1 2xx — Success

CodeTênKhi dùng
200OKGET success, PUT success với body trả
201CreatedPOST tạo resource mới — header Location trỏ resource mới
202AcceptedRequest accepted nhưng chưa process (async job)
204No ContentDELETE success, PUT/PATCH success không return body
// 201 Created với Location header
@PostMapping("/orders")
public ResponseEntity<OrderDto> create(@Valid @RequestBody OrderRequest req) {
    OrderDto created = orderService.create(req);
    URI location = ServletUriComponentsBuilder.fromCurrentRequest()
        .path("/{id}").buildAndExpand(created.id()).toUri();
    return ResponseEntity.created(location).body(created);
}

// 204 No Content cho DELETE
@DeleteMapping("/orders/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
    orderService.delete(id);
}

// 202 Accepted cho async
@PostMapping("/orders/{id}/export")
public ResponseEntity<Void> exportAsync(@PathVariable Long id) {
    String jobId = exportService.startExportJob(id);
    return ResponseEntity.accepted()
        .header("X-Job-Id", jobId)
        .build();
}

2.2 3xx — Redirection

CodeKhi dùng
301Permanent redirect (URL changed)
302Temporary redirect
304Not Modified (cache hit)

REST API hiếm dùng 3xx trừ 304 cho conditional GET:

@GetMapping("/orders/{id}")
public ResponseEntity<OrderDto> get(
    @PathVariable Long id,
    @RequestHeader(value = "If-Modified-Since", required = false) Long lastFetched
) {
    OrderDto order = orderService.findById(id);
    if (lastFetched != null && order.updatedAt().toEpochMilli() <= lastFetched) {
        return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
    }
    return ResponseEntity.ok()
        .lastModified(order.updatedAt())
        .body(order);
}

2.3 4xx — Client error

CodeKhi dùng
400Bad Request — validation fail, malformed JSON
401Unauthorized — missing/invalid auth
403Forbidden — auth OK nhưng không quyền
404Not Found — resource không tồn tại
405Method Not Allowed — wrong HTTP method
409Conflict — duplicate, state conflict
410Gone — resource đã xoá vĩnh viễn
415Unsupported Media Type — wrong Content-Type
422Unprocessable Entity — semantic error
429Too Many Requests — rate limit

2.4 5xx — Server error

CodeKhi dùng
500Internal Server Error — unhandled exception
502Bad Gateway — upstream fail
503Service Unavailable — overloaded, maintenance
504Gateway Timeout — upstream timeout

3. ResponseEntity — builder pattern

Đầy đủ API:

ResponseEntity.ok(body)                                    // 200 + body
ResponseEntity.ok()                                         // 200 builder, can chain
    .header("X-Custom", "value")
    .contentType(MediaType.APPLICATION_JSON)
    .body(dto)

ResponseEntity.created(uri)                                 // 201 + Location
    .body(dto)

ResponseEntity.accepted()                                   // 202

ResponseEntity.noContent()                                  // 204
    .build()

ResponseEntity.notFound()                                   // 404
    .build()

ResponseEntity.badRequest()                                 // 400
    .body(errorDetails)

ResponseEntity.status(HttpStatus.CONFLICT)                  // any status
    .body(error)

ResponseEntity.unprocessableEntity()                        // 422
    .body(violations)

3.1 Generic body type

public ResponseEntity<OrderDto> get() { ... }
public ResponseEntity<List<OrderDto>> list() { ... }
public ResponseEntity<Void> delete() { ... }
public ResponseEntity<?> dynamic() { ... }                  // ? cho any type

ResponseEntity<Void> cho response không body. ? khi return type động (vd success → DTO, fail → ErrorDto).

4. Custom header

4.1 Single header

return ResponseEntity.ok()
    .header("X-Total-Count", String.valueOf(total))
    .header("X-Page-Number", String.valueOf(page))
    .body(orders);

4.2 Header phổ biến cho REST

HeaderUse
LocationURL của resource mới (201 Created)
ETagCache validator
Last-ModifiedCache validator (timestamp)
Cache-ControlCache directive (no-cache, max-age=3600)
Content-DispositionFile download (attachment; filename=...)
X-Total-CountPagination total (custom convention)
X-Request-IdCorrelation ID for tracing
Retry-After429/503 — bao lâu retry

4.3 ETag + conditional GET

@GetMapping("/orders/{id}")
public ResponseEntity<OrderDto> get(
    @PathVariable Long id,
    @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch
) {
    OrderDto order = orderService.findById(id);
    String etag = "\"" + order.version() + "\"";

    if (etag.equals(ifNoneMatch)) {
        return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
    }

    return ResponseEntity.ok()
        .eTag(etag)
        .body(order);
}

Boot có ShallowEtagHeaderFilter tự handle ETag dựa trên response body hash. Add filter qua:

@Bean
public Filter shallowEtagHeaderFilter() {
    return new ShallowEtagHeaderFilter();
}

Hoặc set property:

spring:
  web:
    resources:
      cache:
        use-last-modified: true

5. File download / streaming

5.1 File download

@GetMapping("/orders/{id}/invoice")
public ResponseEntity<Resource> downloadInvoice(@PathVariable Long id) throws IOException {
    Path file = invoiceService.generatePdf(id);
    Resource resource = new FileSystemResource(file);

    return ResponseEntity.ok()
        .contentType(MediaType.APPLICATION_PDF)
        .header(HttpHeaders.CONTENT_DISPOSITION,
            "attachment; filename=\"invoice-" + id + ".pdf\"")
        .body(resource);
}

Resource interface — Spring abstraction cho file/classpath/URL. Content-Disposition: attachment trigger browser save dialog.

5.2 Streaming large file

@GetMapping("/orders/export")
public ResponseEntity<StreamingResponseBody> export() {
    StreamingResponseBody stream = outputStream -> {
        try (OutputStream os = outputStream;
             Stream<Order> orders = orderService.streamAll()) {
            orders.forEach(order -> {
                try {
                    os.write(toCsv(order).getBytes());
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            });
        }
    };

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType("text/csv"))
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"orders.csv\"")
        .body(stream);
}

StreamingResponseBody cho phép write từng chunk — không load toàn bộ file vào memory. Phù hợp export 1M record.

5.3 Server-Sent Events (SSE)

@GetMapping(value = "/orders/feed", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter feed() {
    SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);

    eventBus.subscribe(event -> {
        try {
            emitter.send(SseEmitter.event()
                .id(String.valueOf(event.id()))
                .name("order-update")
                .data(event));
        } catch (IOException e) {
            emitter.completeWithError(e);
        }
    });

    return emitter;
}

SSE = HTTP/1.1 connection giữ mở, server push event. Phù hợp realtime dashboard, notification.

6. ResponseBodyAdvice — global wrapping

Nếu app có convention "mọi response wrap trong ApiResponse<T>":

{
  "success": true,
  "data": { ... },
  "timestamp": "2026-04-15T10:00:00Z",
  "requestId": "abc123"
}

Implement ResponseBodyAdvice để wrap tự động:

@RestControllerAdvice
public class ApiResponseAdvice implements ResponseBodyAdvice<Object> {

    public boolean supports(MethodParameter returnType,
                             Class<? extends HttpMessageConverter<?>> converterType) {
        // skip if return type is already wrapped
        return !returnType.getParameterType().equals(ApiResponse.class);
    }

    public Object beforeBodyWrite(Object body, MethodParameter returnType,
                                   MediaType contentType, Class<? extends HttpMessageConverter<?>> converterType,
                                   ServerHttpRequest request, ServerHttpResponse response) {
        return ApiResponse.success(body, MDC.get("requestId"));
    }
}

public record ApiResponse<T>(
    boolean success,
    T data,
    Instant timestamp,
    String requestId
) {
    public static <T> ApiResponse<T> success(T data, String requestId) {
        return new ApiResponse<>(true, data, Instant.now(), requestId);
    }
}

Mọi controller return OrderDtoResponseBodyAdvice wrap → client nhận ApiResponse<OrderDto>.

Cảnh báo: wrapping là design choice. Standard REST không wrap. Wrap thường gặp ở enterprise nội bộ. Public API following standard nên không wrap — RFC 9457 Problem Details cho error là đủ.

REST hypermedia: response include link đến action liên quan.

{
  "id": 42,
  "customer": "Alice",
  "total": 99.99,
  "_links": {
    "self": {"href": "/api/orders/42"},
    "cancel": {"href": "/api/orders/42/cancel"},
    "items": {"href": "/api/orders/42/items"}
  }
}

Spring HATEOAS lib:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
@GetMapping("/orders/{id}")
public EntityModel<OrderDto> get(@PathVariable Long id) {
    OrderDto order = orderService.findById(id);
    return EntityModel.of(order,
        linkTo(methodOn(OrderController.class).get(id)).withSelfRel(),
        linkTo(methodOn(OrderController.class).cancel(id)).withRel("cancel"),
        linkTo(methodOn(OrderController.class).items(id)).withRel("items")
    );
}

HATEOAS phổ biến trong API enterprise (banking, insurance) cần discoverability. Public API API thường skip — overhead lớn, client thường biết URL convention sẵn.

8. Pattern thực tế

8.1 Status code chuẩn cho CRUD

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    @GetMapping("/{id}")
    public OrderDto get(@PathVariable Long id) {
        return orderService.findById(id);
        // 200 OK (default), throw 404 if not found
    }

    @GetMapping
    public Page<OrderDto> list(Pageable pageable) {
        return orderService.list(pageable);
        // 200 OK
    }

    @PostMapping
    public ResponseEntity<OrderDto> create(@Valid @RequestBody OrderRequest req) {
        OrderDto created = orderService.create(req);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);
        // 201 Created với Location
    }

    @PutMapping("/{id}")
    public OrderDto update(@PathVariable Long id, @Valid @RequestBody OrderRequest req) {
        return orderService.replace(id, req);
        // 200 OK với body update
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        orderService.delete(id);
        // 204 No Content
    }
}

8.2 Pagination response

@GetMapping
public ResponseEntity<List<OrderDto>> list(Pageable pageable) {
    Page<OrderDto> page = orderService.list(pageable);
    return ResponseEntity.ok()
        .header("X-Total-Count", String.valueOf(page.getTotalElements()))
        .header("X-Page-Number", String.valueOf(page.getNumber()))
        .header("X-Page-Size", String.valueOf(page.getSize()))
        .header("X-Total-Pages", String.valueOf(page.getTotalPages()))
        .body(page.getContent());
}

Hoặc dùng RFC 5988 Link header:

Link: </orders?page=1>; rel="next", </orders?page=10>; rel="last"

Hoặc trả Page<OrderDto> JSON full (Spring Data):

{
  "content": [...],
  "pageable": {...},
  "totalElements": 100,
  "totalPages": 5
}

3 pattern cùng tồn tại — chọn theo team convention.

8.3 Error response — Problem Details

Bài 05 đào sâu. Preview:

{
  "type": "https://api.olhub.org/errors/validation",
  "title": "Validation failed",
  "status": 400,
  "detail": "Field 'customer' must not be blank",
  "instance": "/api/v1/orders",
  "violations": [
    {"field": "customer", "message": "must not be blank"}
  ]
}

RFC 9457 Problem Details — standard 2026 cho REST error response.

9. Vận hành production — caching, compression, streaming, security headers

Response side affect bandwidth, latency, cost. Section này cover production patterns.

9.1 HTTP caching — ETag + Cache-Control

Custom ETag cho REST endpoint:

@GetMapping("/orders/{id}")
public ResponseEntity<OrderDto> get(@PathVariable Long id) {
    Order order = service.findById(id);
    String etag = "\"" + order.getVersion() + "\"";

    return ResponseEntity.ok()
        .eTag(etag)
        .cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS).cachePublic())
        .body(OrderDto.from(order));
}

Client gửi If-None-Match: "5" → server check version chưa đổi → 304 Not Modified, no body. Save 99% bandwidth cho repeat read.

Hoặc Spring ShallowEtagHeaderFilter auto-compute ETag từ body hash:

spring:
  web:
    filter:
      shallow-etag-header:
        enabled: true

Trade-off: vẫn compute body, chỉ save bandwidth không CPU.

9.2 Response compression — gzip

server:
  compression:
    enabled: true
    mime-types: application/json,text/html,text/css,application/xml
    min-response-size: 1024              # vuot 1KB compress

JSON response 100KB → khoảng 10KB gzip. Bandwidth save 90%, CPU cost minor (compression chạy native zlib).

Brotli compression (tốt hơn gzip 15-20%) cần plugin hoặc CDN/Cloudflare layer.

9.3 Streaming large response — StreamingResponseBody

@GetMapping("/exports/orders.csv")
public ResponseEntity<StreamingResponseBody> exportCsv() {
    StreamingResponseBody stream = out -> {
        try (PrintWriter w = new PrintWriter(out)) {
            orderService.streamAll().forEach(order -> {
                w.printf("%d,%s,%s%n", order.id(), order.customer(), order.total());
            });
        }
    };

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType("text/csv"))
        .header("Content-Disposition", "attachment; filename=\"orders.csv\"")
        .body(stream);
}

Stream từ DB qua HTTP — không load full into memory. Service dùng Stream<Order> với @QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "1000")).

9.4 Server-Sent Events — real-time push

@GetMapping(value = "/orders/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
    SseEmitter emitter = new SseEmitter(30_000L);    // 30s timeout
    eventBus.subscribe(orderEvent -> {
        try {
            emitter.send(orderEvent);
        } catch (IOException e) {
            emitter.completeWithError(e);
        }
    });
    return emitter;
}

Client EventSource reconnect tự động. Cảnh báo: connection persist → tăng max-connections Tomcat, hoặc switch WebFlux (Module 10) cho thousands concurrent SSE.

9.5 Security headers — defense in depth

Mọi response REST API nên có:

@Configuration
public class SecurityHeadersConfig {

    @Bean
    public FilterRegistrationBean<HeaderFilter> headerFilter() {
        return new FilterRegistrationBean<>(new HeaderFilter() {
            void apply(HttpServletResponse res) {
                res.setHeader("X-Content-Type-Options", "nosniff");
                res.setHeader("X-Frame-Options", "DENY");
                res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
                res.setHeader("Content-Security-Policy", "default-src 'self'");
                res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
            }
        });
    }
}

Spring Security tự config nhiều header này (Module 05 đào sâu).

9.6 Failure runbook

Mode 1 — OOM với large response:

  • Triệu chứng: heap dump cho thấy byte[] lớn, GC pause.
  • Diagnose: endpoint trả List<X> không pagination.
  • Remediate: Page hoặc StreamingResponseBody.

Mode 2 — 502/504 từ load balancer upstream:

  • Triệu chứng: AWS ALB / Nginx timeout 60s.
  • Remediate: tăng LB timeout cho long endpoint, hoặc tách endpoint async (@Async + 202 Accepted + polling pattern).

Mode 3 — Slow client hold connection (Slowloris attack):

  • Mitigation: Tomcat connection-timeout: 20000, rate limiter per IP.

10. Pitfall tổng hợp

Nhầm 1: 200 OK cho mọi success. ✅ POST tạo → 201. DELETE → 204. Async → 202. Status code có ngữ nghĩa.

Nhầm 2: Quên Location header với 201 Created. ✅ ResponseEntity.created(uri).body(created) set Location chuẩn HTTP.

Nhầm 3: Trả 200 với body {"error": "..."} cho client error. ✅ Client error = 4xx. Body Problem Details RFC 9457. Status code 200 với error body breaks client expectation.

Nhầm 4: Wrap response không cần thiết.

return ResponseEntity.ok(orderDto);    // verbose nếu chỉ default 200

✅ Return OrderDto trực tiếp đủ. ResponseEntity chỉ khi cần custom status/header.

Nhầm 5: Stream response giữ memory.

List<Order> all = orderService.findAll();    // 1M record vao memory → OOM
return ResponseEntity.ok(all);

✅ Dùng StreamingResponseBody hoặc Page với pagination.

Nhầm 6: Quên Content-Disposition cho file download.

return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(resource);

Browser hiển thị PDF inline thay download. ✅ header(CONTENT_DISPOSITION, "attachment; filename=\"...\"").

Nhầm 7: Throw exception cho client error.

@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable Long id) {
    OrderDto order = repo.findById(id);
    if (order == null) throw new RuntimeException("not found");    // 500!
    return order;
}

✅ Throw exception specific@ControllerAdvice map đúng status. Hoặc return ResponseEntity.notFound().

11. 📚 Deep Dive Spring Reference

📚 Tài liệu chính chủ

Spring Framework Reference:

HTTP standard:

Spring HATEOAS:

Pattern reference:

Tool:

  • HTTPie / Bruno / Postman — test status code response.
  • curl -v — verbose mode để inspect response header.

12. Tóm tắt

  • 3 cách trả response: return DTO trực tiếp (default 200), ResponseEntity (custom status/header), void + @ResponseStatus.
  • HTTP status code có ngữ nghĩa: 201 Created (POST mới), 204 No Content (DELETE), 202 Accepted (async), 304 Not Modified (cache hit), 4xx client error, 5xx server error.
  • ResponseEntity builder — chain .ok(), .created(uri), .notFound(), .status(...), .header(...), .body(...).
  • Header phổ biến REST: Location (201), ETag/Last-Modified (cache), Content-Disposition (download), X-Total-Count (pagination), Retry-After (429/503).
  • ETag conditional GET: client gửi If-None-Match, server compare → 304 nếu match.
  • File download: Resource + Content-Disposition: attachment; filename="...". Streaming → StreamingResponseBody.
  • SSE: SseEmitter + produces = TEXT_EVENT_STREAM_VALUE cho realtime push.
  • ResponseBodyAdvice: global wrap response. Pattern enterprise dùng — public API thường skip.
  • HATEOAS: Spring HATEOAS lib với EntityModel, linkTo, methodOn — overhead lớn, dùng khi cần discoverability cao.
  • Pagination 3 cách: header X-Total-Count, Link header (RFC 5988), JSON Page object Spring Data.
  • Error response: Problem Details RFC 9457 — standard 2026.

13. Tự kiểm tra

Tự kiểm tra
Q1
Đoạn sau có 3 vấn đề về status code. Liệt kê + fix.
@PostMapping("/orders")
public OrderDto create(@Valid @RequestBody OrderRequest req) {
  return orderService.create(req);    // van 200 OK?
}

@DeleteMapping("/orders/{id}")
public OrderDto delete(@PathVariable Long id) {
  orderService.delete(id);
  return null;                           // body null + 200?
}

@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable Long id) {
  OrderDto order = orderService.findById(id);
  if (order == null) throw new RuntimeException("not found");    // 500?
  return order;
}
  1. POST tạo → nên 201 Created với Location:
    @PostMapping("/orders")
    public ResponseEntity<OrderDto> create(@Valid @RequestBody OrderRequest req) {
      OrderDto created = orderService.create(req);
      URI location = ServletUriComponentsBuilder.fromCurrentRequest()
          .path("/{id}").buildAndExpand(created.id()).toUri();
      return ResponseEntity.created(location).body(created);
    }
    201 Created semantic chuẩn. Header Location: /api/orders/42 cho client biết URL resource mới.
  2. DELETE → nên 204 No Content (no body):
    @DeleteMapping("/orders/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
      orderService.delete(id);
    }
    204 No Content semantic chuẩn cho DELETE thành công. Method return void — Spring không cố write body.
  3. GET không tìm thấy → nên 404 Not Found, không 500:
    @GetMapping("/orders/{id}")
    public OrderDto get(@PathVariable Long id) {
      return orderService.findById(id);
          // service throw OrderNotFoundException
    }
    
    // In @ControllerAdvice (Bài 05):
    @ExceptionHandler(OrderNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ProblemDetail handleNotFound(OrderNotFoundException ex) { ... }
    Hoặc return Optional + ResponseEntity:
    return orderService.findOptional(id)
      .map(ResponseEntity::ok)
      .orElseGet(() -> ResponseEntity.notFound().build());
    RuntimeException generic không có mapping → Spring default 500. Specific exception mới có nghĩa.

Bài học: HTTP status code không phải decoration — là contract giữa server và client. Client (frontend, mobile) dựa vào status để decide UI state. 200 vs 201 vs 204 khác nhau intentional.

Q2
Khi nào dùng ResponseEntity thay return DTO trực tiếp? Cho 3 use case cụ thể.

Default: return DTO trực tiếp — concise, less verbose. Dùng ResponseEntity khi:

  1. Custom status code (không 200):
    @PostMapping("/orders")
    public ResponseEntity<OrderDto> create(@Valid @RequestBody OrderRequest req) {
      OrderDto created = orderService.create(req);
      return ResponseEntity.created(uri).body(created);    // 201
    }
    Hoặc dùng @ResponseStatus(HttpStatus.CREATED) trên method nếu không cần Location header.
  2. Custom header (Location, ETag, X-*):
    return ResponseEntity.ok()
      .header("X-Total-Count", String.valueOf(total))
      .eTag("\"" + version + "\"")
      .body(dto);
  3. Conditional response (200 vs 304 vs 404):
    @GetMapping("/orders/{id}")
    public ResponseEntity<OrderDto> get(
      @PathVariable Long id,
      @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch
    ) {
      return orderService.findOptional(id).map(order -> {
          String etag = "\"" + order.version() + "\"";
          if (etag.equals(ifNoneMatch)) {
              return ResponseEntity.<OrderDto>status(HttpStatus.NOT_MODIFIED).build();
          }
          return ResponseEntity.ok().eTag(etag).body(order);
      }).orElseGet(() -> ResponseEntity.notFound().build());
    }
    Method return có thể 200, 304, hoặc 404 tuỳ logic — ResponseEntity cho phép.

Khi nào không dùng:

  • Endpoint đơn giản: GET trả DTO 200 OK → return DTO trực tiếp.
  • Status code cố định: dùng @ResponseStatus trên method.

Quy tắc: default minimal. Add ResponseEntity khi cần custom — đừng dùng "phòng hờ".

Q3
Bạn export 1M order ra CSV. Code đầu tiên load tất cả vào List, OOM. Cách fix với StreamingResponseBody?

Vấn đề: List<Order> all = orderService.findAll() load 1M record vào memory. JSON serialize cũng tốn memory. App OOM.

Fix với StreamingResponseBody:

@GetMapping("/orders/export")
public ResponseEntity<StreamingResponseBody> exportCsv(
  @RequestParam(required = false) OrderStatus status
) {
  StreamingResponseBody stream = outputStream -> {
      try (PrintWriter writer = new PrintWriter(outputStream);
           Stream<Order> orders = orderService.streamByStatus(status)) {

          // Header
          writer.println("id,customer,total,createdAt,status");

          // Stream rows
          orders.forEach(order -> {
              writer.printf("%d,%s,%s,%s,%s%n",
                  order.id(),
                  escapeCsv(order.customer()),
                  order.total(),
                  order.createdAt(),
                  order.status());
          });
      }
  };

  return ResponseEntity.ok()
      .contentType(MediaType.parseMediaType("text/csv"))
      .header(HttpHeaders.CONTENT_DISPOSITION,
          "attachment; filename=\"orders-" + LocalDate.now() + ".csv\"")
      .body(stream);
}

Service layer dùng Stream:

@Service
public class OrderService {

  @Transactional(readOnly = true)
  public Stream<Order> streamByStatus(OrderStatus status) {
      // Spring Data Stream<T> — fetch row-by-row qua cursor
      return orderRepository.streamByStatus(status);
  }
}

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

  @QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "1000"))
  Stream<Order> streamByStatus(OrderStatus status);
}

Cơ chế:

  • StreamingResponseBody không buffer toàn body — write trực tiếp vào servlet output stream.
  • Spring không hold connection → release thread sớm (Boot 3.2+ Virtual Threads thậm chí tốt hơn).
  • Spring Data Stream<T> dùng JDBC ResultSet cursor — fetch row-by-row, không load all.
  • Memory usage: O(1) thay O(N).

Pitfall:

  • Phải close Stream qua try-with-resources — không close → connection leak.
  • @Transactional trên service method — Stream cần open transaction để hold cursor. Boot tự handle qua transaction propagation.
  • Cảnh giác lazy loading: nếu Order có association lazy, fetch trong stream loop = N+1 query. Fetch eager hoặc projection.

Pattern này standard cho export endpoint — production deploy hàng triệu record không OOM.

Q4
So sánh 3 cách trả pagination response: header X-Total-Count, Link header (RFC 5988), JSON Page object Spring Data. Khi nào nên chọn cái nào?
ApproachProsConsWhen
Header X-Total-CountBody sạch (chỉ array). Dễ parse client (header).Không standard — convention. Cần expose CORS header.Internal API team thống nhất convention. JSON:API style.
Link header RFC 5988Standard chính thức. Self-discoverable next/prev page.Phức tạp parse. Ít client lib support.Public API enterprise (vd GitHub API). Hypermedia-driven.
JSON Page object1 request đủ info (content + total + page metadata). Spring Data native.Body verbose. Coupling vào Spring Data response shape.App internal, simple. Frontend dùng cùng team.

Code mẫu mỗi cách:

1. X-Total-Count:

@GetMapping
public ResponseEntity<List<OrderDto>> list(Pageable pageable) {
  Page<OrderDto> page = orderService.list(pageable);
  return ResponseEntity.ok()
      .header("X-Total-Count", String.valueOf(page.getTotalElements()))
      .header("X-Page-Number", String.valueOf(page.getNumber()))
      .header("X-Page-Size", String.valueOf(page.getSize()))
      .body(page.getContent());
}

2. Link header RFC 5988:

Link: </orders?page=0>; rel="first",
    </orders?page=1>; rel="next",
    </orders?page=4>; rel="last"

Spring HATEOAS có helper:

PagedModel<EntityModel<OrderDto>> paged = pagedAssembler.toModel(page, ...);
return paged;

3. JSON Page object:

@GetMapping
public Page<OrderDto> list(Pageable pageable) {
  return orderService.list(pageable);
}

// Response:
{
"content": [...],
"totalElements": 100,
"totalPages": 5,
"number": 0,
"size": 20,
"first": true,
"last": false,
"pageable": {...}
}

Recommend 2026:

  • Internal API + frontend cùng team: JSON Page — đơn giản nhất.
  • Public API: Link header chuẩn HTTP. Frontend mọi tech parse được.
  • Mobile app + bandwidth-sensitive: X-Total-Count — body nhẹ.

Quy tắc: chọn 1 và stick. Mix gây confusion cho consumer.

Q5
Tại sao response wrapping ("mọi response wrap trong ApiResponse") thường không recommend cho public API? Khi nào nên dùng?

Response wrapping:

{
"success": true,
"data": { ... },
"message": "OK",
"timestamp": "2026-04-15T10:00:00Z"
}

Vấn đề với public API:

  1. Duplicate HTTP semantic: "success": true trùng với HTTP status code 200. "status": 404 trùng status code response. Client kiểm tra cả 2 = redundant + dễ inconsistent.
  2. Phá REST principle: REST dùng HTTP status code làm meta-information. Wrap trong body là "tunneling" — workaround thiếu HTTP knowledge.
  3. Client lib không biết: JavaScript fetch, Axios, Retrofit detect status code natively. Wrap → client phải parse data field manually.
  4. OpenAPI doc verbose: mỗi schema response wrap 2 layer. Generated client SDK awkward.
  5. Pagination/file download không fit: file binary không thể wrap. Pagination dễ trùng metadata (X-Total-Count vs ApiResponse.meta.total).

Khi nào nên wrap:

  • Internal API + team thống nhất convention: mọi service trả format giống nhau cho debug/log dễ.
  • API dùng qua transport không HTTP: WebSocket, SSE — không có HTTP status code, cần status trong body.
  • Frontend lib cần consistent shape: 1 số UI lib (vd Ant Design Pro) expect {success, data, errorMessage} shape.
  • Compliance/audit: response phải có requestId, timestamp, signature — dễ wrap.

Standard 2026 cho public API:

  • Success: trả DTO trực tiếp. HTTP 200/201/204 cho status.
  • Error: Problem Details RFC 9457. HTTP 4xx/5xx cho status.
  • Metadata: response header (X-Request-Id, X-Total-Count).

Implement nếu cần:

@RestControllerAdvice
public class ApiResponseAdvice implements ResponseBodyAdvice<Object> {

  public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
      // Skip nếu đã wrap, response file, error
      return !returnType.getParameterType().equals(ApiResponse.class)
          && !returnType.getParameterType().equals(Resource.class)
          && !returnType.getParameterType().equals(ProblemDetail.class);
  }

  public Object beforeBodyWrite(Object body, ...) {
      return ApiResponse.success(body, MDC.get("requestId"));
  }
}
Q6
App có endpoint GET /orders/{id} trả OrderDto. Yêu cầu thêm caching: client gửi nhiều lần, server không re-fetch DB. Setup ETag + 304 ra sao?

2 cách: Manual ETag hoặc Spring's ShallowEtagHeaderFilter.

Cách 1 — Manual (control rõ ràng):

@GetMapping("/orders/{id}")
public ResponseEntity<OrderDto> get(
  @PathVariable Long id,
  @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch
) {
  OrderDto order = orderService.findById(id);
  String etag = "\"" + order.version() + "\"";    // version từ DB column

  // Client cache hit
  if (etag.equals(ifNoneMatch)) {
      return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
          .eTag(etag)
          .build();
  }

  return ResponseEntity.ok()
      .eTag(etag)
      .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
      .body(order);
}

Flow:

  1. Client first request: server trả 200 + body + ETag: "v1".
  2. Client cache body + ETag.
  3. Client second request gửi If-None-Match: "v1".
  4. Server fetch order, compute ETag = "v1" → match → return 304 (no body).
  5. Client dùng cached body.

Cách 2 — ShallowEtagHeaderFilter (auto):

@Configuration
public class WebConfig {
  @Bean
  public Filter shallowEtagHeaderFilter() {
      return new ShallowEtagHeaderFilter();
  }
}

Cơ chế: filter tự compute ETag = MD5 hash của response body. Client gửi If-None-Match match → filter override response status thành 304.

Trade-off cách 2:

  • Pros: auto, no code per endpoint.
  • Cons: server vẫn compute full response body (chỉ skip network transmission). Không tiết kiệm DB query / serialize cost. ETag dựa trên body bytes, không phải version logic.

Cách 1 (manual) tốt hơn cho production:

  • Skip DB fetch nếu version match (cần cache version riêng — Redis).
  • ETag dựa trên domain version (column `version` trong DB) — semantic hơn body hash.
  • Cache control header explicit.

Pattern enterprise: dùng cả 2 — Cache-Control + ETag manual cho domain. ShallowEtag fallback cho endpoint không quan trọng.

Bài tiếp theo: Exception handling — @ExceptionHandler, @ControllerAdvice, Problem Details RFC 9457

Bài này có giúp bạn hiểu bản chất không?

Bình luận (0)

Đang tải...