The two ends of every request
Every API call has the same two boundaries: untrusted JSON comes in and must be validated, and your objects go out and must be serialized. Spring Boot wires Bean Validation and Jackson to handle both, but the defaults only get you so far. Knowing the annotations — and the traps — is what separates a leaky, inconsistent API from a tight one.
@Valid vs @Validated
@Valid is the standard Bean Validation annotation; @Validated is Spring's superset that adds
validation groups and method-parameter validation.
@PostMapping("/orders")
Order create(@Valid @RequestBody CreateOrderRequest body) { ... } // validate the body
@Validated // class-level: enable method validation
@RestController
class SearchController {
@GetMapping("/search")
List<Item> search(@RequestParam @NotBlank String q,
@RequestParam @Min(1) @Max(100) int page) { ... }
}
Use @Valid to cascade into a request body or nested object — the common case. Reach for
@Validated when you need groups, or to validate loose @RequestParam/@PathVariable constraints
(which @Valid alone won't do).
The constraint toolbox
JSR-380 ships annotations for nullability, size, range, and format. Add
spring-boot-starter-validation and stack them on DTO fields:
record CreateUserRequest(
@NotBlank String name,
@Email String email,
@Min(18) @Max(120) int age,
@Size(min = 8, max = 64) String password,
@NotNull @Past LocalDate birthDate) {}
@NotNull, @NotEmpty, @NotBlank, @Size, @Min/@Max, @Email, @Pattern, @Past/@Future
cover most needs. When none fit a domain rule, write a custom constraint: an annotation plus a
ConstraintValidator, which — being a Spring bean — can inject a repository for DB-backed checks.
Validation groups and nested objects
Two things trip people up. First, cascading is opt-in: add @Valid at every level you want
validated, including inside collection generics:
record OrderRequest(
@Valid @NotNull Address shippingAddress, // cascade into Address
@Valid @Size(min = 1) List<@Valid LineItem> items) {} // validate each element
Second, when the same DTO needs different rules per operation, use groups with
@Validated(Group.class) rather than splitting into separate DTOs:
record UserRequest(
@Null(groups = OnCreate.class) @NotNull(groups = OnUpdate.class) Long id,
@NotBlank(groups = {OnCreate.class, OnUpdate.class}) String name) {}
@PostMapping User create(@Validated(OnCreate.class) @RequestBody UserRequest r) { ... }
@PutMapping User update(@Validated(OnUpdate.class) @RequestBody UserRequest r) { ... }
What you get when validation fails
A failing @Valid @RequestBody throws MethodArgumentNotValidException; a failing @Validated
parameter throws ConstraintViolationException. Handle both in your @RestControllerAdvice and
return all field errors at once:
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> onBody(MethodArgumentNotValidException ex) {
var fields = ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage()).toList();
return ResponseEntity.badRequest().body(new ApiError("VALIDATION", fields));
}
Jackson does the JSON
On the way out, Boot's auto-configured ObjectMapper (via Jackson) serializes your return value.
Shape individual fields with annotations:
@JsonInclude(JsonInclude.Include.NON_NULL) // drop null fields
class UserDto {
@JsonProperty("user_name") private String userName; // rename
@JsonIgnore private String passwordHash; // hide entirely
@JsonProperty(access = Access.WRITE_ONLY) private String password; // input-only
}
@JsonProperty renames, @JsonIgnore excludes, @JsonInclude(NON_NULL) omits nulls, and
WRITE_ONLY/READ_ONLY make a field one-directional — exactly what you want for passwords or
server-set ids.
Dates and naming
Boot registers the JavaTimeModule, so java.time types serialize as ISO-8601 strings. Override
per field with @JsonFormat, or globally with properties:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm", timezone = "UTC")
private LocalDateTime startsAt;
spring.jackson.property-naming-strategy=SNAKE_CASE # userName ↔ user_name, both directions
spring.jackson.time-zone=UTC
Prefer java.time over the legacy java.util.Date, and set a global naming strategy when your whole
API uses snake_case.
DTOs, not entities
The single most important serialization rule: never serialize JPA entities at the boundary.
record UserDto(Long id, String name, String email) {
static UserDto from(User u) { return new UserDto(u.getId(), u.getName(), u.getEmail()); }
}
Returning entities leaks your schema, serializes sensitive fields, triggers
LazyInitializationException on lazy associations outside a transaction, and welds your API to the
database. A DTO lets you control the shape, hide fields, validate input separately, and version the
API independently. Records make DTOs nearly free — Jackson binds them through the constructor with no
annotations.
Customize Jackson without breaking it
When you need global tweaks, use spring.jackson.* or a Jackson2ObjectMapperBuilderCustomizer —
not your own ObjectMapper bean:
@Bean
Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> builder
.failOnUnknownProperties(false)
.serializationInclusion(JsonInclude.Include.NON_NULL);
}
Defining your own ObjectMapper replaces Boot's and silently drops the modules it registered (Java
time, parameter names) unless you re-add them — a classic bug. The customizer layers your changes on
top of the working default.
Recap
Validate input with @Valid (and @Validated for groups and method params), reach for the built-in
constraints before writing a custom ConstraintValidator, and remember cascading is opt-in. On the
way out, let Jackson serialize DTOs — never entities — shaping fields with @JsonProperty,
@JsonIgnore, and @JsonInclude, formatting dates with @JsonFormat, and customizing the mapper
through the builder customizer rather than replacing it. Tight validation in, intentional JSON out:
that's a well-behaved API boundary.