Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 030 / 100

ResponseEntity and HTTP status codes: a practical guide

Return the status your clients expect.

Expona Admin6 min read

Why HTTP status codes matter

A decade ago, many Spring applications returned 200 OK for everything. A user submitted an invalid email? 200 OK with an error message in JSON. A resource didn't exist? 200 OK with a null object. Your API clients had to parse response bodies to figure out what actually happened. Today, we know better. Returning the correct HTTP status code is not about REST purity—it's about building APIs that clients can trust, monitor, and integrate with reliably. This practical guide to ResponseEntity and HTTP status codes walks through the patterns we use in production, from simple reads to complex conflict resolution.

200 for reads, 201 with Location for creates

When a GET request succeeds, return 200 OK. When a POST request creates a new resource, return 201 Created with a Location header pointing to the newly created resource. This distinction matters because it signals intent to clients, proxies, and monitoring tools.

For a read operation, the response is straightforward:

java
@GetMapping("/{id}")
public ResponseEntity<OrderDto> getOrder(@PathVariable Long id) {
    OrderDto order = orderService.findById(id);
    return ResponseEntity.ok(order);
}

For a create operation, include the Location header so clients don't have to parse the response body to know the new resource's URI:

java
@PostMapping
public ResponseEntity<OrderDto> createOrder(@RequestBody CreateOrderRequest request) {
    OrderDto created = orderService.create(request);
    return ResponseEntity
        .created(URI.create("/orders/" + created.getId()))
        .body(created);
}

Why this matters in production: caching layers, CDNs, and HTTP clients all behave differently based on status codes. A 201 tells them this is a new resource worth caching separately. A 200 can be cached as a simple response. Your monitoring team can also set alerts on unusual 201 rates—a spike might indicate a client retry loop creating duplicates.

204 for deletes with no body

When you delete a resource successfully and have no response body, return 204 No Content. This tells the client "I handled your request, there's nothing to read back." It's cleaner than returning 200 with an empty JSON object, and it prevents clients from expecting a body that never comes.

java
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
    orderService.delete(id);
    return ResponseEntity.noContent().build();
}

Some teams prefer to return 200 OK with a confirmation message. In our experience, 204 is better because it's explicit: the resource is gone, here's nothing to parse. If you need to return metadata (how many orders were deleted, for instance), then 200 OK makes sense.

java
@DeleteMapping("/batch")
public ResponseEntity<DeleteBatchResponse> deleteBatch(@RequestBody DeleteBatchRequest request) {
    int deleted = orderService.deleteBatch(request.getIds());
    return ResponseEntity.ok(new DeleteBatchResponse(deleted));
}

400 for invalid input, 404 for missing, 409 for conflicts

Error status codes matter just as much as success codes. They shape how clients and infrastructure respond to failure.

400 Bad Request for validation failures

When the client sends data that doesn't meet your requirements, return 400 Bad Request. This includes missing fields, type mismatches, business rule violations caught at the API boundary, and anything the client could fix by adjusting their request.

java
@PostMapping
public ResponseEntity<OrderDto> createOrder(@Valid @RequestBody CreateOrderRequest request) {
    if (request.getQuantity() < 1) {
        return ResponseEntity
            .badRequest()
            .body(new ErrorResponse("Quantity must be at least 1"));
    }
    OrderDto created = orderService.create(request);
    return ResponseEntity.created(URI.create("/orders/" + created.getId())).body(created);
}

Spring's @Valid annotation automatically returns 400 with validation errors if you configure a ControllerAdvice to handle MethodArgumentNotValidException.

404 Not Found for missing resources

When a GET or DELETE targets a resource that doesn't exist, return 404 Not Found. Never return 200 OK with a null object or empty collection for a specific resource lookup.

java
@GetMapping("/{id}")
public ResponseEntity<OrderDto> getOrder(@PathVariable Long id) {
    return orderService.findById(id)
        .map(ResponseEntity::ok)
        .orElseGet(() -> ResponseEntity.notFound().build());
}

This pattern is clean and explicit. Clients and monitoring systems treat 404 as "resource not found" rather than "something went wrong internally."

409 Conflict for state conflicts

When a request violates a business rule—like trying to cancel an already-cancelled order, or creating a duplicate with a unique constraint—return 409 Conflict. This tells the client "your request is well-formed, but the server state doesn't allow it."

java
@PostMapping("/{id}/cancel")
public ResponseEntity<OrderDto> cancelOrder(@PathVariable Long id) {
    try {
        OrderDto cancelled = orderService.cancel(id);
        return ResponseEntity.ok(cancelled);
    } catch (OrderAlreadyCancelledException e) {
        return ResponseEntity
            .status(HttpStatus.CONFLICT)
            .body(new ErrorResponse("Order is already cancelled"));
    }
}

In a high-scale system, 409 becomes a signal for retry logic. A client that gets 409 from duplicate key violations might back off and retry with a new identifier, whereas 400 tells them to fix their request format.

Use ResponseEntity only when you need headers or dynamic status

Here's a pattern we see in junior-heavy teams: wrapping every response in ResponseEntity even when the status is always 200 and there are no headers.

java
// Over-engineered
@GetMapping("/{id}")
public ResponseEntity<OrderDto> getOrder(@PathVariable Long id) {
    OrderDto order = orderService.findById(id);
    return ResponseEntity.ok(order);
}

// Simpler, equally valid
@GetMapping("/{id}")
public OrderDto getOrder(@PathVariable Long id) {
    return orderService.findById(id);
}

Both work. Spring automatically returns 200 OK for the second version. Use ResponseEntity when:

  • You need to set custom headers (Location, ETag, custom auth headers).
  • The status code is dynamic (sometimes 200, sometimes 404).
  • You're returning different types based on conditions.

If your response is always 200 with no special headers, returning the DTO directly keeps the code readable. Readability compounds over a codebase's lifetime.

java
@GetMapping
public List<OrderDto> listOrders(
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "20") int size) {
    return orderService.list(page, size);
}

@GetMapping("/{id}")
public ResponseEntity<OrderDto> getOrder(@PathVariable Long id) {
    return orderService.findById(id)
        .map(ResponseEntity::ok)
        .orElseGet(() -> ResponseEntity.notFound().build());
}

The list endpoint returns a bare list. The single-resource endpoint uses ResponseEntity because it handles a conditional (found or not found).

Common mistakes

Returning 200 for errors

The most common production bug: wrapping all exceptions and returning 200 OK with an error flag in the JSON. This breaks HTTP semantics, confuses reverse proxies, and makes monitoring impossible. Use appropriate status codes.

Forgetting the Location header on 201

A 201 without a Location header forces clients to infer the resource URI. If you're creating a resource, always include .created(URI.create("/resource/" + id)).

Using 500 for validation errors

If a client sends invalid input and your validation code throws an unhandled exception, Spring returns 500. Configure exception handlers to catch validation and business-logic exceptions and return appropriate 4xx codes instead.

Mixing 409 with 400

A duplicate key violation is a 409, not a 400. The request was well-formed; the server state rejected it. This distinction affects how clients retry.

Key takeaways

  • 200 for successful reads, 201 with Location for creates, 204 for successful deletes with no body.
  • 400 for invalid input the client can fix, 404 for missing resources, 409 for state conflicts.
  • Use ResponseEntity only when you need custom headers or dynamic status codes; return DTOs directly for constant 200 OK responses.
  • Correct status codes enable monitoring, caching, and client logic to work as designed—they're not optional REST ceremony.
  • Set up a ControllerAdvice to map exceptions to status codes consistently across your application.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles