Skip to content

Validation & Serialization Interview Questions & Answers

15 questions Updated 2026-06-26 Share:

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.

Read the in-depth guideValidation and JSON Serialization in Spring Boot, Explained(opens in new tab)
15 of 15

@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 ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel