Spring Boot
Response Handling
Spring Boot serializes controller return values to the HTTP response body using HttpMessageConverters. ResponseEntity gives full control over status, headers, and body. This entry covers return type options, status codes, response headers, caching headers, async responses, and shaping JSON output.
Return Type Options
Controller methods can return a plain object T, ResponseEntity<T>, Optional<T>, or async types such as CompletableFuture<T> and Mono<T>. Prefer ResponseEntity<T> for REST endpoints so the status code and headers are explicit and visible at the method signature level.
Java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// ── Plain T — Spring sets 200 OK automatically ────────────────────
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id);
}
// ── ResponseEntity<T> — explicit status and headers ───────────────
@GetMapping("/entity/{id}")
public ResponseEntity<UserResponse> findByIdEntity(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
// ── ResponseEntity<Void> — no body ────────────────────────────────
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build(); // 204
}
// ── Optional<T> — Spring returns 200 or 404 automatically ─────────
@GetMapping("/optional/{id}")
public Optional<UserResponse> findOptional(@PathVariable Long id) {
return userService.findOptional(id);
// present → 200 with body; empty → 404 No body
}
// ── CompletableFuture<T> — async, non-blocking ────────────────────
@GetMapping("/async/{id}")
public CompletableFuture<UserResponse> findAsync(@PathVariable Long id) {
return userService.findByIdAsync(id);
}
// ── @ResponseStatus — fixed status on the method ─────────────────
@PostMapping("/simple")
@ResponseStatus(HttpStatus.CREATED) // always 201, no ResponseEntity needed
public UserResponse createSimple(
@RequestBody @Valid CreateUserRequest req) {
return userService.create(req);
}
}HTTP Status Codes
Return the correct HTTP status for every operation. Incorrect status codes break caching, break client error handling, and violate the REST contract. Use ResponseEntity builder methods to set the status explicitly.
Java
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
// ── 200 OK — successful retrieval ─────────────────────────────────
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> findById(@PathVariable Long id) {
return ResponseEntity.ok(orderService.findById(id));
}
// ── 201 Created — successful creation with Location ───────────────
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestBody @Valid CreateOrderRequest req,
UriComponentsBuilder ucb) {
OrderResponse created = orderService.create(req);
return ResponseEntity.created(
ucb.path("/api/v1/orders/{id}")
.buildAndExpand(created.id()).toUri())
.body(created);
}
// ── 202 Accepted — async processing started ───────────────────────
@PostMapping("/{id}/ship")
public ResponseEntity<Void> ship(@PathVariable Long id) {
orderService.scheduleShipment(id);
return ResponseEntity.accepted().build();
}
// ── 204 No Content — successful update/delete with no body ────────
@PatchMapping("/{id}")
public ResponseEntity<Void> patch(
@PathVariable Long id,
@RequestBody @Valid PatchOrderRequest req) {
orderService.patch(id, req);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
orderService.delete(id);
return ResponseEntity.noContent().build();
}
// ── 206 Partial Content — range response ─────────────────────────
@GetMapping("/{id}/invoice")
public ResponseEntity<byte[]> invoice(
@PathVariable Long id,
@RequestHeader(value = HttpHeaders.RANGE,
required = false) String range) {
return orderService.getInvoice(id, range);
}
// ── 207 Multi-Status — batch operation results ────────────────────
@PostMapping("/batch-cancel")
public ResponseEntity<List<BatchResult>> batchCancel(
@RequestBody List<Long> ids) {
return ResponseEntity.status(HttpStatus.MULTI_STATUS)
.body(orderService.cancelBatch(ids));
}
}Setting Response Headers
Add response headers using ResponseEntity's header builder methods or by injecting HttpServletResponse. Common use cases include Content-Disposition for downloads, Location for 201 Created, ETag for caching, and custom tracing headers.
Java
@RestController
@RequestMapping("/api/v1/files")
@RequiredArgsConstructor
public class FileController {
private final FileService fileService;
// ── Content-Disposition — trigger download ────────────────────────
@GetMapping("/{id}/download")
public ResponseEntity<Resource> download(@PathVariable Long id) {
FileResponse file = fileService.findById(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(file.filename(), StandardCharsets.UTF_8)
.build().toString())
.contentType(MediaType.parseMediaType(file.contentType()))
.contentLength(file.sizeBytes())
.body(file.resource());
}
// ── ETag — conditional caching ────────────────────────────────────
@GetMapping("/{id}")
public ResponseEntity<FileResponse> findById(
@PathVariable Long id,
@RequestHeader(value = HttpHeaders.IF_NONE_MATCH,
required = false) String ifNoneMatch) {
FileResponse file = fileService.findById(id);
String etag = """ + file.checksum() + """;
if (etag.equals(ifNoneMatch)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
}
return ResponseEntity.ok()
.eTag(etag)
.lastModified(file.updatedAt())
.body(file);
}
// ── Multiple custom headers ────────────────────────────────────────
@PostMapping
public ResponseEntity<FileResponse> upload(
@RequestParam MultipartFile file,
UriComponentsBuilder ucb) {
FileResponse uploaded = fileService.store(file);
return ResponseEntity.created(
ucb.path("/api/v1/files/{id}")
.buildAndExpand(uploaded.id()).toUri())
.header("X-File-Id", uploaded.id())
.header("X-Checksum", uploaded.checksum())
.header("X-Storage-Class", "STANDARD")
.body(uploaded);
}
// ── Inject HttpServletResponse for imperative header writing ──────
@GetMapping("/{id}/preview")
public FileResponse preview(
@PathVariable Long id,
HttpServletResponse response) {
FileResponse file = fileService.findById(id);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename="" + file.filename() + """);
response.setHeader(HttpHeaders.CACHE_CONTROL,
"public, max-age=3600");
return file;
}
}Cache-Control Headers
Set Cache-Control, ETag, and Last-Modified headers to control how proxies and browsers cache responses. Spring's CacheControl builder provides a fluent API. Conditional request handling (If-None-Match, If-Modified-Since) avoids sending unchanged response bodies.
Java
@RestController
@RequestMapping("/api/v1/catalogue")
@RequiredArgsConstructor
public class CatalogueController {
private final ProductService productService;
// ── Public resource — cacheable for 1 hour ─────────────────────────
@GetMapping("/products/{id}")
public ResponseEntity<ProductResponse> findById(@PathVariable Long id) {
ProductResponse product = productService.findById(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic())
.eTag(String.valueOf(product.version()))
.body(product);
}
// ── Private user data — no caching ────────────────────────────────
@GetMapping("/users/{id}/wishlist")
public ResponseEntity<WishlistResponse> wishlist(@PathVariable Long id) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(productService.getWishlist(id));
}
// ── Conditional GET — return 304 if unchanged ─────────────────────
@GetMapping("/products")
public ResponseEntity<List<ProductResponse>> findAll(
WebRequest webRequest) {
List<ProductResponse> products = productService.findAll();
String etag = """ + productService.catalogueChecksum() + """;
if (webRequest.checkNotModified(etag)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
}
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES))
.eTag(etag)
.body(products);
}
// ── Last-Modified conditional ──────────────────────────────────────
@GetMapping("/categories")
public ResponseEntity<List<CategoryResponse>> categories(
WebRequest webRequest) {
Instant lastModified = productService.categoriesLastModified();
if (webRequest.checkNotModified(lastModified.toEpochMilli())) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
}
return ResponseEntity.ok()
.lastModified(lastModified)
.cacheControl(CacheControl.maxAge(30, TimeUnit.MINUTES))
.body(productService.findAllCategories());
}
}Async and Streaming Responses
For long-running operations or large payloads, return async or streaming types to avoid blocking Tomcat threads. DeferredResult and CompletableFuture free the request thread immediately; StreamingResponseBody streams bytes directly to the client without buffering.
Java
@RestController
@RequestMapping("/api/v1/reports")
@RequiredArgsConstructor
public class ReportController {
private final ReportService reportService;
// ── CompletableFuture — offload to async executor ─────────────────
@GetMapping("/{id}")
public CompletableFuture<ReportResponse> generateAsync(
@PathVariable Long id) {
return reportService.generateAsync(id);
// Tomcat thread is released immediately
// Response is written when the future completes
}
// ── DeferredResult — push result from any thread ──────────────────
@GetMapping("/{id}/deferred")
public DeferredResult<ResponseEntity<ReportResponse>> deferred(
@PathVariable Long id) {
DeferredResult<ResponseEntity<ReportResponse>> result =
new DeferredResult<>(30_000L); // 30s timeout
result.onTimeout(() -> result.setErrorResult(
ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT)
.body("Report generation timed out")));
reportService.generateAsync(id).thenAccept(report ->
result.setResult(ResponseEntity.ok(report)));
return result;
}
// ── StreamingResponseBody — stream large CSV without buffering ─────
@GetMapping(value = "/export", produces = "text/csv")
public ResponseEntity<StreamingResponseBody> exportCsv() {
StreamingResponseBody body = outputStream -> {
try (PrintWriter writer = new PrintWriter(
new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
writer.println("id,name,total,createdAt");
reportService.streamAll().forEach(r ->
writer.printf("%d,%s,%.2f,%s%n",
r.id(), r.name(), r.total(), r.createdAt()));
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=report.csv")
.body(body);
}
// ── Server-Sent Events — push updates to the client ───────────────
@GetMapping(value = "/{id}/progress",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter progress(@PathVariable Long id) {
SseEmitter emitter = new SseEmitter(120_000L); // 2 min timeout
reportService.trackProgress(id, emitter);
return emitter;
}
}