The previous lesson closed with a crack left wide open: the CicloUrbana API accepts anything. A station with capacity -5, an empty name, a latitude of 200 degrees or a plate that looks nothing like RB-0142 all get in unopposed and are stored quite happily. The few checks we have written live scattered across the services, mixed in with the business rules, and none produces a useful message for the client. In this lesson we close that crack with Jakarta Bean Validation: a declarative mechanism that turns constraints into annotations on the data itself, applies them automatically at the edge of the application and lets us create constraints specific to the Ribalta domain. By the end, no malformed request will reach the service layer alive.

Contents

  1. Why validate at the edge and what layers of validation exist
  2. The dependency and the constraint catalogue
  3. @Valid on @RequestBody
  4. @Validated for @PathVariable and @RequestParam
  5. Nested objects and collections
  6. Validation groups: creation versus modification
  7. Custom messages and internationalisation
  8. A custom constraint: @BikePlate
  9. A class-level constraint: @ValidCoordinates
  10. Programmatic validation with Validator
  11. Validation error (400) versus business rule (409/422)
  12. Common Mistakes and Tips
  13. Exercises

  1. Why validate at the edge and what layers of validation exist

The edge is the point through which external data enters the application: in our case, the controller. Validating there fails early and cheaply —a POST with negative capacity is rejected before touching the service, the repository or the database—, produces useful messages —"capacity must be greater than zero" instead of a database constraint error— and simplifies the code inside: if StationService can assume the capacity is positive and the name is not empty, half of its ifs disappear.

That said, there are several kinds of validation and confusing them produces bad designs:

Layer What it checks Example in CicloUrbana Where it lives HTTP code
Format Is the JSON syntactically valid? {"capacity": left unclosed Jackson 400
Type Does the value fit the Java type? "capacity": "twenty" Jackson / ConversionService 400
Syntax Does the value follow the expected format? capacity > 0, plate RB-0000 Bean Validation, in the DTO 400
Consistency Are the fields coherent with each other? Latitude and longitude inside Ribalta Class-level Bean Validation 400
Business Is the operation legitimate given the system state? The destination station is full Service 409 / 422

The boundary between "syntax" and "business" is the hardest one. The practical rule: if you need to consult the state of the system to decide, it is business; if looking at the data is enough, it is syntax. "Capacity must be positive" is decided by looking at the number: syntax. "There cannot be two stations with the same name" requires querying the repository: business.

graph LR
    A["HTTP request"] --> B["Jackson<br/>format and types"]
    B --> C["Bean Validation<br/>@Valid in the controller"]
    C --> D["StationService<br/>business rules"]
    D --> E["Repository"]
    B -.400.-> X["Error response"]
    C -.400.-> X
    D -."409 / 422".-> X

  1. The dependency and the constraint catalogue

Bean Validation does not come with spring-boot-starter-web: it has to be added explicitly.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

This starter pulls in Hibernate Validator, the reference implementation of Jakarta Bean Validation 3.0. At startup, ValidationAutoConfiguration registers a LocalValidatorFactoryBean bean —you would see it in the --debug report from 02-06— and from then on @Valid works in the controllers. One important detail: the annotations live in jakarta.validation.constraints, not in javax.validation; Spring Boot 3 migrated to the Jakarta EE namespace and a good share of the examples out there still use javax, which is not compatible.

The catalogue of standard constraints:

Annotation What it requires Applicable types Use in CicloUrbana
@NotNull Not null (empty "" passes) Any destinationStationId when finishing
@NotEmpty Not null and size > 0 String, collections List of bikes in a batch
@NotBlank Not null and with some non-whitespace character String only Station name
@Size(min, max) Size within the range String, collections name between 3 and 80
@Min / @Max Integer within the range Integers batteryLevel between 0 and 100
@Positive / @PositiveOrZero Greater than zero / not negative Numeric capacity
@DecimalMin / @DecimalMax Range with decimals BigDecimal, double latitude, longitude
@Digits(integer, fraction) Number of digits Numeric totalAmount: 6 and 2
@Email Email format String The user's email
@Pattern(regexp) Matches the regular expression String plate (RB-\d{4})
@Past / @PastOrPresent Date in the past java.time dateOfBirth
@Future / @FutureOrPresent Date in the future java.time promotionEndDate
@AssertTrue / @AssertFalse Boolean with a specific value boolean acceptsTerms

The first three get confused constantly. With the value " " (three spaces): @NotNull passes, @NotEmpty passes (it has length 3) and @NotBlank fails; for a name you almost always want @NotBlank. And a note that saves surprises: every constraint except @NotNull considers the value null valid, so @Size(min = 3) on a null field passes without protest. If the field is mandatory you have to combine them: @NotBlank @Size(max = 80).

  1. @Valid on @RequestBody

We annotate the creation DTO from the previous lesson, already renamed to CreateStationRequest, which is the definitive name 03-05 will settle on:

package com.ciclourbana.stations;

import jakarta.validation.constraints.*;

public record CreateStationRequest(

        @NotBlank(message = "The station name is required")
        @Size(min = 3, max = 80, message = "The name must be between {min} and {max} characters")
        String name,

        @NotBlank @Size(max = 120)
        String address,

        @Positive(message = "Capacity must be greater than zero")
        @Max(value = 60, message = "No Ribalta station exceeds {value} docks")
        int capacity,

        @DecimalMin("-90.0")  @DecimalMax("90.0")  double latitude,
        @DecimalMin("-180.0") @DecimalMax("180.0") double longitude
) {}

The {min}, {max} and {value} placeholders are replaced by the values of the annotation itself, so the message does not go out of sync if the limit moves from 60 to 80 docks tomorrow. In the controller it is enough to add @Valid:

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Station> create(@Valid @RequestBody CreateStationRequest request) {
    Station created = stationService.create(request);
    URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}").buildAndExpand(created.id()).toUri();
    return ResponseEntity.created(location).body(created);
}

