Ch. 8 · Spring Boot

Spring Bean Validation Groups

Validate create and update requests differently with groups, avoid silently skipping Default constraints, and order checks with @GroupSequence.

~6 min readintermediateupdated Oct 19, 2026

Most APIs have a create endpoint and an update endpoint that accept nearly the same JSON. Nearly is the problem. On POST /customers the id must be absent, because the server assigns it. On PUT /customers/{id} the version field must be present for optimistic locking. Everything else (a valid email, a name under 50 characters) applies to both. Bean Validation groups let one class carry all of these rules and apply a different subset depending on the operation.

Interviewers bring groups up because they expose whether you know how validation is actually triggered in Spring, and because the most common way of using them contains a silent bug.

The Java here targets Spring Boot 3.x with spring-boot-starter-validation (Hibernate Validator 8, Jakarta Validation 3) on Java 17+. It’s illustrative and wasn’t compiled for this article. The group-selection rules are checked with a small Node model, shown below, that mirrors the specification’s rules rather than running Hibernate Validator itself.

Groups are just marker interfaces

A group is any interface. It has no methods; it only names a set of constraints:

public interface OnCreate {}
public interface OnUpdate {}

public class CustomerRequest {

    @Null(groups = OnCreate.class, message = "id is assigned by the server")
    @NotNull(groups = OnUpdate.class)
    private Long id;

    @NotNull(groups = OnUpdate.class)
    private Integer version;

    @NotBlank
    @Email
    private String email;

    @Size(max = 50)
    private String name;

    // getters and setters
}
java

Every constraint belongs to at least one group. A constraint with no groups attribute, like @NotBlank on email, belongs to jakarta.validation.groups.Default. When you validate, you name the groups to check, and only constraints in those groups run. If you name none, only Default runs.

Choosing groups in a controller

The standard @Valid annotation has no way to name groups, so it always validates Default. Spring’s own @Validated annotation takes group classes:

@RestController
@RequestMapping("/customers")
public class CustomerController {

    @PostMapping
    public ResponseEntity<CustomerView> create(
            @Validated(OnCreate.class) @RequestBody CustomerRequest request) { /* ... */ }

    @PutMapping("/{id}")
    public CustomerView update(@PathVariable long id,
            @Validated(OnUpdate.class) @RequestBody CustomerRequest request) { /* ... */ }
}
java

A failure throws MethodArgumentNotValidException, which Spring turns into a 400 (as problem details if you’ve enabled them). So far it looks right. It isn’t.

The bug: Default stops running

Send this to POST /customers: {"id": 7, "email": "", "name": "<60 characters>"}. You’d expect three errors. You get one, about id. The blank email and the overlong name sail through, because @NotBlank and @Size are in Default, and the controller asked only for OnCreate.

Here’s the rule worked through with a Node model of the specification’s group logic, using the same body. A constraint runs if it belongs to the requested group or to a group the requested group extends:

const extendsMap = { Default: [], OnCreate: [], OnUpdate: [], OnCreateWithDefault: ['Default'] };
const inGroup = (target, g) => target === g || (extendsMap[target] ?? []).some((p) => inGroup(p, g));
const constraints = [
  { field: 'id', rule: '@Null', groups: ['OnCreate', 'OnCreateWithDefault'], ok: (o) => o.id == null },
  { field: 'id', rule: '@NotNull', groups: ['OnUpdate'], ok: (o) => o.id != null },
  { field: 'email', rule: '@NotBlank', groups: ['Default'], ok: (o) => !!o.email?.trim() },
  { field: 'name', rule: '@Size(max = 50)', groups: ['Default'], ok: (o) => (o.name ?? '').length <= 50 },
];
function validate(obj, group) {
  return constraints.filter((c) => c.groups.some((g) => inGroup(group, g)) && !c.ok(obj)).map((c) => `${c.field} ${c.rule}`);
}
const body = { id: 7, email: '', name: 'x'.repeat(60) };
for (const g of ['Default', 'OnCreate', 'OnCreateWithDefault', 'OnUpdate']) console.log(g.padEnd(20), '->', JSON.stringify(validate(body, g)));
JavaScript
Default              -> ["email @NotBlank","name @Size(max = 50)"]
OnCreate             -> ["id @Null"]
OnCreateWithDefault  -> ["id @Null","email @NotBlank","name @Size(max = 50)"]
OnUpdate             -> []
Text

OnCreate alone reports only the id problem. OnUpdate reports nothing at all: the body has an id, and the blank email and overlong name are invisible to that group, so they’d be saved. (The model leaves out the version check for brevity.) The OnCreateWithDefault row shows the fix: make the group extend Default.

public interface OnCreate extends Default {}
public interface OnUpdate extends Default {}
java

