Backend Development 17 min read

Refactoring the Spring Boot Controller Layer for Clean Architecture and Unified Responses

This article explains why the Controller layer should stay thin, identifies common pitfalls such as tangled validation and inconsistent responses, and demonstrates how to introduce a unified result wrapper, global response advice, robust parameter validation, custom validators, and centralized exception handling to produce concise, maintainable backend code.

Top Architect
Top Architect
Top Architect
Refactoring the Spring Boot Controller Layer for Clean Architecture and Unified Responses

The article starts by stating that a Controller in a Spring‑Boot application is an indispensable but thin "assistant" that should only receive HTTP requests, delegate to a Service, handle exceptions, and return a response.

Typical responsibilities are listed:

Receive request and parse parameters

Call Service to execute business logic (including possible validation)

Catch business exceptions and provide feedback

Return a successful response

When these responsibilities are implemented directly in the Controller, several problems appear: validation logic is tightly coupled with business code, duplicate exception handling, and inconsistent response formats.

To solve this, the article introduces a unified response structure:

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

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

@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(IResult error) {
        return new Result<>(error.getCode(), error.getMessage(), null);
    }
    // other factory methods omitted
}

With this wrapper, Controllers can simply return the business data and let the framework handle the envelope.

The article then shows how to automate the wrapping using ResponseBodyAdvice :

@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice
{
    @Override
    public boolean supports(MethodParameter returnType, Class
> converterType) {
        return true; // apply to all responses
    }
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                Class
> selectedConverterType,
                                ServerHttpRequest request, ServerHttpResponse response) {
        if (body instanceof Result) {
            return body;
        }
        return Result.success(body);
    }
}

Next, the article covers parameter validation using JSR‑303 (Hibernate Validator) and Spring’s @Validated support. Examples for @PathVariable and @RequestParam :

@RestController
@RequestMapping("/pretty")
public class TestController {
    @GetMapping("/{num}")
    public Integer detail(@PathVariable("num") @Min(1) @Max(20) Integer num) {
        return num * num;
    }
    @GetMapping("/getByEmail")
    public TestDTO getByEmail(@RequestParam @NotBlank @Email String email) {
        TestDTO dto = new TestDTO();
        dto.setEmail(email);
        return dto;
    }
}

For request bodies, the article demonstrates using @Validated on a DTO:

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

When built‑in constraints are insufficient, a custom validation annotation is shown:

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = MobileValidator.class)
public @interface Mobile {
    boolean required() default true;
    String message() default "不是一个手机号码格式";
    Class
[] groups() default {};
    Class
[] payload() default {};
}

public class MobileValidator implements ConstraintValidator
{
    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 pattern.matcher(value).matches();
        }
        return !StringUtils.hasText(value) || pattern.matcher(value).matches();
    }
}

Finally, the article introduces custom exceptions and a global exception handler to keep error responses consistent with the unified result format:

public class ForbiddenException extends RuntimeException { public ForbiddenException(String msg){ super(msg); } }
public class BusinessException extends RuntimeException { public BusinessException(String msg){ super(msg); } }

@RestControllerAdvice(basePackages = "com.example.demo")
public class ExceptionAdvice {
    @ExceptionHandler(BusinessException.class)
    public Result
handleBusiness(BusinessException ex) { return Result.failed(ex.getMessage()); }
    @ExceptionHandler(ForbiddenException.class)
    public Result
handleForbidden(ForbiddenException ex) { return Result.failed(ResultEnum.FORBIDDEN); }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result
handleValidation(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(Exception.class)
    public Result
handleOther(Exception ex) { return Result.failed(ex.getMessage()); }
}

After applying these refactorings, the Controller code becomes concise, each endpoint clearly shows its input validation rules and return type, and all success and error responses share a uniform structure, greatly improving maintainability and developer experience.

BackendJavaexception handlingvalidationSpring BootControllerResponseBodyAdvice
Top Architect
Written by

Top Architect

Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.

0 followers
Reader feedback

How this landed with the community

login 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.