Validation & Serialization Interview Questions & Answers
Bean Validation and Jackson in Spring Boot — @Valid vs @Validated, JSR-380 constraints, custom validators, validation groups, @JsonProperty/@JsonIgnore, serializing dates and nulls, snake_case naming, DTOs vs entities, and customizing the ObjectMapper.
@Valid is the standard Bean Validation (JSR-380) annotation; @Validated is Spring's
variant that adds validation groups and enables method-level validation on beans.
@PostMapping("/orders")
Order create(@Valid @RequestBody CreateOrderRequest body) { ... } // validate the body
@Validated // class-level: enable method validation
@Service
class PriceService {
BigDecimal quote(@Min(1) int qty, // each param validated on call
@NotNull String sku) { ... }
}
Use @Valid to cascade validation into a @RequestBody or nested object — it's the common
case. Use @Validated when you need groups (validate different rules in different
contexts) or to validate individual method parameters (@RequestParam/@PathVariable) on
a Spring bean.
Rule of thumb: @Valid to validate a request body / nested object; @Validated (on the
class) when you need validation groups or method-parameter validation.
JSR-380 ships a set of annotations you put on DTO fields; Spring runs them when the argument is
@Valid. They cover nullability, size, range, and format.
record CreateUserRequest(
@NotBlank String name, // not null, not empty/whitespace
@Email String email, // valid email format
@Min(18) @Max(120) int age, // numeric range
@Size(min = 8, max = 64) String password, // length / collection size
@NotNull @Past LocalDate birthDate, // must be in the past
@Pattern(regexp = "\\+?\\d{10,15}") String phone) {} // regex
Key ones: @NotNull (present), @NotEmpty (not empty), @NotBlank (not blank text), @Size,
@Min/@Max, @Positive/@Negative, @Email, @Pattern, @Past/@Future. They compose —
stack several on one field. Add the spring-boot-starter-validation dependency to pull in the
Hibernate Validator implementation.
Rule of thumb: Reach for the built-in constraints first (@NotBlank, @Size, @Email,
@Min/@Max, @Pattern); only write a custom validator when none express the rule.
Define an annotation and a ConstraintValidator implementation, then use it like any
built-in constraint.
@Constraint(validatedBy = SkuValidator.class)
@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME)
@interface ValidSku {
String message() default "must be a valid SKU";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
class SkuValidator implements ConstraintValidator<ValidSku, String> {
public boolean isValid(String value, ConstraintValidatorContext ctx) {
return value != null && value.matches("[A-Z]{3}-\\d{4}");
}
}
record OrderRequest(@ValidSku String sku) {} // used like any constraint
The message, groups, and payload members are required by the spec. Because the
validator is a Spring-managed bean, it can inject dependencies (e.g. a repository for a
uniqueness check). Return true for null if you want it to compose with @NotNull rather
than duplicate that check.
Rule of thumb: For domain rules not covered by built-ins, pair a custom annotation with a
ConstraintValidator; let it inject beans for DB-backed checks, and keep null-handling to
@NotNull.
Groups let one DTO carry different validation rules for different operations — e.g. stricter rules on update than on create. You tag constraints with a group interface and tell Spring which group to apply.
interface OnCreate {}
interface OnUpdate {}
record UserRequest(
@Null(groups = OnCreate.class) // id must be absent on create
@NotNull(groups = OnUpdate.class) // …but present on update
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) { ... }
You must use @Validated(Group.class) (not @Valid) to select a group. Without groups,
all constraints run every time, forcing you to split into separate Create/Update DTOs. Groups
keep one DTO but vary the rules.
Rule of thumb: Use validation groups with @Validated(Group.class) when the same DTO
needs different rules per operation; otherwise separate DTOs are simpler.
Put @Valid on the nested field or collection element type so validation cascades into
it. Without it, the inner object's constraints are skipped.
record OrderRequest(
@NotBlank String customer,
@Valid @NotNull Address shippingAddress, // cascade into Address
@Valid @Size(min = 1) List<@Valid LineItem> items) {} // validate each element
record Address(@NotBlank String street, @NotBlank @Size(min=2,max=2) String country) {}
record LineItem(@NotBlank String sku, @Min(1) int qty) {}
The @Valid on shippingAddress triggers Address's constraints; the @Valid inside
List<@Valid LineItem> validates every element. @Size on the list checks the collection
itself. Cascading is not automatic — you opt in at each level you want validated.
Rule of thumb: Add @Valid at every level you want validated — on nested object fields and
inside collection generics (List<@Valid T>) — because cascading is opt-in.
Through Jackson — Boot auto-configures a single ObjectMapper, and the
MappingJackson2HttpMessageConverter uses it to turn return values into JSON and request
bodies into objects.
@GetMapping("/users/{id}")
User get(@PathVariable Long id) {
return new User(1L, "Ada", "[email protected]"); // → {"id":1,"name":"Ada",...}
}
Jackson reads getters (or record components / fields) to build the JSON and writes via
setters/constructors to read it. You shape the output with annotations (@JsonProperty,
@JsonIgnore…) or by customizing the ObjectMapper. Boot also registers modules for Java 8
time types and Optional automatically.
Rule of thumb: Jackson is the default JSON engine; control it with field/class annotations
for local tweaks and a customized ObjectMapper for global ones.
They tune how a single field is serialized: rename it, hide it, or conditionally omit it.
@JsonInclude(JsonInclude.Include.NON_NULL) // drop null fields from output
class UserDto {
@JsonProperty("user_name") // rename field in JSON
private String userName;
@JsonIgnore // never serialize/deserialize
private String passwordHash;
@JsonProperty(access = Access.WRITE_ONLY) // accept on input, never output
private String password;
}
@JsonProperty renames (or maps an input name); @JsonIgnore excludes a field entirely;
@JsonInclude(NON_NULL) omits nulls; access = WRITE_ONLY/READ_ONLY makes a field one-
directional (perfect for passwords or server-set ids). These are the everyday tools for shaping
a DTO's JSON.
Rule of thumb: @JsonProperty to rename, @JsonIgnore to hide, @JsonInclude(NON_NULL)
to drop nulls, and WRITE_ONLY/READ_ONLY for one-directional fields like passwords.
Boot registers the JavaTimeModule, so java.time types serialize as ISO-8601 strings
by default (not numeric timestamps). Override the format per field with @JsonFormat or
globally via properties.
class Event {
private LocalDate date; // "2026-06-26"
@JsonFormat(pattern = "yyyy-MM-dd HH:mm", timezone = "UTC")
private LocalDateTime startsAt; // "2026-06-26 14:30"
}
spring.jackson.serialization.write-dates-as-timestamps=false # ISO strings, not epoch (default)
spring.jackson.time-zone=UTC
spring.jackson.date-format=yyyy-MM-dd'T'HH:mm:ssXXX # global java.util.Date format
Prefer java.time (LocalDate, Instant, OffsetDateTime) over the legacy java.util.Date;
it serializes cleanly and is timezone-aware. Use @JsonFormat for a one-off shape and the
spring.jackson.* properties for an app-wide convention.
Rule of thumb: Use java.time types (ISO-8601 by default), @JsonFormat for per-field
formats, and spring.jackson.* for global date/timezone settings.
Exposing entities directly leaks your schema, breaks lazy loading, and couples the API to the database. A DTO is a stable, intentional view of the data shaped for the client.
// Don't return the entity:
@GetMapping("/{id}") User getEntity(@PathVariable Long id) { return repo.findById(id).get(); }
// ↑ serializes passwordHash, triggers lazy collections (LazyInitializationException),
// and any schema change instantly changes the API.
// Return a DTO you control:
record UserDto(Long id, String name, String email) {
static UserDto from(User u) { return new UserDto(u.getId(), u.getName(), u.getEmail()); }
}
@GetMapping("/{id}") UserDto get(@PathVariable Long id) { return UserDto.from(repo.findById(id).get()); }
DTOs let you omit sensitive fields, avoid serializing lazy associations (and the dreaded
LazyInitializationException outside a transaction), version the API independently of the
schema, and validate input separately from persistence. The mapping cost is worth the
decoupling — automate it with MapStruct if it grows.
Rule of thumb: Never serialize JPA entities at the API boundary — map to DTOs so you control the shape, hide sensitive fields, and decouple the API from the database.
Set a PropertyNamingStrategy — globally with a property, or per-class with
@JsonNaming. Java stays camelCase; the JSON becomes snake_case.
spring.jackson.property-naming-strategy=SNAKE_CASE # global: userName ↔ user_name
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) // per-class override
record UserDto(Long id, String firstName, String lastName) {} // → first_name, last_name
The global property is the clean choice when your whole API uses snake_case (common in
public APIs). @JsonNaming is for the odd class that differs from the app default. The strategy
applies in both directions, so input first_name binds to firstName too.
Rule of thumb: Set spring.jackson.property-naming-strategy=SNAKE_CASE for an app-wide
convention; use @JsonNaming to override a single class.
Prefer a Jackson2ObjectMapperBuilderCustomizer bean (or spring.jackson.* properties) so
you adjust Boot's auto-configured mapper instead of replacing it.
@Configuration
class JacksonConfig {
@Bean
Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> builder
.failOnUnknownProperties(false) // ignore extra JSON fields
.serializationInclusion(JsonInclude.Include.NON_NULL)
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
}
Defining your own @Bean ObjectMapper replaces Boot's and silently drops the modules
Boot registered (Java time, parameter names, etc.) unless you re-add them — a common bug. The
customizer or the spring.jackson.* properties keep those modules and just layer your changes
on top.
Rule of thumb: Customize Jackson with spring.jackson.* or a
Jackson2ObjectMapperBuilderCustomizer; only define your own ObjectMapper bean if you
re-register Boot's default modules.
By default Jackson fails on unknown properties (UnrecognizedPropertyException → 400).
You can make it ignore extras globally, per-class, or keep failing on purpose.
@JsonIgnoreProperties(ignoreUnknown = true) // per-class: tolerate extra fields
record UserRequest(String name, String email) {}
spring.jackson.deserialization.fail-on-unknown-properties=false # global: ignore extras
Ignoring extras makes clients forward-compatible (they can send fields you don't know yet) and is common for public APIs. Failing is stricter and catches typos in field names early. Choose deliberately — strict for internal contracts, lenient for evolving public ones.
Rule of thumb: Default is fail-on-unknown; relax it with @JsonIgnoreProperties or the
Jackson property for forward-compatible/public APIs, keep it strict for internal contracts.
Annotate the controller class with @Validated, then put constraints directly on the
method parameters. Failures throw ConstraintViolationException.
@Validated // enables method-parameter validation
@RestController
class SearchController {
@GetMapping("/search")
List<Item> search(
@RequestParam @NotBlank String q, // must be present, non-blank
@RequestParam(defaultValue = "1") @Min(1) @Max(100) int page) { // bounded
return service.search(q, page);
}
}
@Valid alone doesn't validate loose method params — you need the class-level @Validated to
turn on Spring's method-validation post-processor. Handle the resulting
ConstraintViolationException in your @RestControllerAdvice to return a 400 with the field
details (it isn't a MethodArgumentNotValidException).
Rule of thumb: Put @Validated on the controller class to validate @RequestParam/
@PathVariable constraints, and handle ConstraintViolationException in your global advice.
Jackson can bind JSON to constructor parameters instead of setters, which is how it handles
records and immutable classes. Records work out of the box; classic classes may need
@JsonCreator/@JsonProperty.
// Record — Jackson maps JSON keys to components automatically (Boot enables parameter names):
record Money(String currency, BigDecimal amount) {}
// Immutable class — be explicit if parameter names aren't compiled in:
class Point {
private final int x, y;
@JsonCreator
Point(@JsonProperty("x") int x, @JsonProperty("y") int y) { this.x = x; this.y = y; }
}
Boot compiles with -parameters and registers the ParameterNamesModule, so record and
single-constructor binding usually needs no annotations. You only add @JsonCreator/
@JsonProperty for multiple constructors or when parameter names aren't available. Immutable
DTOs (records) are the modern default for request/response models.
Rule of thumb: Use records for DTOs — Jackson binds them via the constructor with no
annotations; reach for @JsonCreator/@JsonProperty only for ambiguous or multi-constructor
classes.
A failing @Valid @RequestBody throws MethodArgumentNotValidException; a failing
@Validated method parameter throws ConstraintViolationException. Catch each in your
advice and flatten the errors.
@RestControllerAdvice
class ValidationAdvice {
@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));
}
@ExceptionHandler(ConstraintViolationException.class)
ResponseEntity<ApiError> onParam(ConstraintViolationException ex) {
var fields = ex.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage()).toList();
return ResponseEntity.badRequest().body(new ApiError("VALIDATION", fields));
}
}
Returning every field error at once is far friendlier than failing on the first. Use the
constraint message for client-readable text (override per field with
@Size(message = "...")), and keep the same ApiError shape your other errors use.
Rule of thumb: Handle MethodArgumentNotValidException (body) and
ConstraintViolationException (params) in one advice, return all field errors together in your
standard error envelope.
More Web MVC interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.