Bean Validation in OfficeFloor REST YAML Composition
This tutorial demonstrates that Bean Validation integrates with OfficeFloor REST YAML composition without any extra plumbing. The same @Valid, @Validated, and BindingResult patterns that work in a @RestController work unchanged in OfficeFloor service methods.
Two endpoints are covered:
POST /order— annotated with@Validatedand@Valid; invalid requests are rejected automatically with400 Bad Requestbefore the service method body executes.POST /order/binding— usesBindingResultto receive validation errors inside the service method and build a structured error response.
Maven dependency
Add spring-boot-starter-validation alongside the OfficeFloor starter. No additional OfficeFloor-specific validation modules are needed:
<dependency>
<groupId>net.officefloor.springboot</groupId>
<artifactId>officefloor-rest-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Application class
@SpringBootApplication
public class SpringRestValidationApplication {
public static void main(String[] args) {
SpringApplication.run(SpringRestValidationApplication.class, args);
}
}
Request model
Constraints are declared directly on the request class fields using standard Jakarta Validation annotations:
public class OrderRequest {
@NotBlank(message = "Product name is required")
private String product;
@Min(value = 1, message = "Quantity must be at least 1")
private int quantity;
public String getProduct() { return product; }
public void setProduct(String product) { this.product = product; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
}
Automatic rejection — @Validated + @Valid
Placing @Validated on the service class and @Valid on the @RequestBody parameter is all that is required. When a request fails validation, OfficeFloor returns 400 Bad Request before the method body is entered — the service method is never called for an invalid request:
service:
class: net.officefloor.tutorial.springrestvalidation.PlaceOrderService
@Validated
public class PlaceOrderService {
public void service(@Valid @RequestBody OrderRequest request,
ObjectResponse<OrderResponse> response) {
response.send(new OrderResponse("Ordered " + request.getQuantity()
+ " x " + request.getProduct()));
}
}
This is identical to how @Validated works on a Spring @RestController — OfficeFloor delegates to Spring's validation infrastructure without any additional configuration.
Manual error handling — BindingResult
When the service method needs to inspect validation errors itself — for example to return a structured error body — declare a BindingResult parameter immediately after the @Valid-annotated parameter. OfficeFloor passes validation errors through BindingResult and the method body always executes, even when there are errors:
service:
class: net.officefloor.tutorial.springrestvalidation.PlaceOrderBindingService
@Validated
public class PlaceOrderBindingService {
public void service(@Valid @RequestBody OrderRequest request, BindingResult result,
ObjectResponse<ResponseEntity<OrderResponse>> response) {
if (result.hasErrors()) {
String errors = result.getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.reduce((a, b) -> a + "; " + b)
.orElse("Validation failed");
response.send(ResponseEntity.badRequest().body(new OrderResponse(errors)));
return;
}
response.send(ResponseEntity.ok(new OrderResponse(
"Ordered " + request.getQuantity() + " x " + request.getProduct())));
}
}
The BindingResult parameter must immediately follow the @Valid-annotated parameter, exactly as required in Spring MVC.
Structured error response — @RestControllerAdvice for MethodArgumentNotValidException
When the service method does not declare a BindingResult parameter, Spring throws MethodArgumentNotValidException on validation failure. A @RestControllerAdvice catches this and returns a structured body — typically a map of field names to error messages — that clients can display field-by-field:
public class ValidationErrorResponse {
private final Map<String, List<String>> errors;
public ValidationErrorResponse(Map<String, List<String>> errors) {
this.errors = errors;
}
public Map<String, List<String>> getErrors() {
return errors;
}
}
@RestControllerAdvice
public class ValidationControllerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ValidationErrorResponse handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, List<String>> errors = new LinkedHashMap<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
errors.computeIfAbsent(fieldError.getField(), k -> new ArrayList<>())
.add(fieldError.getDefaultMessage());
}
return new ValidationErrorResponse(errors);
}
}
The advice works identically for OfficeFloor service methods and Spring @RestController methods — no additional configuration is needed. The test verifies both the 400 status and the per-field error structure:
// response body for { "product": "", "quantity": 0 }
{
"errors": {
"product": ["Product name is required"],
"quantity": ["Quantity must be at least 1"]
}
}Custom constraint validators
Custom @Constraint annotations backed by a ConstraintValidator implementation work without any change. They are invoked by the Jakarta Bean Validation machinery during @Valid processing — OfficeFloor does not intercept or replace that machinery.
The example below validates that an order quantity is even (items sold in pairs). The validator uses disableDefaultConstraintViolation() and buildConstraintViolationWithTemplate() to produce a specific message, exactly as in a plain Spring MVC application:
@Documented
@Constraint(validatedBy = EvenQuantityValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EvenQuantity {
String message() default "Quantity must be even — items are sold in pairs";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class EvenQuantityValidator implements ConstraintValidator<EvenQuantity, Integer> {
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
if (value == null) {
return true; // let @NotNull handle nulls
}
if (value % 2 == 0) {
return true;
}
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"Quantity " + value + " is odd — items are sold in pairs")
.addConstraintViolation();
return false;
}
}
The annotation is applied to the request DTO alongside standard Jakarta constraints:
public class BulkOrderRequest {
@NotBlank(message = "Product name is required")
private String product;
@Min(value = 2, message = "Bulk quantity must be at least 2")
@EvenQuantity
private int quantity;
public String getProduct() { return product; }
public void setProduct(String product) { this.product = product; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
}
The service method and YAML are identical to the standard @Valid pattern — no special configuration is needed for custom constraints:
service:
class: net.officefloor.tutorial.springrestvalidation.PlaceBulkOrderService
@Validated
public class PlaceBulkOrderService {
public void service(@Valid @RequestBody BulkOrderRequest request,
ObjectResponse<OrderResponse> response) {
response.send(new OrderResponse(
"Bulk order: " + request.getQuantity() + " x " + request.getProduct()));
}
}
Testing
Tests use Spring Boot's MockMvc — no OfficeFloor-specific test utilities are required:
@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestValidationTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper mapper;
@Test
public void valid_request_is_accepted() throws Exception {
OrderRequest request = new OrderRequest();
request.setProduct("Widget");
request.setQuantity(3);
mvc.perform(post("/order")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Ordered 3 x Widget"));
}
@Test
public void invalid_request_is_rejected_with_400() throws Exception {
OrderRequest request = new OrderRequest();
request.setProduct(""); // blank — violates @NotBlank
request.setQuantity(0); // zero — violates @Min(1)
mvc.perform(post("/order")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
public void binding_result_valid_request_is_accepted() throws Exception {
OrderRequest request = new OrderRequest();
request.setProduct("Gadget");
request.setQuantity(5);
mvc.perform(post("/order/binding")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Ordered 5 x Gadget"));
}
@Test
public void binding_result_invalid_request_returns_error_details() throws Exception {
OrderRequest request = new OrderRequest();
request.setProduct(""); // blank — violates @NotBlank
request.setQuantity(0); // zero — violates @Min(1)
mvc.perform(post("/order/binding")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").isNotEmpty());
}
@Test
public void validation_errors_return_structured_field_error_map() throws Exception {
OrderRequest request = new OrderRequest();
request.setProduct(""); // blank — violates @NotBlank
request.setQuantity(0); // zero — violates @Min(1)
mvc.perform(post("/order")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.product[0]").value("Product name is required"))
.andExpect(jsonPath("$.errors.quantity[0]").value("Quantity must be at least 1"));
}
@Test
public void custom_constraint_even_quantity_accepted() throws Exception {
BulkOrderRequest request = new BulkOrderRequest();
request.setProduct("Gloves");
request.setQuantity(4);
mvc.perform(post("/order/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Bulk order: 4 x Gloves"));
}
@Test
public void custom_constraint_odd_quantity_rejected() throws Exception {
BulkOrderRequest request = new BulkOrderRequest();
request.setProduct("Gloves");
request.setQuantity(3); // odd — violates @EvenQuantity
mvc.perform(post("/order/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
Next
The Spring REST Security tutorial demonstrates Spring Security integration with OfficeFloor REST YAML composition.

