Refactoring Spring MVC Controllers for Unified Responses and Robust Validation

The article analyzes the role of Spring MVC Controllers, identifies issues with traditional implementations, and demonstrates how to create a unified response structure, automatically wrap results using ResponseBodyAdvice, resolve String conversion problems, and apply JSR‑303 validation with custom rules and global exception handling.

Architect
Architect
Architect
Refactoring Spring MVC Controllers for Unified Responses and Robust Validation

Controller responsibilities

Receive request and parse parameters

Delegate to Service for business logic (often with validation)

Catch business exceptions and provide feedback

Return successful response data

Problems of a naïve implementation

Parameter validation is tightly coupled with business code, violating SRP.

Repeatedly throwing the same exception across many controllers leads to duplicated code.

Inconsistent error and success response formats make API integration unfriendly.

Unified response structure

Define a common result interface and an enum for status codes, then wrap any payload in Result<T>:

public interface IResult {
    Integer getCode();
    String getMessage();
}

public enum ResultEnum implements IResult {
    SUCCESS(2001, "接口调用成功"),
    VALIDATE_FAILED(2002, "参数校验失败"),
    COMMON_FAILED(2003, "接口调用失败"),
    FORBIDDEN(2004, "没有权限访问资源");
    private final Integer code;
    private final String message;
    // getters and constructor omitted
}

@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);
    }
    // other factory methods omitted
}

Even with this wrapper, writing the same packaging code in every controller is repetitive.

Automatic wrapping with ResponseBodyAdvice

ResponseBodyAdvice

intercepts the response before the HttpMessageConverter writes it, allowing a central place to wrap the payload.

public interface ResponseBodyAdvice<T> {
    boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
    T beforeBodyWrite(T body, MethodParameter returnType, MediaType selectedContentType,
                     Class<? extends HttpMessageConverter<?>> selectedConverterType,
                     ServerHttpRequest request, ServerHttpResponse response);
}

Implementation example:

@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return true; // could add annotation check to exclude some controllers
    }
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                 Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                 ServerHttpRequest request, ServerHttpResponse response) {
        if (body instanceof Result) {
            return body;
        }
        return Result.success(body);
    }
}

When a controller returns a String, the above logic throws ClassCastException because StringHttpMessageConverter tries to write the Result object as a plain string.

Resolving the String conversion issue

Detect String in beforeBodyWrite and manually convert Result to JSON using ObjectMapper:

@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 objectMapper.writeValueAsString(Result.success(body));
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
    return Result.success(body);
}

Reorder the converters so that MappingJackson2HttpMessageConverter is placed before StringHttpMessageConverter, eliminating the need for special handling:

@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) {
                HttpMessageConverter<?> jackson = converters.get(i);
                converters.set(i, converters.get(0));
                converters.set(0, jackson);
                break;
            }
        }
    }
}

Parameter validation with JSR‑303

Use Hibernate Validator implementation of validation-api. Annotate DTO fields with constraints such as @NotBlank, @Email, @Min. Spring MVC binds these via @Validated and throws MethodArgumentNotValidException or ConstraintViolationException on failure.

// DTO
@Data
public class TestDTO {
    @NotBlank
    private String userName;
    @NotBlank @Length(min = 6, max = 20)
    private String password;
    @NotNull @Email
    private String email;
}

// Controller
@RestController
@RequestMapping("/pretty")
@Validated
public class TestController {
    @PostMapping("/test-validation")
    public void testValidation(@RequestBody @Validated TestDTO testDTO) {
        testService.save(testDTO);
    }
}

The core validation flow lives in RequestResponseBodyMethodProcessor. It reads the request body into the DTO, creates a WebDataBinder, invokes validateIfApplicable (delegating to Hibernate Validator), and throws MethodArgumentNotValidException if any constraint fails.

Custom validation annotation

When built‑in constraints are insufficient, define a custom annotation and validator.

