REST Controllers, Validation & Error Handling
Monday morning, three support tickets. A customer "successfully" placed an order with an empty email address — so the confirmation went nowhere. Another order had a negative total, which the billing job happily processed as a credit. And a leaked promo code forty characters long sailed through a field the database defined as eight. Every one of those requests returned HTTP 200. The API had accepted garbage, smiled, and stored it.
The bug wasn't in the business logic. It was at the boundary: the controller took whatever JSON arrived and passed it straight through. This post fixes that layer properly — reading requests with the mapping annotations, declaring validation rules on the request shape, and turning every rejection into clean, structured error JSON. The example is worked end to end: you'll see the exact bytes a bad request gets back.
The mapping annotations: reading a request
Our order controller grows three endpoints. Each mapping annotation answers one question about the incoming request:
package com.javamakeuse.checkout.orders;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
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.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.List;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
public record OrderView(String id, String status, BigDecimal total) {}
public record OrderPage(List items, int page, int size) {}
@GetMapping("/{id}")
public OrderView byId(@PathVariable("id") String id) {
return new OrderView(id, "PAID", new BigDecimal("129.99"));
}
@GetMapping
public OrderPage search(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return new OrderPage(List.of(), page, size);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderView create(@Valid @RequestBody CreateOrderRequest request) {
// TODO: persist the order (later post in this track)
return new OrderView("ORD-1001", "CREATED", request.total());
}
}
@RequestMapping("/api/orders")on the class — the shared prefix, so each method declares only its suffix.byIdhandlesGET /api/orders/ORD-42.@PathVariable("id")— a value from the path itself. The explicit("id")is the lesson from the previous post's-parametersgotcha: it never depends on compiler flags.@RequestParam(defaultValue = "0")— a value from the query string (?page=2&size=50).defaultValuemakes the parameter optional; without it, a missing parameter is a 400.@RequestBody— the request body, deserialized from JSON by Jackson. This is where untrusted bytes enter the system.@ResponseStatus(HttpStatus.CREATED)— the success status for a creation endpoint is 201, not 200. Status codes are part of your API's contract; clients branch on them.@Valid— the two most important characters in this file. It says "validate this body against the rules declared onCreateOrderRequestbefore my method runs." Without it, the annotations on the request class are decoration.
Declaring the rules: Bean Validation
Validation rules belong on the shape of the request, not scattered through controller logic. One dependency adds the whole validation engine:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
(Check for newer versions than the one pinned — the coordinates are the stable part.) And the request shape:
package com.javamakeuse.checkout.orders;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public record CreateOrderRequest(
@NotBlank(message = "customerEmail is required")
@Email(message = "customerEmail must be a valid email address")
String customerEmail,
@NotNull(message = "total is required")
@DecimalMin(value = "0.01", message = "total must be at least 0.01")
BigDecimal total,
@Size(max = 8, message = "promoCode must be at most 8 characters")
String promoCode
) {}
Notes for the careful reader:
- The imports are
jakarta.validation.*— notjavax.validation.*. Spring Boot 4 (like Boot 3) uses the Jakarta namespaces;javaximports are from the previous era and will not be picked up. @NotBlankrejects null,"", and" "—@NotNullalone would have let the empty-email ticket through. Choose the annotation that matches the actual rule.@DecimalMin("0.01")takes a string becauseBigDecimalvalues can't be annotation constants — a deliberate design, not an accident.promoCodehas no@NotNull: null means "no promo code," which is legal. Validation constrains present values; absence is a separate decision.- The
messageattributes are what the API consumer sees — write them for the caller, not for yourself. For messages shared across the codebase, externalize them intosrc/main/resources/ValidationMessages.properties(e.g.order.total.min=total must be at least {value}) and referencemessage = "{order.total.min}".
Here is the lifecycle of a request through this machinery:
The worked example: garbage in, structured error out
Here is the Monday-morning payload — bad email, zero total, oversized promo code — and the exact response the API returns. These bytes were captured by driving the real controller, the real validator, and the real error handler below (via MockMvc, the same approach as this track's JUnit 5 & Mockito post), so what you see is what a client gets:
POST /api/orders
Content-Type: application/json
{"customerEmail":"not-an-email","total":0.00,"promoCode":"TOO-LONG-CODE"}
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://www.javamakeuse.com/problems/validation-failed",
"title": "Request validation failed",
"status": 400,
"detail": "customerEmail: customerEmail must be a valid email address;
promoCode: promoCode must be at most 8 characters;
total: total must be at least 0.01",
"instance": "/api/orders"
}
Three violations, one response, zero stack traces. And the happy path:
POST /api/orders
Content-Type: application/json
{"customerEmail":"buyer@example.com","total":129.99,"promoCode":"SAVE10"}
HTTP/1.1 201 Created
{"id":"ORD-1001","status":"CREATED","total":129.99}
One error handler for the whole API
That 400 didn't come from the controller — the controller never ran. It came from a single class that handles validation failures for every endpoint:
package com.javamakeuse.checkout.orders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setType(URI.create("https://www.javamakeuse.com/problems/validation-failed"));
problem.setTitle("Request validation failed");
String detail = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.sorted()
.collect(Collectors.joining("; "));
problem.setDetail(detail);
return ResponseEntity.badRequest().body(problem);
}
}
@RestControllerAdvice marks this as a global handler: its @ExceptionHandler methods apply to every controller. ProblemDetail is Spring's implementation of RFC 9457 (the standard "problem details for HTTP APIs" format) — using it means your errors look like everyone else's errors, which means clients can parse them without reading your docs. Each field has a job:
Decision rules for error handling:
- Validate at the boundary, fail before business logic.
@Validruns before your method body — an invalid request can never reach the code that charges cards. That ordering is the whole point. - Never leak internals in errors. The 400 above contains field names and messages — never a stack trace, never a SQL fragment, never a bean name.
@RestControllerAdviceis where you enforce that globally instead of remembering it per endpoint. - One format for all errors. Today it's validation; tomorrow it's "order not found" (404) and "payment declined" (402/409). Every handler in the advice returns
ProblemDetail, so clients write one error parser, not five. - Messages are UI.
"total must be at least 0.01"is written for the API consumer staring at the response at 2 AM. Constraint defaults like"must be greater than or equal to 0.01"are technically true and practically useless — always setmessage.
What's next
Your controllers now read requests, enforce rules, and speak structured errors. But notice what create still does: it invents an order ID and returns. The wiring — the service that charges, the repository that stores — is the next layer. That's dependency injection, and it's the next post.
Field check before you move on: add a @Size(min = 2, max = 60) rule to a new customerName field on CreateOrderRequest, send a one-character name, and confirm you get a 400 whose detail names the field. Then remove @Valid from the controller method, resend the Monday-morning payload, and watch the 201 come back for garbage input. That difference is why the annotation exists.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment