- ✅ Conditional validation in Jakarta lets you use different rules for create vs update actions, using Validation Groups.
- 🧠 ConstraintValidator lets you use complex logic, like cross-field validation, that you cannot do with simple annotations.
- ⚠️ Missing @GroupSequence can make validation rules run in the wrong order.
- 🧪 You can easily unit test validation logic. This makes APIs more robust for DTO validations.
- 🧩 The @Valid annotation needs group propagation when you validate DTOs inside other DTOs. If you do not do this, validations might fail without you knowing.
Jakarta Bean Validation is a standard way to make sure rules are followed for Java objects. But real APIs often need rules that change based on what you are doing. For instance, you might check password fields differently when you create something compared to when you update it. This is where ConstraintValidator, conditional validation, and bean validation groups are useful. They let you control things precisely and in a way that is easy to add to.
What is Jakarta Bean Validation and ConstraintValidator?
Jakarta Bean Validation (formerly JSR-380 under javax.validation) is a standard way for developers to add rules to JavaBean properties using annotations. These rules can be simple checks, like @NotNull, @Size, or @Email. Or, they can be more complex checks that use code, through a custom ConstraintValidator.
Typical Declarative Validation Example:
public class UserDTO {
@NotNull
@Size(min = 5, message = "Username must be at least 5 characters")
private String username;
@Email
private String email;
}
This way is simple and easy to understand. It works well with frameworks like Spring and Jakarta EE. But it is not flexible enough for some cases, for example:
- Cross-field validation (e.g., one field depends on another)
- Conditional rules (e.g., a field is required only in certain situations)
- Validating complex objects with different rules based on the situation.
That’s when the ConstraintValidator<A, T> interface is used.
Custom Validations with ConstraintValidator
A ConstraintValidator lets you use your own code logic within a system that usually just declares rules. The interface looks like this:
public interface ConstraintValidator<A extends Annotation, T> {
void initialize(A constraintAnnotation);
boolean isValid(T value, ConstraintValidatorContext context);
}
You can use it for input consistency checks, like making sure a password matches its confirmation. You can also use it for making fields required only in certain cases, and for validation that needs outside rules or database checks.
Key Traits:
- Executes at runtime
- Fully customizable
- Can be applied to fields or entire classes
Conditional Validation: Why Create vs Update Is Tricky
You often need to validate the same DTO differently based on what action is happening. Think about managing users:
public class UserDTO {
@NotNull
private String username;
@NotNull
private String password;
}
This works well for creating a user, because both fields are required. But during an update, users might change only the username and leave the password blank. If we do not want to make the password required during updates, then we have a problem.
If you put specific rules directly into every validator, your code gets messy. Validation groups offer a good answer from Jakarta Bean Validation.
Using Validation Groups to Your Advantage
Validation groups let you put rules into named groups. You then only use the rules from the group you pick when you validate.
Step 1: Define Marker Interfaces
These interfaces have no logic. They simply act as names for groups.
public interface OnCreate {}
public interface OnUpdate {}
You can make as many marker interfaces as you need, based on how your application works (e.g., OnPublish, OnDraftSave, OnReview, etc.).
Step 2: Annotate DTO Fields
Add validation rules and put them into groups using the groups attribute.
public class UserDTO {
@NotNull(groups = OnCreate.class)
private String password;
@NotNull(groups = {OnCreate.class, OnUpdate.class})
private String username;
}
Here, username is always required, but password is required only during OnCreate.
Step 3: Triggering Group-Based Validations
Use the group you named when starting validation. You can do this by hand or with help from your framework.
Manual validator usage:
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<UserDTO>> violations = validator.validate(userDto, OnCreate.class);
Controller-based validation in Spring:
@PostMapping("/users")
public ResponseEntity<Void> createUser(@RequestBody @Validated(OnCreate.class) UserDTO userDto) {
// Valid only according to OnCreate group
}
Update operations can be similarly validated using @Validated(OnUpdate.class).
🔎 Tip: When you use DTOs inside other DTOs, also add @Valid and pass the group types down. If you do not do this, validations for nested items might not run without you knowing.
Combining With @GroupSequence
When you want validation to happen in a certain order, like basic checks first, then database checks that take a lot of resources, use @GroupSequence.
@GroupSequence({Default.class, OnCreate.class})
public interface CreateSequence {}
Now, rules in Default run first. If they pass, then it moves to OnCreate.
Using ConstraintValidator for Conditional Validation
What if certain fields only need validation under certain conditions that can change? For example:
lastNamemust be set only iftype == "PERSONAL"emailis required only ifsendNotifications == true
You cannot solve these with just static groups. This is where you use validator logic at the class level.
Creating a Custom Conditional Validator
Step 1: Define a Custom Annotation
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ConditionalValidator.class)
public @interface CheckCondition {
String message() default "Condition failed";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Step 2: Implement the Logic
public class ConditionalValidator implements ConstraintValidator<CheckCondition, UserDTO> {
@Override
public boolean isValid(UserDTO dto, ConstraintValidatorContext context) {
if ("PERSONAL".equals(dto.getType())) {
return dto.getLastName() != null && !dto.getLastName().trim().isEmpty();
}
return true; // Skip for other types
}
}
Step 3: Annotate Your DTO
@CheckCondition(groups = OnUpdate.class)
public class UserDTO {
private String type;
private String lastName;
// Other fields
}
Now the validator only runs when you validate with the OnUpdate group and the type condition is true.
🔧 Developer Tip: Use ConstraintValidatorContext to connect the error message to a specific field (like lastName). This makes for clearer API error messages.
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("Last name is required for PERSONAL type")
.addPropertyNode("lastName")
.addConstraintViolation();
Reusability with BaseDTOs
Do not copy validation rules across similar DTOs. Instead, use inheritance and base classes.
public abstract class BaseUserDTO {
@NotNull(groups = {OnCreate.class, OnUpdate.class})
private String username;
}
public class CreateUserDTO extends BaseUserDTO {
@NotNull(groups = OnCreate.class)
private String password;
}
You can also pass down group annotations by extending marker interfaces:
public interface OnSoftUpdate extends OnUpdate {}
This means all OnUpdate rules apply to OnSoftUpdate as well. This is very useful for validation that works in layers.
Pitfalls to Avoid with Validation Groups
-
❌ Not Passing Down Groups in Nested Objects:
- When using inner DTOs, always annotate with
@Validand pass the validation group explicitly.
- When using inner DTOs, always annotate with
-
❌ Mixing Default Group Accidentally:
- Rules without a group go into
Default.class. They might then be used in all situations without you meaning to.
- Rules without a group go into
-
❌ Skipping GroupSequence Control:
- Without
@GroupSequence, validations run in any order. They might also skip checks that take a lot of resources too soon.
- Without
-
❌ Conflicting Validators in Shared Classes:
- Do not share DTOs across controllers that have very different validation rules. Instead, use specific classes or groups for them.
Declarative vs. Programmatic Validation: How to Choose
| Use Case | Use Declarative (@NotNull, etc.) |
Use Custom Validator |
|---|---|---|
| Simple per-field rules | ✅ | ❌ |
| Rules that depend on other fields | ❌ | ✅ |
| DTO reuse across endpoints | ✅ | ✅ |
| Field required only on update | ✅ (via groups) | ❌ |
| Field required based on value | ❌ | ✅ |
Real-World Testing Strategy
Test your rules using standard JUnit + Jakarta Validator for fast results:
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
UserDTO dto = new UserDTO();
dto.setType("PERSONAL");
dto.setLastName(null);
Set<ConstraintViolation<UserDTO>> violations = validator.validate(dto, OnUpdate.class);
assertFalse(violations.isEmpty());
assertEquals("Last name is required for PERSONAL type", violations.iterator().next().getMessage());
You can also write integration tests by sending full HTTP requests to your controller. This simulates what really happens.
Swagger/OpenAPI Considerations
Tools like Swagger UI might not understand validation groups on their own. This creates differences in your documentation.
Solutions:
- Make separate DTOs for each action (
CreateUserDTO,UpdateUserDTO) for clearer documentation. - Use OpenAPI annotations like
@Schema(description = "...")from Swagger for clear documentation. - Implement custom ModelConverters if using MapStruct or OpenAPI Generators
🔎 In short: Validation groups make your logic better but might make documentation less clear. So plan for this.
Putting It All Together: Full Working Example
public interface OnCreate {}
public interface OnUpdate {}
@CheckCondition(groups = OnUpdate.class)
public class UserDTO {
@NotNull(groups = OnCreate.class)
private String password;
@NotNull(groups = {OnCreate.class, OnUpdate.class})
private String username;
private String type;
private String lastName;
}
Validator Implementation:
public class CheckTypeConditionValidator implements ConstraintValidator<CheckCondition, UserDTO> {
@Override
public boolean isValid(UserDTO dto, ConstraintValidatorContext context) {
if ("PERSONAL".equals(dto.getType())) {
boolean valid = dto.getLastName() != null;
if (!valid) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("Last name is required for PERSONAL users")
.addPropertyNode("lastName")
.addConstraintViolation();
}
return valid;
}
return true;
}
}
Validation Execution:
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<UserDTO>> violations = validator.validate(dto, OnUpdate.class);
When API validations are cleaner and fit the situation better, APIs become more robust. Error messages also get clearer, and developers can work faster. If you use Jakarta ConstraintValidator with conditional validation and bean validation groups, your DTOs become smarter. Also, you can make sure your business rules are followed at the right time without too much trouble.
Citations
Oracle. (2021). Jakarta Bean Validation Specification, Version 3.0. Jakarta EE.
https://jakarta.ee/specifications/bean-validation/3.0/jakarta-bean-validation-spec-3.0.html
Eclipse Foundation. (2023). Jakarta Bean Validation 3.0 API.
https://jakarta.ee/specifications/bean-validation/3.0/apidocs/
Baeldung. (2023). Using Validation Groups in Bean Validation.
https://www.baeldung.com/java-bean-validation-groups