You’ve got @Validated with group specification written, but for an input with an empty name and an invalid email, the department ID existence check still goes all the way to the DB. On top of that, the response mixes format errors with consistency errors. Sound familiar?

The cause is simple: by default, Bean Validation groups have no guaranteed order, and every specified group is executed.

This article takes a step beyond the interface-based @GroupSequence introduced in the @Validated article. It covers the pitfalls of putting @GroupSequence on a DTO class to redefine the Default group, an implementation that switches groups based on state using the Hibernate Validator-specific @GroupSequenceProvider, staged error responses in a REST API, and MockMvc tests that guarantee “the later-stage constraints were never invoked”. The assumed setup is Spring Boot 3.x + spring-boot-starter-validation (Hibernate Validator 8.x).

Passing Multiple Groups Does Not Control the Order

Let’s start by reproducing the problem. Here is a DTO with two groups: Format for format checks and Persistence for DB existence checks.

public interface Format {}
public interface Persistence {}

public record UserRequest(
        @NotBlank(groups = Format.class) String name,
        @Email(groups = Format.class) String email,
        @NotNull(groups = Format.class)
        @ExistingDepartment(groups = Persistence.class)
        Long departmentId
) {}

@ExistingDepartment is a custom constraint that calls a Repository. We add a log line so we can tell when it is invoked.

public class ExistingDepartmentValidator
        implements ConstraintValidator<ExistingDepartment, Long> {

    private static final Logger log = LoggerFactory.getLogger(ExistingDepartmentValidator.class);
    private final DepartmentRepository repository;

    public ExistingDepartmentValidator(DepartmentRepository repository) {
        this.repository = repository;
    }

    @Override
    public boolean isValid(Long id, ConstraintValidatorContext ctx) {
        if (id == null) return true;
        log.info("existsById called: {}", id);
        return repository.existsById(id);
    }
}

The constraint annotation definition and the mechanism for injecting the Repository are explained in the custom validation annotation article, so they are omitted here.

Declare the Controller parameter as @RequestBody @Validated({Format.class, Persistence.class}) UserRequest request and deliberately send an input with format errors.

INFO  ExistingDepartmentValidator : existsById called: 999
WARN  DefaultHandlerExceptionResolver : Validation failed ... with 3 errors

Even though name is empty and email is invalid, existsById was called. Per the specification, listing multiple groups in @Validated does not define an execution order, and violations from all groups are returned together.

There are two problems here. Unnecessary DB access occurs, and the response becomes hard to read because format errors and consistency errors are mixed together.

Serializing Groups with @GroupSequence

The solution is jakarta.validation.GroupSequence. You put it on an interface and declare the order of the groups.

@GroupSequence({Format.class, Persistence.class})
public interface OrderedChecks {}

Change the Controller parameter to @RequestBody @Validated(OrderedChecks.class) UserRequest request. The basic syntax of interface-based sequences, and the caveat that you must include Default.class in the sequence to run constraints with no group specified, are summarized in the ordering section of the @Validated article, so let’s focus on the difference in behavior here.

Here is the result of sending the same input with format errors as before.

WARN  DefaultHandlerExceptionResolver : Validation failed ... with 2 errors

The existsById called line is gone, and only the two format errors remain. Once a violation occurs in Format, Persistence is not evaluated. In other words, the behavior of a sequence is that it does not proceed to the next stage until the previous one passes.

One additional note: the order is only guaranteed between groups. The order of constraints within the same group is not guaranteed. If you need “check A, then check B”, split them into separate groups. As a rule of thumb, the heavier a constraint is (DB or external API involved), the further back in the sequence it should go.

Nested objects are also straightforward to handle in this form. With an interface-based sequence, the group currently being validated propagates as-is to fields annotated with @Valid. If you want to validate the nested object with a different group, combine it with @ConvertGroup.

public record UserRequest(
        @NotBlank(groups = Format.class) String name,
        // 親が Persistence 段階に入ったとき、ネスト先は Default グループで検証される
        @Valid @ConvertGroup(from = Persistence.class, to = Default.class)
        AddressRequest address
) {}

During the Format stage, the Format group constraints of AddressRequest are evaluated. Once the parent’s format checks pass and it moves on to the Persistence stage, the constraints of AddressRequest with no group specified are evaluated.

Redefining the Default Group on the Class

Interface-based sequences are convenient, but writing @Validated(OrderedChecks.class) on every Controller parameter is tedious. There are also places where only @Valid can be used.

In such cases, put @GroupSequence on the DTO class itself.

@GroupSequence({UserRequest.class, Persistence.class})
public record UserRequest(
        @NotBlank String name,
        @Email String email,
        @NotNull
        @ExistingDepartment(groups = Persistence.class)
        Long departmentId
) {}

