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. byId handles GET /api/orders/ORD-42.
  • @PathVariable("id") — a value from the path itself. The explicit ("id") is the lesson from the previous post's -parameters gotcha: it never depends on compiler flags.
  • @RequestParam(defaultValue = "0") — a value from the query string (?page=2&size=50). defaultValue makes 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 on CreateOrderRequest before 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.* — not javax.validation.*. Spring Boot 4 (like Boot 3) uses the Jakarta namespaces; javax imports are from the previous era and will not be picked up.
  • @NotBlank rejects null, "", and "   " — @NotNull alone would have let the empty-email ticket through. Choose the annotation that matches the actual rule.
  • @DecimalMin("0.01") takes a string because BigDecimal values can't be annotation constants — a deliberate design, not an accident.
  • promoCode has no @NotNull: null means "no promo code," which is legal. Validation constrains present values; absence is a separate decision.
  • The message attributes are what the API consumer sees — write them for the caller, not for yourself. For messages shared across the codebase, externalize them into src/main/resources/ValidationMessages.properties (e.g. order.total.min=total must be at least {value}) and reference message = "{order.total.min}".

Here is the lifecycle of a request through this machinery:

JSON body untrusted bytes @RequestBody Jackson binds it @Valid rules run here controller runs → 201 + OrderView rejected → 400 ProblemDetail MethodArgumentNotValidException thrown before your method is entered — invalid input never touches business logic @Valid is a gate, not a suggestion: validation happens before the method body

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:

"type": "https://.../validation-failed" stable error identity clients switch on this URI, not on prose "title": "Request validation failed" human summary short, stable, log-friendly "status": 400 repeats the HTTP status so the body is self-describing in logs "detail": "customerEmail: ...; ..." per-request specifics the only field that changes per call "instance": "/api/orders" which request failed lets support correlate instantly

Decision rules for error handling:

  • Validate at the boundary, fail before business logic. @Valid runs 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. @RestControllerAdvice is 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 set message.

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

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number