Refactor Spring Boot Controllers for Cleaner Code and Unified Responses
This article explains the role of Controllers in Spring Boot, identifies common pitfalls such as duplicated validation and inconsistent responses, and demonstrates how to refactor them using a unified response structure, ResponseBodyAdvice, proper message converter ordering, JSR‑303 validation, custom validators, and centralized exception handling.
Understanding the Controller Layer
Controllers act as an indispensable yet supporting role in both traditional three‑layer and modern COLA architectures, handling request reception, delegating to services, catching business exceptions, and returning responses.
Current Problems with Controllers
Parameter validation is tightly coupled with business logic, violating the Single Responsibility Principle.
Repeatedly throwing the same exceptions across multiple services leads to code duplication.
Inconsistent error and success response formats make API integration unfriendly.
Refactoring the Controller Logic
The mall project (SpringBoot3 + JDK 17 + Vue) provides a real‑world example of these improvements.
Unified Return Structure
A common response type with a status code and message helps clients quickly determine success or failure, regardless of whether the front‑end and back‑end are separated.
<code>public interface IResult {
Integer getCode();
String getMessage();
}
public enum ResultEnum implements IResult {
SUCCESS(2001, "接口调用成功"),
VALIDATE_FAILED(2002, "参数校验失败"),
COMMON_FAILED(2003, "接口调用失败"),
FORBIDDEN(2004, "没有权限访问资源");
// fields, constructor, getters 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
}
</code>After defining this structure, each Controller can simply return
Result.success(...)or
Result.failed(...), eliminating repetitive wrapping code.
Unified Wrapper with ResponseBodyAdvice
Spring provides
ResponseBodyAdviceto intercept the response before the
HttpMessageConverterprocesses it.
<code>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);
}
</code>Implementation example:
<code>@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true; // apply to all responses
}
@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 new ObjectMapper().writeValueAsString(Result.success(body));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
return Result.success(body);
}
}
</code>Handling String Conversion Issues
When the response type is
String, Spring uses
StringHttpMessageConverter, which cannot convert a
Resultobject, causing a
ClassCastException. Two solutions are presented:
Detect
Stringin
beforeBodyWriteand manually serialize the
Resultto JSON.
Reorder the message converters so that
MappingJackson2HttpMessageConverterprecedes
StringHttpMessageConverter, allowing automatic conversion.
Parameter Validation with JSR‑303
Spring’s validation framework builds on the JSR‑303
validation-apiand Hibernate Validator. By annotating DTO fields with constraints such as
@NotBlank,
@Length,
@Email, and using
@Validatedon controllers, validation is performed automatically without mixing business logic.
<code>@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 {
@PostMapping("/test-validation")
public void testValidation(@RequestBody @Validated TestDTO testDTO) {
testService.save(testDTO);
}
}
</code>Custom Validation Rules
When built‑in constraints are insufficient, developers can create custom annotations and validators.
<code>@Target({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 {};
}
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 ctx) {
if (required && !StringUtils.hasText(value)) return false;
return !StringUtils.hasText(value) || pattern.matcher(value).matches();
}
}
</code>Custom Exceptions and Global Exception Handling
Define specific runtime exceptions for business errors and permission issues, then handle them centrally with
@RestControllerAdviceto produce the unified
Resultformat.
<code>public class BusinessException extends RuntimeException { public BusinessException(String msg) { super(msg); } }
public class ForbiddenException extends RuntimeException { public ForbiddenException(String msg) { super(msg); } }
@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) {
StringBuilder sb = new StringBuilder("校验失败:");
for (FieldError fe : ex.getBindingResult().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());
}
}
</code>Conclusion
By applying a unified response wrapper, leveraging
ResponseBodyAdvice, ordering message converters correctly, and using JSR‑303 validation with custom rules and centralized exception handling, Controller code becomes concise, maintainable, and consistent, allowing developers to focus on business logic.
macrozheng
Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.
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.