Refactoring Spring Controllers for Unified Responses and Robust Validation

This article explains why the Controller layer remains essential in modern Spring applications, identifies common problems such as tangled validation and inconsistent responses, and demonstrates how to introduce a unified result structure, use ResponseBodyAdvice for automatic wrapping, fix String conversion issues, apply JSR‑303 validation, create custom validators, and handle exceptions uniformly.

Java Tech Enthusiast
Java Tech Enthusiast
Java Tech Enthusiast
Refactoring Spring Controllers for Unified Responses and Robust Validation

Current Problems

The Controller layer is an indispensable supporting role; it provides data interfaces in both traditional three‑tier architecture and the newer COLA architecture. Its responsibilities usually include:

Receiving requests and parsing parameters

Calling Service to execute business logic (including parameter validation)

Catching business exceptions and providing feedback

Returning 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("The input 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;
    }
}

If we develop Controllers strictly according to the above responsibilities, several issues appear:

Parameter validation is tightly coupled with business code, violating the Single Responsibility Principle.

The same exception may be thrown in many services, causing code duplication.

Exception feedback and success response formats are inconsistent, making the API unfriendly.

Refactoring the Controller Layer

Unified Return Structure

A unified return type is essential whether the front‑end and back‑end are separated or not. Instead of judging success merely by a null value, a status code and message make the result clear.

// 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, setters, constructor omitted
}

// Unified result class
@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 <T> Result<T> 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 <T> Result<T> instance(Integer code, String message, T data) {
        Result<T> 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(...). However, writing the wrapping code in every Controller would be repetitive, so we move the logic to a global advice.

Unified Wrapper Handling

Spring provides ResponseBodyAdvice, which intercepts the response before the HttpMessageConverter performs type conversion.

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

supports : decide whether beforeBodyWrite should be executed.

beforeBodyWrite : process the response body.

@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // Add custom exclusion logic if needed
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                ServerHttpRequest request, ServerHttpResponse response) {
        // If already wrapped, return directly
        if (body instanceof Result) {
            return body;
        }
        // If the controller returns a String, convert Result to JSON string 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
}

With this advice, Controllers no longer need to write repetitive wrapping code.

Handling "cannot be cast to java.lang.String" Issue

When the return type is String, Spring selects StringHttpMessageConverter, which cannot convert the custom Result object, leading to a ClassCastException. Using MappingJackson2HttpMessageConverter solves the problem because it serializes the object to JSON.

In beforeBodyWrite, detect String and manually convert Result to JSON.

@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
    // ... supports method omitted
    @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);
    }
}

Another solution is to adjust the order of converters so that MappingJackson2HttpMessageConverter is placed before StringHttpMessageConverter in the HttpMessageConverter list.

@Configuration
public class WebConfiguration implements WebMvcConfigurer {
    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.add(0, new MappingJackson2HttpMessageConverter());
    }
}
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
    /**
     * Swap MappingJackson2HttpMessageConverter to the first position
     */
    @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 (Bean Validation) defines a standard validation API. The most popular implementation is Hibernate Validator, and Spring Validation provides a convenient wrapper for Spring MVC.

@PathVariable and @RequestParam Validation

For GET requests, use @PathVariable or @RequestParam. When the number of parameters exceeds five, prefer a DTO. Add validation annotations directly on the method parameters.

@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 prettyTestService) {
        this.testService = prettyTestService;
    }
}

Validation Principle

Spring MVC uses RequestResponseBodyMethodProcessor to resolve @RequestBody arguments and handle @ResponseBody return values. During argument resolution, it invokes validateIfApplicable, which checks for @Valid, @Validated, or any annotation whose name starts with "Valid". If validation fails, a MethodArgumentNotValidException is thrown.

public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
    @Override
    public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
                                NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
        parameter = parameter.nestedIfOptional();
        Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
        String name = Conventions.getVariableNameForParameter(parameter);
        if (binderFactory != null) {
            WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
            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 + name, binder.getBindingResult());
            }
        }
        return adaptArgumentIfNecessary(arg, parameter);
    }
    protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
        Annotation[] annotations = parameter.getParameterAnnotations();
        for (Annotation ann : annotations) {
            Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
            if (validationHints != null) {
                binder.validate(validationHints);
                break;
            }
        }
    }
}

@RequestBody Validation

For POST/PUT requests, place the request body in a DTO and annotate the DTO fields with validation constraints. Add @Validated on the controller method to trigger validation.

// 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(value = "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;
    }
}

Custom Validation Rules

When built‑in constraints are insufficient, define a custom annotation and a corresponding 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; // whether null is allowed
    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 = 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(CharSequence str) {
        Matcher m = pattern.matcher(str);
        return m.matches();
    }
}

Custom Exceptions and Unified Exception Handling

Define specific runtime exceptions for business errors and permission issues, then create a global @RestControllerAdvice to translate them into the unified Result format. All other uncaught exceptions are also wrapped so that the HTTP status code remains 200 and the client can rely on the business‑level code.

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

Conclusion

After applying the above refactorings, Controller code becomes concise, each parameter’s validation rules are clearly visible, return types are uniform, and exceptions are handled consistently. Developers can focus on business logic while the framework guarantees clean, maintainable, and robust APIs.

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.

springvalidationControllerexceptionhandlingresponsebodyadvice
Java Tech Enthusiast
Written by

Java Tech Enthusiast

Sharing computer programming language knowledge, focusing on Java fundamentals, data structures, related tools, Spring Cloud, IntelliJ IDEA... Book giveaways, red‑packet rewards and other perks await!

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.