// Custom annotation
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = MobileValidator.class)
public @interface Mobile {
    String message() default "不是一个手机号码格式";
    boolean required() default true;
    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 annotation) {
        this.required = annotation.required();
    }
    @Override
    public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
        if (required && !StringUtils.hasText(value)) {
            return false;
        }
        return !StringUtils.hasText(value) || pattern.matcher(value).matches();
    }
}

Custom exceptions and global exception handling

Define fine‑grained runtime exceptions for business scenarios and handle them uniformly with @RestControllerAdvice:

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 br = ex.getBindingResult();
        StringBuilder sb = new StringBuilder("校验失败:");
        for (FieldError fe : br.getFieldErrors()) {
            sb.append(fe.getField()).append(":").append(fe.getDefaultMessage()).append(", ");
        }
        return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), sb.toString());
    }
    @ExceptionHandler(ConstraintViolationException.class)
    public Result<?> handleConstraintViolationException(ConstraintViolationException ex) {
        return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), ex.getMessage());
    }
    @ExceptionHandler(Exception.class)
    public Result<?> handle(Exception ex) {
        return Result.failed(ex.getMessage());
    }
}

Handling String conversion in ResponseBodyAdvice

Two approaches:

Detect String and convert to JSON manually (see code above).

Adjust converter order so that MappingJackson2HttpMessageConverter precedes StringHttpMessageConverter. Example configuration:

@Configuration
public class WebConfiguration implements WebMvcConfigurer {
    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.add(0, new MappingJackson2HttpMessageConverter());
    }
}

A more precise swap implementation:

@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;
            }
        }
    }
}

Validation of @PathVariable and @RequestParam

Apply constraint annotations directly on method parameters:

@RestController
@RequestMapping("/pretty")
@Validated
public class TestController {
    @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 dto = new TestDTO();
        dto.setEmail(email);
        return dto;
    }
}

Validation failures for these parameters raise ConstraintViolationException, which is handled by the global ExceptionAdvice above.

Underlying validation mechanism

RequestResponseBodyMethodProcessor

resolves @RequestBody arguments, creates a WebDataBinder, and calls validateIfApplicable. The latter scans for @Valid, @Validated or annotations whose name starts with "Valid" and delegates to Hibernate Validator.

protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
    Annotation[] annotations = parameter.getParameterAnnotations();
    for (Annotation ann : annotations) {
        Object[] hints = ValidationAnnotationUtils.determineValidationHints(ann);
        if (hints != null) {
            binder.validate(hints);
            break;
        }
    }
}

Method‑level validation is provided by MethodValidationPostProcessor, which registers an AOP advisor for beans annotated with @Validated. The advisor uses MethodValidationInterceptor to invoke Hibernate Validator on method parameters and return values.

public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor implements InitializingBean {
    private Class<? extends Annotation> validatedAnnotationType = Validated.class;
    @Override
    public void afterPropertiesSet() {
        Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
        this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
    }
    protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
        return new MethodValidationInterceptor(validator != null ? validator : new LocalValidatorFactoryBean());
    }
}

public class MethodValidationInterceptor implements MethodInterceptor {
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        // parameter validation
        Set<ConstraintViolation<Object>> violations = validator.forExecutables()
            .validateParameters(invocation.getThis(), invocation.getMethod(), invocation.getArguments());
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
        Object returnValue = invocation.proceed();
        // return value validation
        violations = validator.forExecutables()
            .validateReturnValue(invocation.getThis(), invocation.getMethod(), returnValue);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
        return returnValue;
    }
}

Summary

By defining a unified Result wrapper, centralizing response packaging with ResponseBodyAdvice, adjusting message‑converter order to handle String responses, leveraging JSR‑303 for declarative parameter validation, creating custom validation annotations when needed, and handling all exceptions through a global @RestControllerAdvice, controller code becomes concise, validation rules are explicit, and success and error responses share a consistent format. This separation of concerns improves maintainability and aligns with the Single Responsibility Principle.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

BackendJavaException HandlingspringvalidationControllerresponsebodyadvice
Architect
Written by

Architect

Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.