Best Practices for Designing the Controller Layer in Spring Boot: Unified Response, Validation, and Exception Handling
This article explains how to build a clean and maintainable Spring Boot controller layer by separating responsibilities, unifying response structures, applying comprehensive parameter validation (including custom rules), and implementing centralized exception handling to keep business logic concise and robust.
An Excellent Controller Layer Logic
In Spring applications the Controller is the indispensable "supporting actor" that provides data interfaces to the outside world. Whether using the classic three‑tier architecture or the modern COLA architecture, the Controller remains essential because it receives requests, delegates to services, and returns responses without containing business logic itself.
Identify Problems from Current Situation
The typical duties of a Controller are:
Receive a request and parse its parameters.
Call a Service to execute business code (often with parameter validation).
Catch business exceptions and give feedback.
Return a successful response when the business logic succeeds.
Implementing these duties directly leads to three main issues:
Excessive parameter validation couples validation logic with business code, violating the Single Responsibility Principle.
Repeated exception throwing across multiple services creates duplicated code.
Inconsistent error and success response formats make API integration unfriendly.
Refactor Controller Layer Logic
Unified Return Structure
Having a consistent return type, regardless of whether the front‑end and back‑end are separated, helps callers quickly determine if an API call succeeded. A simple status code and message convey the result without relying on null checks.
public interface IResult {
Integer getCode();
String getMessage();
}
public enum ResultEnum implements IResult {
SUCCESS(2001, "Interface call succeeded"),
VALIDATE_FAILED(2002, "Parameter validation failed"),
COMMON_FAILED(2003, "Interface call failed"),
FORBIDDEN(2004, "No permission to access resource");
private Integer code;
private String message;
// getters, setters and constructor omitted
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result
{
private Integer code;
private String message;
private T data;
public static
Result
success(T data) {
return new Result<>(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(), data);
}
public static
Result
success(String message, T data) {
return new Result<>(ResultEnum.SUCCESS.getCode(), message, data);
}
public static Result
failed() {
return new Result<>(ResultEnum.COMMON_FAILED.getCode(), ResultEnum.COMMON_FAILED.getMessage(), null);
}
public static Result
failed(String message) {
return new Result<>(ResultEnum.COMMON_FAILED.getCode(), message, null);
}
public static Result
failed(IResult errorResult) {
return new Result<>(errorResult.getCode(), errorResult.getMessage(), null);
}
public static
Result
instance(Integer code, String message, T data) {
Result
result = new Result<>();
result.setCode(code);
result.setMessage(message);
result.setData(data);
return result;
}
}After defining the unified structure, each Controller can simply return Result.success(...) . To avoid repeating the wrapping logic in every Controller, we move the packaging into a global advice.
Unified Wrapper Processing
Spring provides ResponseBodyAdvice , which intercepts the response before the HttpMessageConverter writes it. By implementing this interface we can automatically wrap any non‑Result object.
public interface ResponseBodyAdvice
{
boolean supports(MethodParameter returnType, Class
> converterType);
@Nullable T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
Class
> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response);
} @RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice
{
@Override
public boolean supports(MethodParameter returnType, Class
> converterType) {
// Add logic to exclude certain controllers if needed
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class
> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof Result) {
return body;
}
return Result.success(body);
}
}With this advice, all controller methods automatically return a unified Result without modifying existing code.
Parameter Validation
JSR‑303 (Bean Validation) defines a standard validation API; Hibernate Validator is the most common implementation. Spring Validation is a convenient wrapper that automatically validates method parameters annotated with @Valid or @Validated .
1. @PathVariable and @RequestParam Validation
For GET requests, use @PathVariable or @RequestParam . When the number of parameters exceeds five, prefer using a DTO to keep the method signature clean.
@RestController
@RequestMapping("/pretty")
public class TestController {
private TestService testService;
@GetMapping("/{num}")
public Integer detail(@PathVariable("num") @Min(1) @Max(20) Integer num) {
return num * num;
}
@GetMapping("/getByEmail")
public TestDTO getByAccount(@RequestParam @NotBlank @Email String email) {
TestDTO testDTO = new TestDTO();
testDTO.setEmail(email);
return testDTO;
}
@Autowired
public void setTestService(TestService prettyTestService) {
this.testService = prettyTestService;
}
}If validation fails, Spring throws MethodArgumentNotValidException .
2. @RequestBody Validation
For POST/PUT requests, place parameters in the request body and annotate the DTO with validation constraints. Combine with @Validated on the controller method.
@Data
public class TestDTO {
@NotBlank
private String userName;
@NotBlank
@Length(min = 6, max = 20)
private String password;
@NotNull
@Email
private String email;
}
@RestController
@RequestMapping("/pretty")
public class TestController {
private TestService testService;
@PostMapping("/test-validation")
public void testValidation(@RequestBody @Validated TestDTO testDTO) {
this.testService.save(testDTO);
}
@Autowired
public void setTestService(TestService testService) {
this.testService = testService;
}
}Validation failures raise ConstraintViolationException .
3. Custom Validation Rules
When built‑in constraints are insufficient, define a custom annotation and its validator.
// Custom annotation
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = MobileValidator.class)
public @interface Mobile {
boolean required() default true;
String message() default "Invalid mobile number format";
Class
[] groups() default {};
Class
[] payload() default {};
}
// Validator implementation
public class MobileValidator implements ConstraintValidator
{
private boolean required;
private final Pattern pattern = Pattern.compile("^1[34578][0-9]{9}$");
@Override
public void initialize(Mobile constraintAnnotation) {
this.required = constraintAnnotation.required();
}
@Override
public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
if (required) {
return isMobile(value);
}
if (StringUtils.hasText(value)) {
return isMobile(value);
}
return true;
}
private boolean isMobile(CharSequence str) {
return pattern.matcher(str).matches();
}
}Custom validators allow complex business rules to stay out of the controller code, keeping it clean and adhering to the Single Responsibility Principle.
Custom Exceptions and Unified Exception Interception
Original code often throws generic Exception , making it hard to give precise feedback. Define specific runtime exceptions and handle them globally.
// Custom exceptions
public class ForbiddenException extends RuntimeException {
public ForbiddenException(String message) { super(message); }
}
public class BusinessException extends RuntimeException {
public BusinessException(String message) { super(message); }
}
// Global exception advice
@RestControllerAdvice(basePackages = "com.example.demo")
public class ExceptionAdvice {
@ExceptionHandler(BusinessException.class)
public Result
handleBusinessException(BusinessException ex) {
return Result.failed(ex.getMessage());
}
@ExceptionHandler(ForbiddenException.class)
public Result
handleForbiddenException(ForbiddenException ex) {
return Result.failed(ResultEnum.FORBIDDEN);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result
handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult();
StringBuilder sb = new StringBuilder("Validation failed: ");
for (FieldError fieldError : bindingResult.getFieldErrors()) {
sb.append(fieldError.getField()).append(": ")
.append(fieldError.getDefaultMessage()).append(", ");
}
String msg = sb.toString();
if (StringUtils.hasText(msg)) {
return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), msg);
}
return Result.failed(ResultEnum.VALIDATE_FAILED);
}
@ExceptionHandler(ConstraintViolationException.class)
public Result
handleConstraintViolationException(ConstraintViolationException ex) {
if (StringUtils.hasText(ex.getMessage())) {
return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), ex.getMessage());
}
return Result.failed(ResultEnum.VALIDATE_FAILED);
}
@ExceptionHandler(Exception.class)
public Result
handle(Exception ex) {
return Result.failed(ex.getMessage());
}
}With specific exceptions and a unified advice, error responses share the same Result format, while HTTP status codes can remain 200 and business logic can distinguish error types.
Conclusion
After applying the above refactorings, Controller code becomes concise: each parameter’s validation rules are clearly expressed in DTOs, each method’s return type is uniformly wrapped, and all exceptions are centrally handled. This allows developers to focus on core business logic rather than repetitive plumbing.
Architecture Digest
Focusing on Java backend development, covering application architecture from top-tier internet companies (high availability, high performance, high stability), big data, machine learning, Java architecture, and other popular fields.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.