Validating OnCreate now also validates everything in Default, because group inheritance pulls in the parent’s constraints. The alternative is to list both groups, @Validated({OnCreate.class, Default.class}), on every endpoint, which is easy to forget on the next one. Extending Default puts the rule in one place. This is the bug interviewers are fishing for when they ask “how do you validate create and update differently?”

Ordering checks with @GroupSequence

Normally all constraints in the requested groups run, in no defined order, and you get every violation at once. That’s what you want for form fields. Sometimes it isn’t: a check that hits the database (is this email already registered?) shouldn’t run on a value that isn’t even a valid email, and a cross-field check (endDate after startDate) shouldn’t run when either date is missing.

@GroupSequence defines an ordered list of groups. Validation runs them in order and stops at the first group that produces a violation:

public interface Basic {}
public interface Expensive {}

@GroupSequence({Basic.class, Expensive.class})
public interface OnSignup {}

public class SignupRequest {
    @NotBlank(groups = Basic.class)
    @Email(groups = Basic.class)
    @UniqueEmail(groups = Expensive.class)   // custom constraint whose validator queries the users table
    private String email;
}
java

@Validated(OnSignup.class) checks the cheap Basic constraints first. Only if they all pass does the @UniqueEmail validator run its query. A request with "email": "not-an-email" gets one clear error and costs no database round trip.

You can also put @GroupSequence on the bean class itself to redefine its Default group, for example @GroupSequence({SignupRequest.class, Expensive.class}), where the class name stands for its default constraints. Then even plain @Valid runs the cheap checks first. The trade-off of sequences is that clients see errors in rounds: fix the format, resubmit, then learn the email is taken.

Groups beyond controllers, and when not to use them

@Validated also works on service classes. Put it on the class, and Spring’s method validation checks annotated parameters (@Valid, @NotNull and so on) on every call through the proxy, throwing ConstraintViolationException. Put @Validated(OnUpdate.class) on a single method to pick groups there. The usual proxy caveat applies: calls from inside the same class aren’t validated.

You can validate programmatically too, which is handy in tests and batch jobs: inject a jakarta.validation.Validator and call validator.validate(request, OnCreate.class).

And JPA validates entities on persist and update through the same mechanism, using Default unless you configure spring.jpa.properties.jakarta.persistence.validation.group.pre-persist and its siblings. That’s one more reason to keep Default meaningful.

Groups are a tool, not a goal. When create and update requests differ in more than a couple of fields (different required fields, different allowed values, fields that only exist on one), separate CreateCustomerRequest and UpdateCustomerRequest records are easier to read, document in OpenAPI and test than one class covered in group annotations. A good interview answer mentions both and says when each fits.

Exercise: the update that saved an invalid email

A team uses one ProductRequest DTO for POST /products and PATCH /products/{id}. Fields have plain constraints like @NotBlank on name and @Positive on priceCents, plus @Null(groups = OnCreate.class) and @NotNull(groups = OnUpdate.class) on id. Controllers use @Validated(OnCreate.class) and @Validated(OnUpdate.class). Support finds products with blank names and negative prices in the database, all created through the update endpoint. Explain, and fix it.

Answer

The plain constraints belong to the Default group, and both endpoints validate only their own group, so @NotBlank and @Positive never run on either path. Creation happens to look fine only because the client’s form validates those fields; the API itself doesn’t.

The direct fix is interface OnCreate extends Default {} and interface OnUpdate extends Default {}, so each endpoint’s group includes the shared rules. Add a test per endpoint that posts a blank name and a negative price and expects a 400, since this bug is invisible until someone sends bad data.

Then look at the PATCH semantics, because there’s a second problem hiding. A partial update sends only the fields being changed, so @NotBlank on name would reject a patch that only changes the price. If the endpoint really is a partial update, it needs its own DTO where fields are optional but, when present, must be valid (for example @Pattern(regexp = ".*\\S.*") on a nullable name, which passes for null and fails for blanks), and the merged entity should be validated before saving. That’s the point where separate request classes beat groups.

More in Spring Boot

read ✓Spring Boot · mid

Spring Boot Problem Details for APIs

Return RFC 9457 problem details from Spring Boot 3: turn on the built-in handler, map domain exceptions, add fields, and keep internals out.

~6 min readread →
read ✓Spring Boot · mid

Spring Boot REST Pagination

Paginate Spring Boot endpoints with Pageable, add a unique tie-breaker to every sort, and switch to keyset scrolling when OFFSET gets slow.

~6 min readread →
read ✓Spring Boot · easy

Spring Configuration Properties and Validation

Bind Spring Boot settings to typed @ConfigurationProperties records, validate them at startup, and know when @Value is still the right tool.

~6 min readread →
esc