Validation & Exception Handling

Validation Groups & Advanced Scenarios

18 min Lesson 9 of 13

Validation Groups & Advanced Scenarios

The built-in Jakarta Validation annotations and custom constraints you have seen so far apply uniformly — every time you validate an object, every annotated field is checked. That is fine for simple forms, but real APIs quickly outgrow this model. A UserDto used for registration needs a password; the same DTO used to update a profile must not require it. A request to create a resource must not supply an ID; the request to update it must. And rules like "the end date must be after the start date" cannot be expressed on a single field at all — they span the whole object.

Jakarta Validation answers both challenges: validation groups let you activate only a subset of constraints at a time, and cross-field (class-level) constraints let you validate relationships between fields. Both are first-class features in Spring Boot 3 with Spring 6.

What Are Validation Groups?

Every constraint annotation accepts a groups attribute. When you leave it empty the constraint belongs to the Default group, which is what @Valid activates. By setting groups to one or more marker interfaces you create named groups, and then you ask Spring to validate only those groups by replacing @Valid with @Validated.

The marker interfaces are plain Java interfaces — they carry no methods. Their only purpose is to act as a type-safe label.

package com.example.api.validation; public interface OnCreate {} public interface OnUpdate {}

Now annotate your DTO fields with the appropriate group:

