Follow

Keep Up to Date with the Most Important News

By pressing the Subscribe button, you confirm that you have read and are agreeing to our Privacy Policy and Terms of Use
Contact

Jakarta ConstraintValidator: How to Choose Between Create vs Update?

Struggling with conditional validation in Jakarta Bean Validation? Learn how to use constraint groups for create vs update fields in a single DTO.
Split-screen thumbnail showcasing Jakarta ConstraintValidator in action with CREATE and UPDATE validation paths for a Java DTO Split-screen thumbnail showcasing Jakarta ConstraintValidator in action with CREATE and UPDATE validation paths for a Java DTO
  • ✅ 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:

MEDevel.com: Open-source for Healthcare and Education

Collecting and validating open-source software for healthcare, education, enterprise, development, medical imaging, medical records, and digital pathology.

Visit Medevel

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:

  • lastName must be set only if type == "PERSONAL"
  • email is required only if sendNotifications == 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

  1. ❌ Not Passing Down Groups in Nested Objects:

    • When using inner DTOs, always annotate with @Valid and pass the validation group explicitly.
  2. ❌ Mixing Default Group Accidentally:

    • Rules without a group go into Default.class. They might then be used in all situations without you meaning to.
  3. ❌ Skipping GroupSequence Control:

    • Without @GroupSequence, validations run in any order. They might also skip checks that take a lot of resources too soon.
  4. ❌ 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

Add a comment

Leave a Reply

Keep Up to Date with the Most Important News

By pressing the Subscribe button, you confirm that you have read and are agreeing to our Privacy Policy and Terms of Use

Discover more from Dev solutions

Subscribe now to keep reading and get access to the full archive.

Continue reading