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ách | Khi dùng |
|---|---|
| Return DTO | Đơn giản, default 200 OK, không custom header |
ResponseEntity | Custom status, header, optional body |
void + @ResponseStatus | DELETE, 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
| Code | Tên | Khi dùng |
|---|---|---|
| 200 | OK | GET success, PUT success với body trả |
| 201 | Created | POST tạo resource mới — header Location trỏ resource mới |
| 202 | Accepted | Request accepted nhưng chưa process (async job) |
| 204 | No Content | DELETE 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
| Code | Khi dùng |
|---|---|
| 301 | Permanent redirect (URL changed) |
| 302 | Temporary redirect |
| 304 | Not 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
| Code | Khi dùng |
|---|---|
| 400 | Bad Request — validation fail, malformed JSON |
| 401 | Unauthorized — missing/invalid auth |
| 403 | Forbidden — auth OK nhưng không quyền |
| 404 | Not Found — resource không tồn tại |
| 405 | Method Not Allowed — wrong HTTP method |
| 409 | Conflict — duplicate, state conflict |
| 410 | Gone — resource đã xoá vĩnh viễn |
| 415 | Unsupported Media Type — wrong Content-Type |
| 422 | Unprocessable Entity — semantic error |
| 429 | Too Many Requests — rate limit |
2.4 5xx — Server error
| Code | Khi dùng |
|---|---|
| 500 | Internal Server Error — unhandled exception |
| 502 | Bad Gateway — upstream fail |
| 503 | Service Unavailable — overloaded, maintenance |
| 504 | Gateway 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
| Header | Use |
|---|---|
Location | URL của resource mới (201 Created) |
ETag | Cache validator |
Last-Modified | Cache validator (timestamp) |
Cache-Control | Cache directive (no-cache, max-age=3600) |
Content-Disposition | File download (attachment; filename=...) |
X-Total-Count | Pagination total (custom convention) |
X-Request-Id | Correlation ID for tracing |
Retry-After | 429/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 OrderDto → ResponseBodyAdvice 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à đủ.
7. HATEOAS — link relations
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:
PagehoặcStreamingResponseBody.
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 mà @ControllerAdvice map đúng status. Hoặc return ResponseEntity.notFound().
11. 📚 Deep Dive Spring Reference
Spring Framework Reference:
- Spring MVC — Return Values — bảng đầy đủ.
- Spring MVC — ResponseEntity
- Spring MVC — ResponseBodyAdvice
- Spring MVC — Async (StreamingResponseBody, SseEmitter)
HTTP standard:
- RFC 9110 — HTTP Semantics — status code chính chủ.
- RFC 5988 — Web Linking (Link header) — pagination.
- RFC 9457 — Problem Details for HTTP APIs — error format.
Spring HATEOAS:
- Spring HATEOAS Reference — full guide.
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.
ResponseEntitybuilder — 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_VALUEcho 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,Linkheader (RFC 5988), JSON Page object Spring Data. - Error response: Problem Details RFC 9457 — standard 2026.
13. 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;
}
▸
@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;
}- POST tạo → nên 201 Created với Location:201 Created semantic chuẩn. 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); }Location: /api/orders/42cho client biết URL resource mới. - DELETE → nên 204 No Content (no body):204 No Content semantic chuẩn cho DELETE thành công. Method return
@DeleteMapping("/orders/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Long id) { orderService.delete(id); }void— Spring không cố write body. - GET không tìm thấy → nên 404 Not Found, không 500:Hoặc return Optional + ResponseEntity:
@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) { ... }return orderService.findOptional(id) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build());RuntimeExceptiongeneric 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.
Q2Khi nào dùng ResponseEntity thay return DTO trực tiếp? Cho 3 use case cụ thể.▸
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:
- Custom status code (không 200):Hoặc dùng
@PostMapping("/orders") public ResponseEntity<OrderDto> create(@Valid @RequestBody OrderRequest req) { OrderDto created = orderService.create(req); return ResponseEntity.created(uri).body(created); // 201 }@ResponseStatus(HttpStatus.CREATED)trên method nếu không cần Location header. - Custom header (Location, ETag, X-*):
return ResponseEntity.ok() .header("X-Total-Count", String.valueOf(total)) .eTag("\"" + version + "\"") .body(dto); - Conditional response (200 vs 304 vs 404):Method return có thể 200, 304, hoặc 404 tuỳ logic —
@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()); }ResponseEntitycho 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
@ResponseStatustrên method.
Quy tắc: default minimal. Add ResponseEntity khi cần custom — đừng dùng "phòng hờ".
Q3Bạn export 1M order ra CSV. Code đầu tiên load tất cả vào List, OOM. Cách fix với StreamingResponseBody?▸
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ế:
StreamingResponseBodykhô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.
@Transactionaltrê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.
Q4So 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?▸
| Approach | Pros | Cons | When |
|---|---|---|---|
| Header X-Total-Count | Body 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 5988 | Standard 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 object | 1 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.
Q5Tạ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:
- Duplicate HTTP semantic:
"success": truetrùng với HTTP status code 200."status": 404trùng status code response. Client kiểm tra cả 2 = redundant + dễ inconsistent. - Phá REST principle: REST dùng HTTP status code làm meta-information. Wrap trong body là "tunneling" — workaround thiếu HTTP knowledge.
- Client lib không biết: JavaScript fetch, Axios, Retrofit detect status code natively. Wrap → client phải parse
datafield manually. - OpenAPI doc verbose: mỗi schema response wrap 2 layer. Generated client SDK awkward.
- 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"));
}
}Q6App 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?▸
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:
- Client first request: server trả 200 + body +
ETag: "v1". - Client cache body + ETag.
- Client second request gửi
If-None-Match: "v1". - Server fetch order, compute ETag = "v1" → match → return 304 (no body).
- 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...