Spring BootJava 21Spring Framework深入淺出

Spring Boot 04: from JSON to services, validation, and errors

··3 min read
Back to the Spring Boot learning path

An API can return 400 without reaching your controller breakpoint. JSON conversion or @Valid may reject input before method invocation. This lesson separates receiving a request from accepting it as valid business input.

text
POST /api/tickets
Content-Type: application/json

{"title":"Learn Boot"}

DispatcherServlet → JSON conversion → @Valid
                  → Controller → Service → Store
                  ← status + headers + JSON

Define input separately from persistence

java
// CreateTicket.java
package com.keeponfirst.bootlab;

import jakarta.validation.constraints.NotBlank;

public record CreateTicket(@NotBlank String title) {}

CreateTicket is an input DTO with only title. @NotBlank rejects null, empty, and whitespace-only values. HTTP input, business values, and persistence models can differ. Keeping input separate prevents future internal fields from accidentally becoming writable API fields.

java
@PostMapping("/tickets")
public ResponseEntity<Ticket> create(@Valid @RequestBody CreateTicket request) {
    Ticket ticket = service.createBatch(List.of(request.title())).getFirst();
    return ResponseEntity.created(URI.create("/api/tickets/" + ticket.id()))
        .body(ticket);
}

This is a method excerpt; the download contains its imports and class. @RequestBody converts the body to CreateTicket and @Valid requests validation. The controller calls the service and returns 201 with a Location pointing to the new ticket. That contract is explicitly implemented, not a universal consequence of using POST.

Define the error format explicitly

java
// ApiErrors.java
package com.keeponfirst.bootlab;

import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ApiErrors {
    @ExceptionHandler(MissingTicket.class)
    public ResponseEntity<Map<String, String>> missing(MissingTicket error) {
        return ResponseEntity.status(404).body(Map.of("code", "TICKET_NOT_FOUND"));
    }

    @ExceptionHandler(BadTitle.class)
    public ResponseEntity<Map<String, String>> badTitle(BadTitle error) {
        return ResponseEntity.status(422).body(Map.of("code", "INVALID_TITLE"));
    }

    @ExceptionHandler({MethodArgumentNotValidException.class,
                       HttpMessageNotReadableException.class})
    public ResponseEntity<Map<String, String>> badRequest(Exception error) {
        return ResponseEntity.badRequest().body(Map.of("code", "INVALID_REQUEST"));
    }
}

This application uses a simple code object. A missing ticket is 404, parsing or field-validation failure is 400, and a rejected title length is 422. These are design choices. Turning all responses into 200 shifts error interpretation onto clients. Spring also supports ProblemDetail, which should be adopted deliberately with client compatibility in mind.

InputStatusObservation
Valid title201id, trimmed title, Location
Whitespace-only title400INVALID_REQUEST, no insert
Only an opening brace400INVALID_REQUEST
Missing id404TICKET_NOT_FOUND
81 ASCII characters422INVALID_TITLE, no insert
Expected responses for fixed inputs

Exercise: change one input property at a time

bash
curl -i -X POST http://127.0.0.1:8080/api/tickets \
  -H 'Content-Type: application/json' -d '{"title":" Learn Boot "}'
curl -i -X POST http://127.0.0.1:8080/api/tickets \
  -H 'Content-Type: application/json' -d '{"title":" "}'
curl -i -X POST http://127.0.0.1:8080/api/tickets \
  -H 'Content-Type: application/json' -d '{'

Predict where each stops. The first reaches the service, which trims and stores the title. The second fails Bean Validation; the third fails JSON conversion. Preserve status, headers, and body. Title length uses Java String.length, measured in UTF-16 code units, not universally visible characters.

Why can some rejections bypass ApiErrors?

This lab has no Spring Security. In a secured application, a filter can return 401 or 403 before MVC, outside what these controller advice handlers necessarily handle. CORS is not authentication. Adding security requires unauthenticated, forbidden, expired-session, and CSRF cases rather than removing filters to make tests pass.

Answer and migration connection: what changes when CXF becomes MVC?

Even with the same service, parameter conversion, dates, null fields, content types, and error formats may differ between CXF and MVC. Fixed requests are a starting point; compare the response contract that callers depend on. The exercise teaches you to locate the changed layer instead of assigning every HTTP problem to business logic.

Download the complete ticket API lab (ZIP)Next: 05: Database and transaction rollback

Sources