If validation fails, Spring throws MethodArgumentNotValidException and the controller method never runs. With a POST of {"name":"","capacity":-5,...}, Spring Boot 3's default response is:

{ "type": "about:blank", "title": "Bad Request", "status": 400,
  "detail": "Invalid request content.", "instance": "/api/v1/stations" }

It works —it is a 400— but it is useless for the client: it does not say which fields failed nor why. The details are inside the exception, and turning them into a response with the list of offending fields is 03-06's job: here we generate the error correctly, there we will present it properly.

  1. @Validated for @PathVariable and @RequestParam

@Valid only works on objects. To validate individual parameters —the path id, the pagination size— you need @Validated at class level, which activates an AOP proxy that intercepts the calls to the method.

@RestController
@RequestMapping(path = "/api/v1/stations", produces = MediaType.APPLICATION_JSON_VALUE)
@Validated                       // <-- essential: validates the parameters
public class StationController {

    @GetMapping
    public List<Station> list(
            @RequestParam(required = false) @Size(max = 80) String name,
            @RequestParam(required = false) @Positive Integer minimumCapacity,
            @RequestParam(defaultValue = "0")  @Min(0) int page,
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {

        // Goodbye to the manual clamping of lesson 03-02: it is declarative now
        return stationService.search(name, minimumCapacity, page, size);
    }

    @GetMapping("/{id:\\d+}")
    public ResponseEntity<Station> getById(@PathVariable("id") @Positive Long id) { ... }
}

Compare this list with the one from 03-02, where three lines of Math.min and Math.max were needed to stop ?size=1000000 from taking the service down. Now the constraint sits next to the parameter, documents itself in OpenAPI (03-07) and cannot be forgotten. Two important differences compared with @Valid:

Aspect @Valid on @RequestBody @Validated on the class
What it validates The object's fields The method's parameters
Exception MethodArgumentNotValidException ConstraintViolationException
Default status 400 Bad Request 500 Internal Server Error
Mechanism Argument resolver AOP proxy (MethodValidationPostProcessor)

The 500 in the third row is a classic trap: ConstraintViolationException is not mapped to any HTTP code, so Spring treats it as an unexpected error. It is wrong —the fault lies with the client, who sent ?size=5000— and we will fix it in the global handler of 03-06. Remember the warning from 03-01: a 5xx caused by the client pollutes production alerts.