With this, the Default group of this class is replaced by the sequence itself. Constraints with no group specified, such as @NotBlank, are evaluated at the position of UserRequest.class, and then validation proceeds to Persistence. On the Controller side, simply writing @RequestBody @Valid UserRequest request gives you ordered validation.

There are two things to watch out for.

The first is that you must include the class itself (UserRequest.class) in the sequence. If you forget it, you get a GroupDefinitionException (HV000054, UserRequest must be part of the redefined default group sequence). You cannot write Default.class instead, either. Including Default itself in the sequence while you are redefining Default is not allowed, and that results in HV000053.

What makes this tricky is that this exception is not detected at startup. Hibernate Validator builds the bean metadata for a class the first time it validates that class, so the application starts normally and returns a 500 the first time that DTO is validated. Make sure to run the DTO through at least once in the MockMvc tests described later.

The second is deciding when to use which. A good rule to keep things organized: DTO-specific orders go on the class, and orders shared across multiple DTOs go on an interface.

Handling of nested objects differs from the interface-based sequence in the previous section. When you redefine Default on the class, fields annotated with @Valid receive the Default group rather than each stage of the parent’s sequence, and the nested object is validated with its own Default (or its own sequence, if it has been redefined). Furthermore, the nested object is validated even if the parent’s sequence stops at an earlier stage. If you want ordering to apply inside the nested object as well, put @GroupSequence on the nested class in the same way.

For the basics of automatic validation with @Valid, see the @Valid article.

Choosing Groups Based on State with @GroupSequenceProvider

Some cases cannot be expressed with a static @GroupSequence. For example, in an order request, you might want to add card number constraints when the payment method is a card, and bank account constraints when it is a bank transfer.

This is a Hibernate Validator-specific feature. The annotation is org.hibernate.validator.group.GroupSequenceProvider, and the interface to implement is org.hibernate.validator.spi.group.DefaultGroupSequenceProvider. It is not part of the Jakarta Bean Validation standard, but since spring-boot-starter-validation includes Hibernate Validator, no additional dependency is needed.

Implementing the Provider

import org.hibernate.validator.spi.group.DefaultGroupSequenceProvider;

public class OrderRequestSequenceProvider
        implements DefaultGroupSequenceProvider<OrderRequest> {

    @Override
    public List<Class<?>> getValidationGroups(OrderRequest order) {
        List<Class<?>> sequence = new ArrayList<>();
        sequence.add(OrderRequest.class); // 自クラスは必須

        // (1) メタデータ構築時は order 自体が null
        // (2) paymentMethod を省略した入力では order.paymentMethod() が null
        if (order == null || order.paymentMethod() == null) {
            return sequence;
        }
        switch (order.paymentMethod()) {
            case CARD -> sequence.add(CardChecks.class);
            case BANK_TRANSFER -> sequence.add(BankChecks.class);
        }
        sequence.add(Persistence.class);
        return sequence;
    }
}
import org.hibernate.validator.group.GroupSequenceProvider;

@GroupSequenceProvider(OrderRequestSequenceProvider.class)
public record OrderRequest(
        @NotNull PaymentMethod paymentMethod,
        @NotBlank(groups = CardChecks.class) String cardNumber,
        @NotBlank(groups = BankChecks.class) String bankAccount,
        @ExistingProduct(groups = Persistence.class) Long productId
) {}

Points to Keep in Mind

You need to think about null at two levels. The first is the order argument itself, which is passed as null when the metadata is built. The second is the field used for branching: if JSON arrives without paymentMethod, order.paymentMethod() is null. The provider is called with the actual object before any constraints are evaluated, so @NotNull does not protect you here. If you forget the latter guard, the switch throws a NullPointerException, and an input that should have returned 400 returns 500 instead.

The returned list must include the class itself. If it does not, you get a GroupDefinitionException (HV000054), just as with @GroupSequence on a class. It also cannot be combined with @GroupSequence (HV000052). If the provider is not being called, suspect either this combination or the use of a validation provider other than Hibernate Validator.

The provider is instantiated via its no-arg constructor, so Spring DI does not apply. Logic that needs a Repository should stay on the ConstraintValidator side, as before.

Note that in Spring Boot 4.x (Hibernate Validator 9.x), getValidationGroups(Class<?>, T) has been added and the single-argument version above is deprecated. Adjust the signature accordingly when migrating.

Returning Staged Error Responses from a REST API

Now that ordered validation is in place, let’s return errors to the client in stages as well. Here is a minimal implementation that formats MethodArgumentNotValidException in a @RestControllerAdvice.

@RestControllerAdvice
public class ValidationExceptionHandler {

