Improving Spring MVC Controllers: Unified Response Structure, Validation, and Exception Handling
This article explains how to refactor Spring MVC controller layers by introducing a unified response wrapper, handling String conversion issues with ResponseBodyAdvice, applying JSR‑303 validation for request parameters, creating custom validation annotations, and implementing custom exceptions with a global exception handler to produce clean, maintainable backend code.
Current Situation
In a typical three‑layer or COLA architecture, the Controller layer is indispensable as it receives requests, delegates business logic to the Service layer, catches exceptions, and returns responses, but it should not contain business logic itself.
Controller Responsibilities
Receive requests and parse parameters.
Invoke Service methods (including parameter validation).
Capture business exceptions and provide feedback.
Return successful responses.
//DTO
@Data
public class TestDTO {
private Integer num;
private String type;
}
//Service
@Service
public class TestService {
public Double service(TestDTO testDTO) throws Exception {
if (testDTO.getNum() <= 0) {
throw new Exception("输入的数字需要大于0");
}
if (testDTO.getType().equals("square")) {
return Math.pow(testDTO.getNum(), 2);
}
if (testDTO.getType().equals("factorial")) {
double result = 1;
int num = testDTO.getNum();
while (num > 1) {
result = result * num;
num -= 1;
}
return result;
}
throw new Exception("未识别的算法");
}
}
//Controller
@RestController
public class TestController {
private TestService testService;
@PostMapping("/test")
public Double test(@RequestBody TestDTO testDTO) {
try {
Double result = this.testService.service(testDTO);
return result;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Autowired
public void setTestService(TestService testService) {
this.testService = testService;
}
}Developing Controllers strictly according to the above responsibilities leads to several problems:
Parameter validation is tightly coupled with business code, violating the Single Responsibility Principle.
Repeated throwing of the same exception across multiple services causes code duplication.
Inconsistent error and success response formats make API integration unfriendly.
Refactoring the Controller Layer
Unified Return Structure
A consistent return type is essential for both monolithic and micro‑service projects. Using a status code and message makes it clear whether an API call succeeded.
// Define return data structure
public interface IResult {
Integer getCode();
String getMessage();
}
// Common result enum
public enum ResultEnum implements IResult {
SUCCESS(2001, "接口调用成功"),
VALIDATE_FAILED(2002, "参数校验失败"),
COMMON_FAILED(2003, "接口调用失败"),
FORBIDDEN(2004, "没有权限访问资源");
private Integer code;
private String message;
// getters, setters, constructor omitted
}
// Unified result wrapper
@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
failed() {
return new Result<>(ResultEnum.COMMON_FAILED.getCode(), ResultEnum.COMMON_FAILED.getMessage(), null);
}
// other factory methods omitted
}After defining the unified structure, each Controller can simply return Result.success(...) . However, writing this wrapper in every method is still repetitive.
Unified Wrapper Processing
Spring provides ResponseBodyAdvice , which intercepts the response before the HttpMessageConverter processes it. By implementing this interface we can automatically wrap all controller responses.
// ResponseAdvice implementation
@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice
{
@Override
public boolean supports(MethodParameter returnType, Class
> converterType) {
// Add custom logic to exclude certain endpoints if needed
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class
> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
// If already wrapped, return as is
if (body instanceof Result) {
return body;
}
// If the controller returns a String, convert the Result to JSON manually
if (body instanceof String) {
try {
return this.objectMapper.writeValueAsString(Result.success(body));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
return Result.success(body);
}
// ObjectMapper injection omitted for brevity
}Using this advice, all controller responses are automatically wrapped without modifying existing code.
Handling "cannot be cast to java.lang.String" Issue
When the response type is String , Spring selects StringHttpMessageConverter , which cannot convert the Result object, leading to a ClassCastException . Two solutions are proposed:
In beforeBodyWrite , detect String responses and manually serialize the Result to JSON, then set the controller's produces attribute to application/json;charset=UTF-8 .
Reorder the message converters so that MappingJackson2HttpMessageConverter is placed before StringHttpMessageConverter , ensuring JSON conversion takes precedence.
// Adjust converter order
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List
> converters) {
converters.add(0, new MappingJackson2HttpMessageConverter());
}
}Parameter Validation
JSR‑303 (Bean Validation) defines a standard validation API. Spring Validation is a convenient wrapper that can be applied to @PathVariable , @RequestParam , and @RequestBody parameters, decoupling validation logic from business code.
@PathVariable and @RequestParam Validation
@RestController("prettyTestController")
@RequestMapping("/pretty")
@Validated
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 (for @RequestBody ) or ConstraintViolationException (for @PathVariable / @RequestParam ).
Validation Mechanism
The core class RequestResponseBodyMethodProcessor parses @RequestBody arguments and processes @ResponseBody return values. It invokes validateIfApplicable , which checks for @Valid , @Validated , or any annotation whose name starts with "Valid". Validation is delegated to Hibernate Validator.
@RequestBody Validation
// DTO with validation annotations
@Data
public class TestDTO {
@NotBlank
private String userName;
@NotBlank
@Length(min = 6, max = 20)
private String password;
@NotNull
@Email
private String email;
}
// Controller
@RestController("prettyTestController")
@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;
}
}When validation fails, a MethodArgumentNotValidException is thrown.
Custom Validation Rules
For business‑specific checks, you can create 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 "不是一个手机号码格式";
Class
[] groups() default {};
Class
[] payload() default {};
}
// Validator implementation
public class MobileValidator implements ConstraintValidator
{
private boolean required = false;
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 (this.required) {
return isMobile(value);
}
if (StringUtils.hasText(value)) {
return isMobile(value);
}
return true;
}
private boolean isMobile(final CharSequence str) {
return pattern.matcher(str).matches();
}
}Custom Exceptions and Global Exception Handling
Original code threw generic Exception , making it hard to differentiate error types. By defining specific runtime exceptions and a global @RestControllerAdvice , we can map each exception to the unified Result format.
// 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 handler
@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("校验失败:");
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());
}
}Conclusion
After applying the above changes, controller code becomes concise, validation rules are explicit, and response formats are consistent. This allows developers to focus on business logic while maintaining clean, robust backend services.
Join our backend‑focused technical community to share knowledge, job opportunities, and internal referrals.
Code Ape Tech Column
Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn
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.