  1. Nested objects and collections

Bean Validation does not descend automatically into nested objects: you have to ask for it with @Valid on the field.

public record LocationRequest(
        @DecimalMin("-90.0")  @DecimalMax("90.0")  double latitude,
        @DecimalMin("-180.0") @DecimalMax("180.0") double longitude) {}

public record CreateStationRequest(
        @NotBlank @Size(min = 3, max = 80) String name,
        @NotBlank String address,
        @Positive @Max(60) int capacity,

        @NotNull @Valid            // <-- without @Valid, the location is NOT validated
        LocationRequest location
) {}

Without that @Valid, a request with {"location": {"latitude": 500}} would pass validation without protest. It is one of the most frequent silent failures: validation "works" and yet lets impossible data through.

For collections there are two levels, and they are worth telling apart:

public record BikeBatchRequest(

        @NotEmpty(message = "The batch must contain at least one bike")
        @Size(max = 50, message = "No more than {max} bikes can be registered at once")
        List<@Valid @NotNull CreateBikeRequest> bikes,       // validates EACH element

        @NotNull @PastOrPresent LocalDate receivedOn
) {}

@NotEmpty and @Size apply to the list —how many elements it has—, whereas @Valid and @NotNull inside the angle brackets are constraints on the contained type (Bean Validation 2.0) and apply to each element. It also works on Optional<@NotBlank String> and on the keys and values of a Map<@NotBlank String, @Positive Integer>.

When the whole body is a list, @Valid on the @RequestBody is not enough: you have to wrap it in an object like the one above, or annotate the controller with @Validated and write @RequestBody List<@Valid CreateBikeRequest> batch. The first option is preferable: it lets you add metadata to the batch without breaking the contract.

  1. Validation groups: creation versus modification

The same DTO may need different rules depending on the operation: when creating a station the name is mandatory, whereas when partially modifying it the name may not arrive, although if it does it must satisfy the size limits. Groups solve this and are simply marker interfaces:

package com.ciclourbana.common;

/** Markers for the validation groups. They have no methods. */
public interface ValidationGroups {
    interface OnCreate {}
    interface OnUpdate {}
}
public record StationRequest(

        // Mandatory only when creating; when updating it may be omitted
        @NotBlank(groups = OnCreate.class, message = "The name is required when registering")
        @Size(min = 3, max = 80)              // no group: belongs to the Default group
        String name,

        @NotBlank(groups = OnCreate.class) @Size(max = 120) String address,
        @NotNull(groups = OnCreate.class) @Positive @Max(60) Integer capacity,

        // The id may only arrive when updating, and must match the path
        @Null(groups = OnCreate.class, message = "The id cannot be set when creating")
        @NotNull(groups = OnUpdate.class)
        Long id
) {}

To activate a group you must use @Validated(Group.class), not @Valid, which does not accept groups: create(@Validated(OnCreate.class) @RequestBody StationRequest request) and replace(..., @Validated(OnUpdate.class) @RequestBody StationRequest request).

The Default group rule that almost nobody remembers: a constraint without groups implicitly belongs to the Default group, and @Validated(OnCreate.class) does NOT include Default. In the example above, @Size(min = 3, max = 80) would not be evaluated on creation. The fix is to declare both groups in every annotation —@Size(..., groups = {OnCreate.class, OnUpdate.class})— or, better, to make the group inherit: public interface OnCreate extends jakarta.validation.groups.Default {}. With the latter, @Validated(OnCreate.class) evaluates the OnCreate constraints and those with no group, which is what you almost always want; it is what CicloUrbana adopts.

A design warning: groups are powerful but become unreadable fast. With more than two or three, it is usually better to have separate DTOs —CreateStationRequest and UpdateStationRequest—, each with its own rules. That is the solution the project will adopt in 03-05.

