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
- Why validate at the edge and what layers of validation exist
- The dependency and the constraint catalogue
@Validon@RequestBody@Validatedfor@PathVariableand@RequestParam- Nested objects and collections
- Validation groups: creation versus modification
- Custom messages and internationalisation
- A custom constraint:
@BikePlate - A class-level constraint:
@ValidCoordinates - Programmatic validation with
Validator - Validation error (400) versus business rule (409/422)
- Common Mistakes and Tips
- Exercises
- 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
- 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).
@Valid on @RequestBody
@Valid on @RequestBodyWe 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.
@Validated for @PathVariable and @RequestParam
@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.
- 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.
- 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.
- 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.propertiesThe 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.
- A custom constraint:
@BikePlate
@BikePlateThe 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.
- A class-level constraint:
@ValidCoordinates
@ValidCoordinatesSome 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.
- Programmatic validation with
Validator
ValidatorSometimes 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.
- 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:
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:
- Returning
truewhen mandatory fields are missing. Bean Validation does not guarantee the evaluation order, so the class-level constraint may run with null fields that@NotNullis already flagging. Without that guard, the client would receive aNullPointerExceptionturned into a500, or two contradictory messages about the same field. - The
else ifbetween "end after start" and "maximum duration". If the end is before the start, reporting the duration as well is just noise. - 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
validvariable exists for that; a prematurereturn falsewould 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
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
