ResponseEntity and Status Codes

Choose the right HTTP status for each outcome and set it, with headers, through ResponseEntity or @ResponseStatus.

SyntaxResponseEntity.ok(body) ResponseEntity.created(uri).body(body) ResponseEntity.noContent().build() ResponseEntity.status(HttpStatus.CONFLICT).body(problem) @ResponseStatus(HttpStatus.CREATED)

When a handler returns a plain object, Spring sends it with 200 OK. That is right for reads and wrong for most other outcomes. Clients, caches, proxies and monitoring all act on the status code, so a create that answers 200, or a failure that answers 200 with {"error": ...}, breaks retries, alerting and client libraries in ways that are hard to trace.

The codes a REST API actually uses

StatusWhen
200 OKRead succeeded, or an update that returns the new state
201 CreatedA resource was created; send a Location header with its URL
202 AcceptedWork was queued and will finish later
204 No ContentSuccess with no body, typically a delete
400 / 422The request is malformed / well-formed but breaks a rule
404 Not FoundNo such resource (or the caller may not know it exists)
409 ConflictThe request clashes with current state, such as a duplicate email

ResponseEntity

ResponseEntity<T> is the whole response: status, headers and body. Its static builders read well and are hard to misuse:

return ResponseEntity.created(location).body(saved);   // 201 + Location
return ResponseEntity.noContent().build();              // 204
return ResponseEntity.of(repository.findById(id));      // 200 or 404

ResponseEntity.of(Optional) is the neat way to turn a repository lookup into 200 or an empty 404. When the 404 needs a body explaining what was missing, throw an exception that maps to a problem detail instead.

@ResponseStatus

When an endpoint always returns the same non-200 status and needs no headers, @ResponseStatus(HttpStatus.NO_CONTENT) on the method keeps the return type clean. Put it on an exception class and Spring uses that status whenever the exception escapes a handler. It is fine for small apps; larger ones centralise this in a @RestControllerAdvice.

Mistakes

  • Returning ResponseEntity<?> everywhere. Keep the concrete type so readers and API documentation tools can see what comes back; use exceptions for error paths.
  • A 201 without Location. Clients then have to guess the URL of what they just made.
  • Returning 500 for bad input. A 5xx says the server is broken, wakes someone up, and invites the client to retry the same bad request.

Example

Example · java
import java.net.URI;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

record NewCustomer(String name, String email) {}

record Customer(long id, String name, String email) {}

@RestController
@RequestMapping("/api/customers")
public class CustomerController {

    private final Map<Long, Customer> store = new ConcurrentHashMap<>();
    private final AtomicLong ids = new AtomicLong();

    // 200 with the customer, or 404 with no body.
    @GetMapping("/{id}")
    public ResponseEntity<Customer> get(@PathVariable long id) {
        return ResponseEntity.of(Optional.ofNullable(store.get(id)));
    }

    // 201 Created + Location: http://host/api/customers/{id}
    @PostMapping
    public ResponseEntity<Customer> create(@RequestBody NewCustomer request) {
        Customer saved = new Customer(ids.incrementAndGet(), request.name(), request.email());
        store.put(saved.id(), saved);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(saved.id())
                .toUri();
        return ResponseEntity.created(location).body(saved);
    }

    // 204 whether or not it existed: DELETE is idempotent.
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable long id) {
        store.remove(id);
        return ResponseEntity.noContent().build();
    }
}

When to use it

  • Answer a successful POST /api/customers with 201 and a Location header the front end follows to load the new record.
  • Start a long CSV export with 202 Accepted and a polling URL instead of holding the request open.
  • Return 409 Conflict when a sign-up uses an email that already has an account, so the client can show the right message.

More examples

@ResponseStatus for fixed statuses

The handler signatures stay plain, which is pleasant when there is nothing dynamic about the response. The trade-off is that you cannot add headers such as <code>Location</code>, so a create that should point at the new resource is better served by <code>ResponseEntity.created</code>. Statuses on exceptions keep error mapping next to the error, but scatter it; the error handling category shows the centralised alternative.

Example · java
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

record Subscription(String email, String plan) {}

// Thrown anywhere below the controller, it becomes a 404 response.
@ResponseStatus(HttpStatus.NOT_FOUND)
class SubscriptionNotFoundException extends RuntimeException {
    SubscriptionNotFoundException(String email) {
        super("No subscription for " + email);
    }
}

@RestController
public class SubscriptionController {

    @PostMapping("/api/subscriptions")
    @ResponseStatus(HttpStatus.CREATED)
    public Subscription subscribe(@RequestBody Subscription request) {
        return request;
    }

    @PutMapping("/api/subscriptions/{email}/plan")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void changePlan(@PathVariable String email, @RequestBody String plan) {
        if (!email.endsWith("@example.com")) {
            throw new SubscriptionNotFoundException(email);
        }
    }
}

202 Accepted for work that finishes later

A report that takes two minutes should not hold an HTTP request open that long; proxies and load balancers will time it out. 202 says "received, not done", the <code>Location</code> tells the client where to poll, and <code>Retry-After</code> suggests how often. The status endpoint then returns 200 with the job state, and finally a link to the finished file.

Example · java
import java.net.URI;
import java.util.UUID;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

record ExportRequest(String format, String from, String to) {}

record ExportJob(UUID id, String status) {}

@RestController
public class ExportController {

    @PostMapping("/api/exports")
    public ResponseEntity<ExportJob> start(@RequestBody ExportRequest request) {
        ExportJob job = new ExportJob(UUID.randomUUID(), "QUEUED");
        // A real app hands the job to a queue or an @Async service here.
        return ResponseEntity.accepted()
                .location(URI.create("/api/exports/" + job.id()))
                .header("Retry-After", "5")
                .body(job);
    }
}

A 409 Conflict with a problem detail body

<code>ProblemDetail</code> is Spring's model of RFC 9457, and Spring writes it as <code>application/problem+json</code>. Returning it directly works, but the method has to fall back to <code>ResponseEntity&lt;?&gt;</code> because the two branches have different body types. That is the signal to move the error path into an exception and a handler, which the error handling lessons do.

Example · java
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

record SignUp(String email) {}

@RestController
public class SignUpController {

    private final Set<String> emails = ConcurrentHashMap.newKeySet();

    @PostMapping("/api/accounts")
    public ResponseEntity<?> signUp(@RequestBody SignUp request) {
        if (!emails.add(request.email().toLowerCase())) {
            ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                    HttpStatus.CONFLICT, "An account with this email already exists.");
            problem.setTitle("Duplicate account");
            return ResponseEntity.status(HttpStatus.CONFLICT).body(problem);
        }
        return ResponseEntity.status(HttpStatus.CREATED).body(request);
    }
}

Discussion

  • Be the first to comment on this lesson.