  1. Custom messages and internationalisation

Hibernate Validator's default messages are generic ("must not be blank"). Fixing them in the annotation, as we have done, leaves them written in the code and in a single language. The complete solution is to externalise them in one file per language under src/main/resources:

# messages.properties (default language: English)
station.name.required=The station name is required
station.name.size=The name must be between {min} and {max} characters
station.capacity.positive=Capacity must be greater than zero
bike.plate.format=The plate must follow the RB-0000 format
bike.battery.range=Battery level must be between {min} and {max}

# messages_es.properties
station.name.required=El nombre de la estación es obligatorio
station.name.size=El nombre debe tener entre {min} y {max} caracteres
station.capacity.positive=La capacidad debe ser mayor que cero
bike.plate.format=La matrícula debe seguir el formato RB-0000
bike.battery.range=El nivel de batería debe estar entre {min} y {max}

In the annotations the key is referenced between braces: @NotBlank(message = "{station.name.required}"). And the validator has to be wired to Spring's MessageSource, because by default Hibernate Validator looks for its messages in ValidationMessages.properties and not in Spring's messages.properties:

package com.ciclourbana.common;

@Configuration
public class ValidationConfig {

    /** Resolves the {keys} against Spring's MessageSource, so they inherit
     *  the language negotiated through Accept-Language. */
    @Bean
    LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean();
        factory.setValidationMessageSource(messageSource);
        return factory;
    }
}

With the MessageSource configuration in YAML:

spring:
  messages:
    basename: messages
    encoding: UTF-8
    fallback-to-system-locale: false   # if the language is missing, use messages.properties

The language is selected from Accept-Language. For Spring MVC to honour it, it is worth declaring the resolver explicitly:

@Bean
LocaleResolver localeResolver() {
    var resolver = new AcceptHeaderLocaleResolver();
    resolver.setDefaultLocale(Locale.forLanguageTag("en"));
    resolver.setSupportedLocales(List.of(Locale.forLanguageTag("en"),
            Locale.forLanguageTag("es"), Locale.forLanguageTag("ca")));
    return resolver;
}

Now a client sending Accept-Language: es receives "El nombre de la estación es obligatorio" and "La capacidad debe ser mayor que cero" without the code changing a single line.

  1. A custom constraint: @BikePlate

The plates of the Ribalta network follow the RB-0142 format: two fixed letters, a hyphen and four digits. We could use @Pattern(regexp = "RB-\\d{4}") everywhere, but copying the same regular expression into five DTOs is an invitation for them to diverge one day. A custom constraint gives the rule a name and centralises it.

A constraint is always made of two pieces: the annotation and the validator.

package com.ciclourbana.common.validation;

@Documented
@Constraint(validatedBy = BikePlateValidator.class)            // <-- the validator
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface BikePlate {

    // The following three attributes are MANDATORY in every constraint
    String message() default "{bike.plate.format}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    /** If true, the null value is accepted (the default, like the rest). */
    boolean allowNull() default true;
}

The three attributes message, groups and payload are not optional: if any is missing, Hibernate Validator fails at startup with a rather unhelpful error. ElementType.RECORD_COMPONENT is needed so that the annotation can be placed on a record component.

The validator:

package com.ciclourbana.common.validation;

public class BikePlateValidator
        implements ConstraintValidator<BikePlate, String> {

    // The expression is compiled ONCE: the validator is a reused singleton
    private static final Pattern PATTERN = Pattern.compile("^RB-\\d{4}$");

    private boolean allowNull;

    @Override
    public void initialize(BikePlate annotation) {
        this.allowNull = annotation.allowNull();
    }

    @Override
    public boolean isValid(String plate, ConstraintValidatorContext context) {
        if (plate == null) {
            return allowNull;        // by convention, null is usually left to @NotNull
        }
        return PATTERN.matcher(plate).matches();
    }
}

And its use, as clean as any standard constraint:

public record CreateBikeRequest(
        @NotBlank @BikePlate String plate,                  // the whole rule, one word

        @Min(value = 0,   message = "{bike.battery.range}")
        @Max(value = 100, message = "{bike.battery.range}")
        int batteryLevel,

        @NotNull @Positive Long stationId
) {}

