package com.example.orders.dto;
import com.example.orders.validation.ValidationGroups.Create;
import com.example.orders.validation.ValidationGroups.Update;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.util.List;
public class OrderRequest {
@Null(groups = Create.class, message = "id must be absent when creating")
@NotNull(groups = Update.class, message = "id is required when updating")
private Long id;
@Valid
@NotNull(message = "customer is required")
private CustomerDto customer;
@NotEmpty(message = "order must contain at least one line")
private List<@Valid OrderLineDto> lines;
public static class CustomerDto {
@NotBlank(message = "name is required")
private String name;
@Email(message = "email must be valid")
@NotBlank(message = "email is required")
private String email;
public String getName() { return name; }
public String getEmail() { return email; }
}
public static class OrderLineDto {
@NotBlank(message = "sku is required")
private String sku;
@Positive(message = "quantity must be greater than zero")
private int quantity;
@DecimalMin(value = "0.00", message = "unitPrice cannot be negative")
private BigDecimal unitPrice;
public String getSku() { return sku; }
public int getQuantity() { return quantity; }
public BigDecimal getUnitPrice() { return unitPrice; }
}
public Long getId() { return id; }
public CustomerDto getCustomer() { return customer; }
public List<OrderLineDto> getLines() { return lines; }
}
package com.example.orders.validation;
import jakarta.validation.groups.Default;
public final class ValidationGroups {
private ValidationGroups() {
}
// Extending Default keeps ungrouped constraints active alongside the group.
public interface Create extends Default {
}
public interface Update extends Default {
}
}
package com.example.orders.web;
import com.example.orders.dto.OrderRequest;
import com.example.orders.service.OrderService;
import com.example.orders.validation.ValidationGroups.Create;
import com.example.orders.validation.ValidationGroups.Update;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<Long> create(@RequestBody @Validated(Create.class) OrderRequest request) {
Long id = orderService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(id);
}
@PutMapping("/{id}")
public ResponseEntity<Void> update(@PathVariable Long id,
@RequestBody @Validated(Update.class) OrderRequest request) {
orderService.update(id, request);
return ResponseEntity.noContent().build();
}
}
package com.example.orders.web;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> onValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
fieldErrors.putIfAbsent(error.getField(), error.getDefaultMessage());
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", HttpStatus.UNPROCESSABLE_ENTITY.value());
body.put("message", "Validation failed");
body.put("errors", fieldErrors);
return ResponseEntity.unprocessableEntity().body(body);
}
}
This snippet shows how Spring Boot performs deep validation across nested request objects using cascading @Valid combined with Jakarta Bean Validation groups, so a single endpoint can enforce different rules for a create versus an update.
In OrderRequest DTO, the top-level object holds a CustomerDto and a list of OrderLineDto. The key detail is the @Valid annotation on those fields: without it, Bean Validation stops at the surface of OrderRequest and never descends into the nested customer or line items. Adding @Valid on customer and on the List<@Valid OrderLineDto> element tells the validator to cascade, walking the object graph and collecting violations from every level in one pass. Constraints are also assigned to groups such as Create and Update via the groups attribute, which lets the same field carry different requirements depending on the operation. For example, id is @Null on Create but @NotNull on Update.
The ValidationGroups marker file defines the empty marker interfaces used to select those groups. They extend Default where appropriate so that ungrouped constraints still run, avoiding the common pitfall where switching to a custom group silently skips every constraint that lacks an explicit group.
In OrderController, the create endpoint uses @Validated(Create.class) at the parameter to activate the create group, while the update path activates Update.class. Spring's @Validated is used here rather than plain @Valid precisely because only @Validated accepts a group argument; the nested @Valid annotations inside the DTO still drive the cascade. When validation fails, Spring throws MethodArgumentNotValidException before the handler body runs.
ApiExceptionHandler centralizes the response shape. It reads BindingResult from the exception, flattens each FieldError into a stable field-to-message map, and returns HTTP 422 so clients can bind messages to inputs. This keeps controllers thin and error contracts consistent. The trade-off of group-based validation is added ceremony and a subtle dependency on group inheritance; the benefit is one DTO and one validator serving multiple operations without duplicated request classes.
Related snips
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
package com.example.demo.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
Messaging with Apache Kafka
import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
Frontend: normalize and display server validation errors
Share this code
Here's the card — post it anywhere.