Validation Groups

Use groups and @Validated to apply different rules to the same DTO on create and update, and know when two DTOs are the clearer choice.

Syntaxinterface OnCreate extends Default { } @Null(groups = OnCreate.class) Long id @PostMapping View create(@Validated(OnCreate.class) @RequestBody Request request)

Sometimes one DTO serves two operations with slightly different rules. On create the id must be absent; on update it must be present. Validation groups let one constraint apply only when you ask for a particular group.

Declaring groups

A group is any interface, usually an empty marker. Constraints name the groups they belong to in their groups attribute:

interface OnCreate extends Default { }
interface OnUpdate extends Default { }

record ProductRequest(
        @Null(groups = OnCreate.class) @NotNull(groups = OnUpdate.class) Long id,
        @NotBlank @Size(max = 120) String name) { }

To choose a group, use Spring's @Validated instead of @Valid, which has no way to name groups:

@PostMapping
ProductView create(@Validated(OnCreate.class) @RequestBody ProductRequest request)

The Default group trap

A constraint with no groups belongs to jakarta.validation.groups.Default. When you validate with @Validated(OnCreate.class), only OnCreate constraints run. If OnCreate is a plain interface, @NotBlank on name is skipped and blank names reach the database. Making each group interface extend Default, as above, means "the usual rules plus these". Most bugs with groups are this one, and they pass every test that only sends valid data.

Ordering checks with @GroupSequence

All constraints in a group run, and all failures are reported. To stop early, for example to skip an expensive check when the basic format is already wrong, define a sequence. Validation runs the groups in order and stops at the first group with a violation.

When to use separate DTOs instead

Groups keep one class, but the rules for each operation are spread across annotations and you have to read every groups attribute to know what create accepts. Once the operations differ by more than one or two fields, separate records such as CreateProductRequest and UpdateProductRequest are easier to read, document and evolve, and an update DTO can leave out fields that must never change. Groups earn their place when the difference really is small, or when the same rules must run from code outside the web layer.

Example

Example · java
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Null;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import jakarta.validation.groups.Default;
import java.math.BigDecimal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

interface OnCreate extends Default {
}

interface OnUpdate extends Default {
}

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

        @NotBlank @Size(max = 120) String name,

        @NotNull @Positive BigDecimal price) {
}

record ProductView(long id, String name, BigDecimal price) {
}

@RestController
@RequestMapping("/api/products")
public class ProductController {

    @PostMapping
    public ProductView create(@Validated(OnCreate.class) @RequestBody ProductRequest request) {
        return new ProductView(101L, request.name(), request.price());
    }

    @PutMapping("/{id}")
    public ProductView update(@PathVariable long id,
                              @Validated(OnUpdate.class) @RequestBody ProductRequest request) {
        return new ProductView(id, request.name(), request.price());
    }
}

When to use it

  • Require an id on update but forbid one on create, using a single product DTO.
  • Skip a costly postcode lookup when the postcode is not even four digits.
  • Let an article be saved as a draft without a body but refuse to publish it until it has one.

More examples

Running cheap checks before expensive ones

Validating with Ordered runs the Default group first. Only if every Default constraint passes does Expensive run, so a postcode of "abc" gets one clear format error and never reaches the lookup. Without the sequence, both errors would be reported and the lookup would run on input already known to be wrong.

Example · java
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.GroupSequence;
import jakarta.validation.Payload;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.groups.Default;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

interface Expensive {
}

@GroupSequence({Default.class, Expensive.class})
interface Ordered {
}

@Constraint(validatedBy = KnownPostcodeValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@interface KnownPostcode {
    String message() default "is not a known postcode";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

class KnownPostcodeValidator implements ConstraintValidator<KnownPostcode, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // Imagine a lookup in a large in-memory table here.
        return value == null || !value.startsWith("0");
    }
}

record AddressRequest(
        @NotBlank String city,
        @NotBlank @Pattern(regexp = "\\d{4}") @KnownPostcode(groups = Expensive.class) String postcode) {
}

@RestController
public class AddressController {

    @PostMapping("/api/addresses")
    public AddressRequest create(@Validated(Ordered.class) @RequestBody AddressRequest request) {
        return request;
    }
}

Validating with a group from a service

Spring Boot exposes its configured validator as a jakarta.validation.Validator bean, so services can validate on demand. The same Article passes as a draft and fails on publish, which is a state-dependent rule groups handle well. The caller decides how ConstraintViolationException becomes an HTTP response.

Example · java
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Validator;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.validation.groups.Default;
import java.util.Set;
import org.springframework.stereotype.Service;

interface Publishing extends Default {
}

record Article(
        @NotBlank @Size(max = 200) String title,
        @NotNull(groups = Publishing.class) @Size(min = 50, groups = Publishing.class) String body) {
}

@Service
public class ArticleService {

    private final Validator validator;

    public ArticleService(Validator validator) {
        this.validator = validator;
    }

    public void saveDraft(Article article) {
        check(validator.validate(article));
    }

    public void publish(Article article) {
        // A draft may have no body; a published article must have a real one.
        check(validator.validate(article, Publishing.class));
    }

    private static void check(Set<ConstraintViolation<Article>> violations) {
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
    }
}

The alternative: one record per operation

No groups, no @Validated, and each record documents exactly what its endpoint accepts. The update record makes every field optional for a partial update and leaves out sku entirely, so a client cannot change it even by accident. This is often the better design; reach for groups when the two shapes are nearly identical.

Example · java
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

record CreateProductRequest(
        @NotBlank @Size(max = 120) String name,
        @NotNull @Positive BigDecimal price,
        @NotBlank @Size(max = 20) String sku) {
}

// sku is fixed after creation, so it is simply not here.
record UpdateProductRequest(
        @Size(min = 1, max = 120) String name,
        @Positive BigDecimal price) {
}

@RestController
public class CatalogueController {

    @PostMapping("/api/catalogue")
    public String create(@Valid @RequestBody CreateProductRequest request) {
        return request.sku();
    }

    @PatchMapping("/api/catalogue/{id}")
    public String update(@PathVariable long id, @Valid @RequestBody UpdateProductRequest request) {
        return "updated " + id;
    }
}

Discussion

  • Be the first to comment on this lesson.