One detail worth understanding: the validator is a bean with a managed life cycle, so it can inject dependencies through the constructor. That allows validators that query a repository... but careful, that would already be a business rule, and section 11 explains why it normally should not be done.

  1. A class-level constraint: @ValidCoordinates

Some rules cannot be expressed on an isolated field because they relate several. In CicloUrbana, the coordinates must fall within the Ribalta municipal boundary, and that requires looking at latitude and longitude together. The solution is a class-level constraint.

package com.ciclourbana.common.validation;

@Documented
@Constraint(validatedBy = ValidCoordinatesValidator.class)
@Target(ElementType.TYPE)                    // <-- on the TYPE, not on the field
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidCoordinates {
    String message() default "{station.coordinates.outside-ribalta}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class ValidCoordinatesValidator
        implements ConstraintValidator<ValidCoordinates, CreateStationRequest> {

    // Bounding box around the Ribalta municipal boundary
    private static final double LAT_MIN = 41.30, LAT_MAX = 41.48;
    private static final double LON_MIN = 2.05,  LON_MAX = 2.28;

    @Override
    public boolean isValid(CreateStationRequest r, ConstraintValidatorContext context) {
        if (r == null) {
            return true;
        }
        boolean inside = r.latitude() >= LAT_MIN && r.latitude() <= LAT_MAX
                && r.longitude() >= LON_MIN && r.longitude() <= LON_MAX;
        if (!inside) {
            // Without this, the error is attached to the whole object and the
            // client does not know which field to look at. With it, it goes to "latitude".
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(
                            "{station.coordinates.outside-ribalta}")
                    .addPropertyNode("latitude").addConstraintViolation();
        }
        return inside;
    }
}

And the whole record is annotated with @ValidCoordinates, on top of the field constraints it already had. The buildConstraintViolationWithTemplate block is what tells a usable class-level constraint from an annoying one: without it, the validation error has no associated field and the Ribalta panel's form cannot highlight anything. With it, the client receives the error pointing at latitude.

Note as well that if latitude is 500 both @DecimalMax("90.0") and @ValidCoordinates will fail, and the client will receive two violations: Bean Validation evaluates every constraint and returns the complete set, which is desirable because a form must show all its errors at once.

  1. Programmatic validation with Validator

Sometimes you need to validate away from the HTTP edge: when processing a CSV file for a bulk bike registration, when consuming a message from a queue (module 7) or when validating an object built inside the service itself. For that you inject Jakarta's Validator.

package com.ciclourbana.bikes;

@Service
public class BikeImporter {

    private final Validator validator;                    // jakarta.validation.Validator
    private final BikeService bikeService;

    // ... constructor injecting both

    /**
     * Imports a batch validating row by row. Unlike the HTTP edge,
     * here we do NOT want to abort the whole batch because of one bad row:
     * the failure is logged, that row is discarded and we carry on.
     */
    public ImportResult importBatch(List<CreateBikeRequest> rows) {
        List<Bike> imported = new ArrayList<>();
        Map<Integer, List<String>> errors = new LinkedHashMap<>();

        for (int i = 0; i < rows.size(); i++) {
            var violations = validator.validate(rows.get(i));
            if (violations.isEmpty()) {
                imported.add(bikeService.create(rows.get(i)));
            } else {
                List<String> messages = violations.stream()
                        .map(v -> v.getPropertyPath() + ": " + v.getMessage())
                        .sorted().toList();
                errors.put(i + 1, messages);           // row 1 = first in the file
                log.warn("Row {} discarded: {}", i + 1, messages);
            }
        }
        return new ImportResult(imported.size(), errors);
    }
}

validator.validate(object) returns an empty Set<ConstraintViolation<T>> if everything is fine. Each violation exposes getPropertyPath() (the field), getMessage() (already resolved and internationalised) and getInvalidValue() (the rejected value).

Approach When to use it On failure
Declarative (@Valid) HTTP input, 95% of cases Aborts the request with an exception
Programmatic (Validator) Batches, messaging, conditional validation You decide: discard, accumulate, warn

The rule: declarative by default, programmatic when you need to control what happens after the failure. Rejecting 4,000 correct rows because row 137 has a bad plate would be absurd.

About getInvalidValue(), a security warning picked up again in 03-06: never include it in the response without thinking. If the field that failed were a password or a card number, you would be returning the sensitive data in the error body and, almost certainly, writing it to the log.

  1. Validation error (400) versus business rule (409/422)

This is the design decision that generates the most argument. CicloUrbana's policy, with examples:

Situation Detected by Code Why
Malformed JSON Jackson 400 The request is not interpretable
capacity is not a number Jackson 400 Wrong type
capacity is -5 Bean Validation 400 The data is invalid in itself
plate does not match RB-0000 Bean Validation 400 Wrong format
A station with that name already exists Service 409 Conflict with the current state
The destination station is full Service 409 Conflict with the current state
The bike is under maintenance Service 422 Syntactically correct, not processable
Battery below the threshold Service 422 Depends on configuration and state

The operational criterion: if the client can fix the request by looking only at what it sent, it is a 400; if it needs to know something about the server state, it is not. capacity: -5 fixes itself; Main Square being full cannot be guessed from the client. Between 409 and 422 the boundary is blurrier, and the project convention is 409 Conflict when the conflict is with another existing resource (duplicate name, full station) and 422 Unprocessable Entity when the request clashes with a business rule or with a resource's state (bike under maintenance, rental already finished). What matters is not which convention you pick, but documenting it and applying it without exceptions: a client that sees 409 in one place and 422 in another for the same kind of failure cannot program against the API.

There is a temptation worth resisting: putting business rules inside a ConstraintValidator. It is technically possible —the validator is a bean and can inject StationRepository— and there are examples online of a @UniqueStationName. The problems: it produces the wrong HTTP code (400 instead of 409); it queries the database outside the transaction, so between validation and saving another thread may insert the same name, which makes the check unreliable; and it breaks reuse, because the validator only runs if someone calls @Valid and the CSV importer or a queue consumer would bypass it.

The project's firm rule: the validator looks at the data, the service looks at the system.

Common Mistakes and Tips

Forgetting the spring-boot-starter-validation dependency. Without it, the annotations compile and do nothing. It is the most frequent silent failure: everything looks fine until a negative capacity reaches production. The three sibling omissions produce the same symptom: @Valid on the @RequestBody (the DTO annotations are ignored), @Validated on the class (the @RequestParam and @PathVariable constraints are not evaluated) and @Valid on a nested field (the inner object goes through unvalidated).

Confusing @NotNull, @NotEmpty and @NotBlank. With " ": the first two pass, the third fails. For a name you want @NotBlank. Related: @Size does not imply not null, because every constraint except @NotNull accepts null.

Groups without Default. @Validated(OnCreate.class) does not evaluate the constraints with no group. Make your group extend Default.

Using javax.validation in Spring Boot 3. The annotations exist if some old dependency drags the package in, but Hibernate Validator never looks at them. The right one is jakarta.validation.

Tip: validate in the DTO, never in the domain entity. The entity may have different rules and must not know the API contract: another argument for the separation in 03-05. And write the invalid case before the valid one: when creating an endpoint, the first request in the .http file should carry incorrect data. If it responds 200, validation is not switched on.

Exercises

Exercise 1: Validate the whole rental flow

StartRentalRequest(Long userId, Long bikeId) and FinishRentalRequest(Long destinationStationId) have no validation at all. Add the appropriate constraints, decide which checks must not be Bean Validation and justify why. Also apply validation to the parameters of the rental listing, which accepts ?from= and ?to= with dates.

Exercise 2: Custom constraint @CoherentCapacity

Ribalta city council requires a station's capacity to be a multiple of 6, because the docks are installed in blocks of six units. Create a reusable @MultipleOf(6) constraint with its validator, applicable to int, Integer and long, with an internationalised message.

Exercise 3: Conditional validation across fields

In the "Ribalta Summer" promotion, the DTO PromotionRequest(String name, LocalDate start, LocalDate end, Integer discount, Boolean combinable) must satisfy: end later than start; if combinable is true, the discount cannot exceed 20%; and the duration cannot exceed 90 days. Implement it with a class-level constraint that reports the correct field in each case.

Solutions

Solution 1.

public record StartRentalRequest(
        @NotNull(message = "{rental.user.required}") @Positive Long userId,
        @NotNull(message = "{rental.bike.required}") @Positive Long bikeId) {}

public record FinishRentalRequest(
        @NotNull(message = "{rental.destination.required}") @Positive Long destinationStationId) {}

What must NOT be Bean Validation:

Check Why it is not validation
That user 42 exists Requires the repository: business, and a 404 besides
That the bike is AVAILABLE Current system state: 422
That the battery is above the threshold Configuration (ciclourbana.network.battery-threshold) + state
That the destination station has room System state: 409
That the rental is not already finished Resource state: 422

They all live in RentalService. Bean Validation only guarantees that the identifiers arrive and are positive: exactly what can be decided by looking at the data. For the listing, with @Validated on the class:

@GetMapping
public List<Rental> list(
        @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE)
        @PastOrPresent LocalDate from,

        @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE)
        @PastOrPresent LocalDate to,

        @RequestParam(defaultValue = "0")  @Min(0) int page,
        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {

    // "from <= to" relates two separate parameters: Bean Validation
    // cannot express that over method parameters. It goes to the service.
    return rentalService.search(from, to, page, size);
}

