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 @Validated and @Valid; invalid requests are rejected automatically with 400 Bad Request before the service method body executes.
  • POST /order/binding — uses BindingResult to receive validation errors inside the service method and build a structured error response.

Tutorial Source

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-4-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()));
	}
}

Validation in a multi-function pipeline

@Valid runs before the body of the function that declares it. In a single function this is automatic. In a multi-function pipeline the position of the validating function decides when validation runs.

Put the validating function first. If a load function ran first, an invalid body against a missing id would return 404 before the body was checked. With validation first, the invalid body returns 400.

The REST CRUD Orchestration tutorial shows this ordering for create and update.

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.