    record ErrorItem(String field, String code, String message) {}

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, List<ErrorItem>>> handle(MethodArgumentNotValidException ex) {
        BindingResult result = ex.getBindingResult();
        List<ErrorItem> errors = new ArrayList<>();
        result.getFieldErrors().forEach(e ->
                errors.add(new ErrorItem(e.getField(), e.getCode(), e.getDefaultMessage())));
        // クラスレベル制約(項目間の整合性など)はフィールドに紐付かないのでこちらに入る
        result.getGlobalErrors().forEach(e ->
                errors.add(new ErrorItem(null, e.getCode(), e.getDefaultMessage())));
        return ResponseEntity.badRequest().body(Map.of("errors", errors));
    }
}

If you only format getFieldErrors(), violations of class-level constraints are silently dropped, so the key point is to also pick up getGlobalErrors().

Let’s send two requests to the same endpoint. The first one has format errors.

curl -s -X POST localhost:8080/users -H 'Content-Type: application/json' \
  -d '{"name":"","email":"bad","departmentId":999}'
# => {"errors":[{"field":"name","code":"NotBlank","message":"空白は許可されていません"},
#              {"field":"email","code":"Email","message":"電子メールアドレスとして正しくありません"}]}

The existence check for departmentId is not returned. Fix the format and send the second request.

curl -s -X POST localhost:8080/users -H 'Content-Type: application/json' \
  -d '{"name":"Taro","email":"[email protected]","departmentId":999}'
# => {"errors":[{"field":"departmentId","code":"ExistingDepartment","message":"存在しない部署です"}]}

Messages for the standard constraints switch based on Accept-Language (or the JVM’s default locale if unspecified), so in an English environment you get something like must not be blank.

The client can first fix the format, and then deal with consistency errors. The server side performs no unnecessary DB access.

If you want to vary the status code per stage (for example 400 for format and 422 for existence checks), you can unwrap(ConstraintViolation.class) the FieldError and inspect getConstraintDescriptor().getGroups() to determine which group the validation stopped at. Sequences are also effective in Service-layer method validation, but the exception there is ConstraintViolationException, so it needs a separate handler. Error response design in general is covered in the REST API exception handling article.

Testing with MockMvc That “Later-Stage Constraints Were Not Called”

Ordering control breaks silently the moment someone rewrites the @Validated argument. Write tests that go as far as verify(never()).

@WebMvcTest(UserController.class)
class UserControllerValidationTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean DepartmentRepository repository; // Boot 3.3以前は @MockBean

    @Test
    void 形式エラーがあれば存在チェックは呼ばれない() throws Exception {
        mockMvc.perform(post("/users").contentType(APPLICATION_JSON)
                        .content("""
                                {"name":"","email":"bad","departmentId":999}"""))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[*].field", containsInAnyOrder("name", "email")));

        verify(repository, never()).existsById(any());
    }

    @Test
    void 形式がOKなら存在チェックが1回呼ばれる() throws Exception {
        when(repository.existsById(999L)).thenReturn(false);

        mockMvc.perform(post("/users").contentType(APPLICATION_JSON)
                        .content("""
                                {"name":"Taro","email":"[email protected]","departmentId":999}"""))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("departmentId"));

        verify(repository).existsById(999L);
    }
}

Add one more test asserting that “a fully valid input returns 201”, and you are protected against regressions as well. The GroupDefinitionException (HV000053 / HV000054) caused by a mistake in a class-level @GroupSequence also surfaces at first validation, so a single test that runs the DTO through will catch it without relying on a startup check.

For the branching in @GroupSequenceProvider, it is enough to use @ParameterizedTest with @CsvSource to switch paymentMethod between CARD and BANK_TRANSFER, and confirm with jsonPath that errors for cardNumber and bankAccount appear or do not appear as expected. It is also reassuring to include a case where JSON without paymentMethod returns 400, as a regression guard for the null check.

For the basic setup of @WebMvcTest, see the article on testing Controllers with MockMvc, and for testing the exception handler side, see the @RestControllerAdvice testing article.

Summary

We have looked at three ways to control the execution order of groups.

For a fixed order shared across multiple DTOs, create an interface-based @GroupSequence and use it with @Validated(OrderedChecks.class). If you want a DTO-specific order to apply even with @Valid, put @GroupSequence on the class to redefine Default, and don’t forget to include the class itself. Just remember that Default is what propagates to nested objects. If you need to switch groups based on values, use @GroupSequenceProvider, but keep three things in mind: it depends on Hibernate Validator, null must be considered at two levels, and the class itself is required.

Whichever pattern you choose, push heavy constraints involving DBs or external APIs into later groups, and write MockMvc tests that go as far as verify(never()). Broken ordering and mistakes in sequence definitions are both hard to see, so protect them with tests.