@DateTimeFormat is not a constraint but a conversion instruction: it tells Spring that ?from=2026-08-01 should be converted to LocalDate. Without it the conversion depends on the Locale and fails inconsistently. And the comment points at the interesting part: the from <= to relationship cannot be expressed with Bean Validation over a method's individual parameters. The ways out are to group the parameters into a filter record with a class-level constraint —as in solution 3— or to check it in the service, which is enough for an isolated case.

Solution 2.

package com.ciclourbana.common.validation;

@Documented
@Constraint(validatedBy = MultipleOfValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface MultipleOf {
    String message() default "{validation.multiple-of}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    /** Divisor: the annotated value must be a multiple of this number. */
    int value();
}

/**
 * It is declared over Number to accept int, Integer, long, Long and short:
 * autoboxing makes the primitives arrive as wrappers.
 */
public class MultipleOfValidator implements ConstraintValidator<MultipleOf, Number> {

    private int divisor;

    @Override
    public void initialize(MultipleOf annotation) {
        this.divisor = annotation.value();
        if (divisor == 0) {
            // Fails at startup, not at request time: a better error, sooner
            throw new IllegalArgumentException("@MultipleOf does not accept divisor 0");
        }
    }

    @Override
    public boolean isValid(Number value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;                    // being mandatory is @NotNull's job
        }
        return value.longValue() % divisor == 0;
    }
}
validation.multiple-of=The value must be a multiple of {value}
station.capacity.blocks=Docks are installed in blocks of {value}: the capacity must be a multiple of {value}

