Building REST APIs with Spring Boot

Responses & ResponseEntity

18 min Lesson 5 of 13

Responses & ResponseEntity

In the previous lessons you learned how to map incoming requests and bind their data. Now the focus shifts to the outbound side: how Spring Boot translates your return value into an HTTP response, and how ResponseEntity gives you precise control over every part of that response — the status code, the headers, and the body.

The Default: Return a POJO and Let Spring Handle It

When a method inside a @RestController returns a plain Java object, Spring's HttpMessageConverter infrastructure serialises it to JSON (or XML) automatically. The HTTP status defaults to 200 OK and the Content-Type header is set to application/json.

@GetMapping("/products/{id}") public Product getProduct(@PathVariable Long id) { return productService.findById(id); // 200 OK, body = JSON }

This is perfectly fine for the happy path, but what about when the resource is not found? Or when you create something and must return 201 Created with a Location header? For those cases you need ResponseEntity.

ResponseEntity — Full Response Control

ResponseEntity<T> is a generic wrapper that bundles a body of type T, an HttpStatus, and a set of HttpHeaders into a single return value. It is part of org.springframework.http and works in any Spring MVC or Spring WebFlux controller.

import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @GetMapping("/{id}") public ResponseEntity<Product> getProduct(@PathVariable Long id) { return productService.findById(id) .map(ResponseEntity::ok) // 200 OK with body .orElse(ResponseEntity.notFound() // 404 Not Found, no body .build()); } }
Why return ResponseEntity<T> rather than T directly? Because HTTP is more than a carrier for JSON. Status codes communicate intent to clients, middleware, and monitoring systems. Returning an object with a hard-coded 200 when the resource was not found forces the client to inspect the body to discover the failure — that is a leaky contract.

The Builder API

Constructing a ResponseEntity through its constructors directly is verbose. The fluent builder API (available since Spring 4) is much cleaner:

// 200 OK with body ResponseEntity.ok(product); // 200 OK with no body ResponseEntity.ok().build(); // 201 Created with Location header and body ResponseEntity .created(URI.create("/api/v1/products/" + saved.getId())) .body(saved); // 204 No Content (typical for DELETE) ResponseEntity.noContent().build(); // 404 Not Found with no body ResponseEntity.notFound().build(); // Any status + custom headers + body ResponseEntity .status(HttpStatus.ACCEPTED) .header("X-Request-Id", requestId) .contentType(MediaType.APPLICATION_JSON) .body(dto);

Setting Custom Headers

Headers carry metadata that is not part of the body: pagination cursors, rate-limit information, cache directives, and more. You add them through HttpHeaders or directly on the builder:

import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import java.net.URI; @PostMapping public ResponseEntity<ProductDto> createProduct(@RequestBody CreateProductRequest req) { ProductDto saved = productService.create(req); HttpHeaders headers = new HttpHeaders(); headers.add("X-Created-By", "product-service"); return ResponseEntity .created(URI.create("/api/v1/products/" + saved.getId())) .headers(headers) .body(saved); }

The Location header set by .created(uri) tells the client exactly where to find the newly created resource. REST clients, hypermedia frameworks, and API gateways all rely on this.

Controlling the Status Code Explicitly

Use ResponseEntity.status(HttpStatus.XXX) whenever the right code is not covered by the convenience factory methods. For example, returning 202 Accepted for an asynchronous task:

@PostMapping("/reports") public ResponseEntity<Map<String, String>> scheduleReport(@RequestBody ReportRequest req) { String jobId = reportService.enqueue(req); return ResponseEntity .status(HttpStatus.ACCEPTED) .body(Map.of("jobId", jobId, "status", "queued")); }

Returning No Body

Several HTTP operations should return an empty body: successful DELETE, successful PUT when you do not echo the updated resource, and acknowledgements. Use ResponseEntity<Void> to communicate this clearly at the type level:

@DeleteMapping("/{id}") public ResponseEntity<Void> deleteProduct(@PathVariable Long id) { productService.delete(id); return ResponseEntity.noContent().build(); // 204 No Content }
Prefer ResponseEntity<Void> over ResponseEntity<?> for empty-body responses. It makes the contract explicit in the method signature: callers and code-generation tools know there is intentionally no body, rather than wondering whether the wildcard hides something.

Conditional Responses with ETags and If-None-Match

For read-heavy endpoints you can support HTTP conditional requests to reduce bandwidth. Spring provides ShallowEtagHeaderFilter for automatic ETag generation, but you can also set etag headers manually via ResponseEntity when you need fine-grained control:

@GetMapping("/{id}") public ResponseEntity<Product> getProductWithEtag( @PathVariable Long id, @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) { Product product = productService.findByIdOrThrow(id); String etag = "\"" + product.getVersion() + "\""; if (etag.equals(ifNoneMatch)) { return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build(); } return ResponseEntity.ok() .eTag(etag) .body(product); }

Typed vs. Wildcard ResponseEntity

You will sometimes see methods declared as ResponseEntity<?> or even ResponseEntity<Object>. This is a code smell in most cases. It sacrifices compile-time type safety, confuses IDE tooling, and breaks OpenAPI schema generation. The only legitimate use is when a single endpoint genuinely returns different body types depending on outcome — and even then, a proper exception handler (covered in a later lesson on error handling) is usually the better design.

Avoid ResponseEntity<?> as a lazy catch-all. When you find yourself wanting to return either a ProductDto or an error string from the same method, that is a sign the error case should be an exception thrown from the service and translated by a @ControllerAdvice class — not a second code path inside the controller method.

Practical Pattern: the Service Returns an Optional

A clean, idiomatic pattern for GET endpoints is to have the service return an Optional and map it to the appropriate ResponseEntity in the controller:

@GetMapping("/{id}") public ResponseEntity<ProductDto> getProduct(@PathVariable Long id) { return productService.findById(id) // Optional<ProductDto> .map(ResponseEntity::ok) // present → 200 OK .orElseGet(() -> // absent → 404 ResponseEntity.notFound().build()); }

This pattern keeps the controller thin, keeps the null-handling explicit, and avoids a conditional branch that is easy to forget.

Summary

Spring Boot serialises plain return values to JSON with a default status of 200. ResponseEntity<T> lifts that default, giving you first-class control over the status code, headers, and body in a single type-safe object. Use the fluent builder API — ResponseEntity.ok(), .created(uri), .noContent(), .status(HttpStatus.X) — for readable, intention-revealing code. Type your ResponseEntity with a concrete type parameter; reach for <Void> when the body is intentionally empty. In the next lesson you will deepen your understanding of the HTTP status codes themselves and the REST semantics they carry.