Refactoring Spring Controllers: Unified Responses, Validation, and Global Exception Handling
This article explains how to redesign Spring MVC controllers by introducing a unified response wrapper, leveraging ResponseBodyAdvice for automatic packaging, fixing String conversion issues, applying JSR‑303 validation with custom rules, and implementing global exception handling to keep controller code clean and maintainable.
Hello, I'm your friend the architect, a developer who writes code and poetry.
Current Problems with Controllers
The Controller layer typically handles request reception, parameter parsing, delegating to services, catching business exceptions, and returning responses. Implementing these responsibilities directly leads to several issues:
Parameter validation is tightly coupled with business logic, violating the Single Responsibility Principle.
Identical exceptions are thrown across multiple services, causing code duplication.
Response formats are inconsistent, making API integration unfriendly.
// 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("The number must be greater than 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("Unrecognized algorithm");
}
}
// 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;
}
}Unified Response Structure
Define a generic result interface and an enum for common status codes, then implement a generic Result<T> class.
// Define return data structure
public interface IResult {
Integer getCode();
String getMessage();
}
// Common result enum
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, constructors omitted for brevity
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> 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);
}
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);
}
}After defining the wrapper, controllers can simply return data, and the wrapper will be applied automatically.
Automatic Packaging with ResponseBodyAdvice
Spring provides ResponseBodyAdvice to intercept the body before it is written by the HttpMessageConverter. Implementing it allows us to wrap every response in Result without touching individual controllers.
public interface ResponseBodyAdvice<T> {
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
@Nullable T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response);
}
@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// Always wrap unless already wrapped
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof Result) {
return body;
}
if (body instanceof String) {
try {
return this.objectMapper.writeValueAsString(Result.success(body));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
return Result.success(body);
}
}When the response type is String, the advice manually converts the Result object to JSON to avoid a ClassCastException.
Fixing StringHttpMessageConverter Order
The exception occurs because StringHttpMessageConverter is placed before MappingJackson2HttpMessageConverter in the converter list. Adjusting the order resolves the issue.
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(0, new MappingJackson2HttpMessageConverter());
}
}Alternatively, swap the positions dynamically:
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
for (int i = 0; i < converters.size(); i++) {
if (converters.get(i) instanceof MappingJackson2HttpMessageConverter) {
MappingJackson2HttpMessageConverter jackson = (MappingJackson2HttpMessageConverter) converters.get(i);
converters.set(i, converters.get(0));
converters.set(0, jackson);
break;
}
}
}
}Parameter Validation (JSR‑303)
Spring Validation builds on the JSR‑303 validation-api (commonly implemented by Hibernate Validator). Using annotations such as @NotBlank, @Min, @Max, and @Email on DTO fields or method parameters enables automatic validation.
@RestController(value = "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 testService) {
this.testService = testService;
}
}The core validation logic resides in RequestResponseBodyMethodProcessor, which parses @RequestBody arguments, invokes validateIfApplicable, and throws MethodArgumentNotValidException when constraints fail.
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, arg, parameter.getParameterName());
if (arg != null) {
validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + parameter.getParameterName(), binder.getBindingResult());
}
}
return adaptArgumentIfNecessary(arg, parameter);
}
}Custom Validation Annotation
When built‑in constraints are insufficient, you can create a custom annotation and validator.
// Custom annotation
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = MobileValidator.class)
public @interface Mobile {
boolean required() default true;
String message() default "Not a valid mobile number";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Validator implementation
public class MobileValidator implements ConstraintValidator<Mobile, CharSequence> {
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 && !StringUtils.hasText(value)) {
return true;
}
return pattern.matcher(value).matches();
}
}Custom Exceptions and Global Exception Handling
Define business‑specific exceptions and handle them centrally with @RestControllerAdvice so that every error is wrapped in the unified Result format.
public class ForbiddenException extends RuntimeException {
public ForbiddenException(String message) { super(message); }
}
public class BusinessException extends RuntimeException {
public BusinessException(String message) { super(message); }
}
@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());
}
}Conclusion
After applying the unified response wrapper, proper ordering of message converters, JSR‑303 validation, custom validators, and global exception handling, controller code becomes concise, each method’s contract is explicit, and error feedback is consistent, allowing developers to focus on business logic.
Java Architect Essentials
Committed to sharing quality articles and tutorials to help Java programmers progress from junior to mid-level to senior architect. We curate high-quality learning resources, interview questions, videos, and projects from across the internet to help you systematically improve your Java architecture skills. Follow and reply '1024' to get Java programming resources. Learn together, grow together.
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.