And in the DTO, alongside the constraints the field already had:

@Positive @Max(60)
@MultipleOf(value = 6, message = "{station.capacity.blocks}")
int capacity,

With this constraint, Ribalta's four existing stations remain valid: 24, 30, 18 and 36 are all multiples of 6. It is always worth doing that check before adding a constraint to a running system, because a rule that invalidates the existing data breaks the PUT of resources that were already there. And about Number instead of Integer: a ConstraintValidator<MultipleOf, Integer> would not apply to a long field, whereas Number covers the whole integer family; for BigDecimal a different validator would be needed, because longValue() would silently truncate the decimals.

Solution 3.

@CoherentPromotion
public record PromotionRequest(
        @NotBlank @Size(min = 3, max = 60) String name,
        @NotNull @FutureOrPresent LocalDate start,
        @NotNull LocalDate end,
        @NotNull @Min(1) @Max(50) Integer discount,
        @NotNull Boolean combinable) {}

public class CoherentPromotionValidator
        implements ConstraintValidator<CoherentPromotion, PromotionRequest> {

    private static final int MAX_DAYS = 90;
    private static final int MAX_COMBINABLE_DISCOUNT = 20;

    @Override
    public boolean isValid(PromotionRequest p, ConstraintValidatorContext ctx) {

        // If mandatory fields are missing, their own constraints will
        // report it: we add no noise here. It is an important pattern.
        if (p == null || p.start() == null || p.end() == null
                || p.discount() == null || p.combinable() == null) {
            return true;
        }

        boolean valid = true;
        ctx.disableDefaultConstraintViolation();   // we control the messages one by one

        if (!p.end().isAfter(p.start())) {
            error(ctx, "{promotion.end.after}", "end");
            valid = false;
        } else if (ChronoUnit.DAYS.between(p.start(), p.end()) > MAX_DAYS) {
            error(ctx, "{promotion.duration.maximum}", "end");
            valid = false;
        }

        if (p.combinable() && p.discount() > MAX_COMBINABLE_DISCOUNT) {
            error(ctx, "{promotion.discount.combinable}", "discount");
            valid = false;
        }
        return valid;
    }

    private void error(ConstraintValidatorContext ctx, String template, String field) {
        ctx.buildConstraintViolationWithTemplate(template)
                .addPropertyNode(field).addConstraintViolation();
    }
}
promotion.end.after=The end date must be later than the start date
promotion.duration.maximum=A promotion cannot last more than 90 days
promotion.discount.combinable=A combinable promotion cannot exceed 20%