package com.example.api.dto; import com.example.api.validation.OnCreate; import com.example.api.validation.OnUpdate; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Null; import jakarta.validation.constraints.Size; public class UserDto { // Must be null on create (server assigns it), must be present on update @Null(groups = OnCreate.class, message = "ID must not be supplied on creation") @NotNull(groups = OnUpdate.class, message = "ID is required for updates") private Long id; @NotBlank(groups = { OnCreate.class, OnUpdate.class }) @Size(min = 2, max = 60) private String name; // Required only when creating a new account @NotBlank(groups = OnCreate.class, message = "Password is required for new accounts") @Size(min = 8, groups = OnCreate.class) private String password; // getters and setters omitted for brevity }
Groups are additive. A field can belong to multiple groups by listing them in the array: groups = { OnCreate.class, OnUpdate.class }. When you activate OnCreate, only constraints that declare that group (or Default, depending on configuration) fire.

Activating Groups in a Controller with @Validated

Spring's @Validated (from org.springframework.validation.annotation) accepts group classes as arguments. Use it instead of @Valid when you want group-selective validation:

import com.example.api.dto.UserDto; import com.example.api.validation.OnCreate; import com.example.api.validation.OnUpdate; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/users") public class UserController { @PostMapping public UserDto createUser(@RequestBody @Validated(OnCreate.class) UserDto dto) { // Only OnCreate constraints are checked here return userService.create(dto); } @PutMapping("/{id}") public UserDto updateUser(@PathVariable Long id, @RequestBody @Validated(OnUpdate.class) UserDto dto) { // Only OnUpdate constraints are checked here return userService.update(id, dto); } }
Tip — include Default when you mean it. Constraints with no explicit groups belong to Default. When you call @Validated(OnCreate.class), those Default-group constraints are not checked unless you also pass jakarta.validation.groups.Default.class. Be deliberate: either assign every constraint to an explicit group, or always combine your group with Default.

Ordering Groups with GroupSequence

Sometimes you want groups to run in order, stopping on the first failure. @GroupSequence defines that order on a dedicated interface:

import jakarta.validation.GroupSequence; import jakarta.validation.groups.Default; import com.example.api.validation.OnCreate; @GroupSequence({ Default.class, OnCreate.class }) public interface CreateSequence {}

Then use @Validated(CreateSequence.class) in the controller. If any Default constraint fails, the OnCreate group is never executed, which avoids a cascade of redundant errors.

Cross-Field Validation with Class-Level Constraints

Field-level constraints validate a single value in isolation. Cross-field rules — "end must be after start", "password and confirmation must match" — need access to the whole object. The cleanest solution is a class-level constraint: a custom annotation placed on the class itself whose validator receives the entire instance.

Define the annotation targeting ElementType.TYPE:

package com.example.api.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; @Documented @Constraint(validatedBy = DateRangeValidator.class) @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface ValidDateRange { String message() default "startDate must be before endDate"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }

Write the validator. The type parameter T is the class being annotated:

package com.example.api.validation; import com.example.api.dto.EventDto; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public class DateRangeValidator implements ConstraintValidator<ValidDateRange, EventDto> { @Override public boolean isValid(EventDto dto, ConstraintValidatorContext ctx) { if (dto.getStartDate() == null || dto.getEndDate() == null) { return true; // let @NotNull handle the null case } boolean valid = dto.getStartDate().isBefore(dto.getEndDate()); if (!valid) { // Attach the error to a specific field instead of the class root ctx.disableDefaultConstraintViolation(); ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate()) .addPropertyNode("endDate") .addConstraintViolation(); } return valid; } }

Apply it to the DTO:

import com.example.api.validation.ValidDateRange; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; @ValidDateRange public class EventDto { @NotNull private LocalDate startDate; @NotNull private LocalDate endDate; @NotBlank private String title; // getters and setters omitted }
Always return true when the values needed for the cross-field check are null. If you return false on null inputs you will produce a confusing class-level error message alongside the more helpful @NotNull field-level message. Guard at the top of isValid and let the field constraints do their job first.

The Password Confirmation Pattern

A very common cross-field scenario is verifying that two fields match. The same class-level technique applies:

@PasswordsMatch public class RegistrationDto { @NotBlank @Size(min = 8) private String password; @NotBlank private String passwordConfirm; // getters omitted } // Validator: public class PasswordsMatchValidator implements ConstraintValidator<PasswordsMatch, RegistrationDto> { @Override public boolean isValid(RegistrationDto dto, ConstraintValidatorContext ctx) { if (dto.getPassword() == null || dto.getPasswordConfirm() == null) { return true; } boolean match = dto.getPassword().equals(dto.getPasswordConfirm()); if (!match) { ctx.disableDefaultConstraintViolation(); ctx.buildConstraintViolationWithTemplate("Passwords do not match") .addPropertyNode("passwordConfirm") .addConstraintViolation(); } return match; } }

Combining Groups with Cross-Field Constraints

Class-level constraints support the groups attribute just like field-level ones. You can restrict a cross-field check to a specific group:

@ValidDateRange(groups = OnCreate.class) public class EventDto { ... }

This is useful when the date range is only enforced during creation and patching allows partial updates where one date might legitimately be absent.

Programmatic Validation Outside Controllers

Sometimes you need to validate manually in a service — before persisting to a database, or inside a batch process. Inject the jakarta.validation.Validator bean that Spring Boot auto-configures:

import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; import java.util.Set; @Service public class EventService { private final Validator validator; public EventService(Validator validator) { this.validator = validator; } public void schedule(EventDto dto) { Set<ConstraintViolation<EventDto>> violations = validator.validate(dto); if (!violations.isEmpty()) { throw new ConstraintViolationException(violations); } // proceed with persistence } }

To validate only a specific group programmatically, pass the group class as a second argument: validator.validate(dto, OnCreate.class).

Trade-offs and When to Use Each Approach

  • Groups — ideal when the same DTO is reused across create/update/patch endpoints and the difference is structural (which fields are required). Avoid overusing them: if the shapes diverge too much, separate DTOs are cleaner.
  • Class-level constraints — the right tool for any rule that involves more than one field. Keep them focused; a validator that checks five different inter-field relationships is hard to test and hard to maintain.
  • Programmatic validation — reach for it in services and batch jobs, but prefer annotation-driven validation at the API boundary where the framework wiring is already in place.

Summary

Validation groups let you apply different rule sets to the same class depending on context, activated through @Validated(Group.class) in the controller. Cross-field validation uses class-level custom constraints whose validator receives the whole object, allowing you to enforce relationships between fields and attach violations to specific property paths. Together, these two techniques cover virtually every validation scenario a production API encounters — without scattering business rules across controllers or services.