Three design decisions worth remembering:

  1. Returning true when mandatory fields are missing. Bean Validation does not guarantee the evaluation order, so the class-level constraint may run with null fields that @NotNull is already flagging. Without that guard, the client would receive a NullPointerException turned into a 500, or two contradictory messages about the same field.
  2. The else if between "end after start" and "maximum duration". If the end is before the start, reporting the duration as well is just noise.
  3. Accumulating every error instead of bailing out at the first. The method keeps evaluating the discount even when the dates have already failed, because they are independent problems and the form must show them together. The valid variable exists for that; a premature return false would force the user into two round trips.

Conclusion

The crack is closed. You know how to distinguish the five layers of validation —format, type, syntax, consistency and business— and you have an operational criterion for separating them: if looking at the data is enough it is syntax, and if you have to consult the state of the system it is business. You know the complete catalogue of Jakarta Bean Validation constraints and the classic traps: the difference between @NotNull, @NotEmpty and @NotBlank, and the fact that every one except @NotNull accepts the null value without protest. You know how to apply @Valid to request bodies and @Validated at class level for path and query parameters, and you know the difference between the exceptions each one throws —MethodArgumentNotValidException and ConstraintViolationException— and the uncomfortable fact that the second produces a 500 by default. You know how to descend into nested objects with @Valid on the field, and how to validate every element of a collection with constraints on the contained type. You handle validation groups and the Default group trap, with the recommendation not to overuse them.

On top of that, CicloUrbana's messages are now externalised in messages.properties and internationalised, wired to Spring's MessageSource and selected by Accept-Language. You have built two complete custom constraints: @BikePlate with its ConstraintValidator for the RB-0000 format, and @ValidCoordinates as a class-level constraint that checks a station falls inside the Ribalta municipal boundary and attaches the error to the right field. You know how to validate programmatically with the injected Validator when you need to decide what happens after the failure, as in batch importing. And you have the project's policy table on when a failure is a 400, when a 409 and when a 422, with the firm rule that the validator looks at the data and the service looks at the system.

One loose end keeps showing up. We are validating CreateStationRequest, an object that is not Station; we have talked about UpdateStationRequest without building it; and we have been dragging along since 03-02 the exercise that demonstrated why exposing the domain class directly does not scale. The project now has two representations of a station coexisting without our having organised that coexistence.

Lesson 03-05, DTOs and Mapping Between Layers, organises it. We will see with concrete cases why domain entities are not exposed —coupling, sensitive data leaks, circular references, an impossible contract evolution—, we will design CicloUrbana's complete DTO hierarchy separating request from response objects, and we will compare the mapping strategies: manual, MapStruct with its Maven configuration and its generated code, and ModelMapper with its risks. We will decide where the mapping lives and build StationResponse, StationDetailResponse with its list of BikeSummary, and RentalResponse. By the end, the Ribalta domain and its public contract will finally be two separate things.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved