Backend Development 19 min read

Best Practices for Designing an Excellent Controller Layer in Spring MVC

The article presents comprehensive best‑practice guidelines for building a clean, maintainable Controller layer in Spring MVC, covering its essential responsibilities, common pitfalls, unified response structures, parameter validation with JSR‑303, custom validators, and centralized exception handling to improve code simplicity and robustness.

Java Captain
Java Captain
Java Captain
Best Practices for Designing an Excellent Controller Layer in Spring MVC

An Excellent Controller Layer Logic

When it comes to Controllers, everyone is familiar with them as convenient data‑exposing interfaces. Their role is an indispensable supporting component in both traditional three‑tier architecture and modern COLA architecture, proving their necessity.

Although essential, Controllers are considered a supporting role because they typically do not contain business logic; they are responsible for receiving and responding to requests.

Identify Issues from Current Situation

The main responsibilities of a Controller are:

Receive requests and parse parameters

Invoke Service to execute business code (including parameter validation)

Capture business‑logic exceptions and provide feedback

Return successful responses when business logic succeeds

@Data
public class TestDTO {
    private Integer num;
    private String type;
}

@Service
public class TestService {
    public Double service(TestDTO testDTO) throws Exception {
        if (testDTO.getNum() <= 0) {
            throw new Exception("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");
    }
}

@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 problems arise:

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

Repeated throwing of the same exception across multiple services leads to code duplication.

Inconsistent error and success response formats make API integration unfriendly.

Refactor Controller Layer Logic

Unified Return Structure

A consistent return type is essential for both front‑end and back‑end developers to clearly understand whether an API call succeeded, beyond merely checking for null values.

Using a status code and message provides a clear indication of the call outcome:

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

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
}

@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
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
Result
instance(Integer code, String message, T data) {
        Result
result = new Result<>();
        result.setCode(code);
        result.setMessage(message);
        result.setData(data);
        return result;
    }
}

After defining this unified structure, each Controller can use it, but writing the wrapping logic in every Controller would be repetitive, so further automation is needed.

Unified Wrapper Processing

Spring provides ResponseBodyAdvice to intercept the response before HttpMessageConverter processes it, allowing us to apply the unified wrapper automatically.

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

Implementation example:

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

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                Class
> selectedConverterType,
                                ServerHttpRequest request, ServerHttpResponse response) {
        // If already wrapped, return as is
        if (body instanceof Result) {
            return body;
        }
        return Result.success(body);
    }
}

With this advice, Controllers no longer need to manually wrap responses, keeping the code concise while maintaining a uniform output format.

Parameter Validation

JSR‑303 defines a standard validation API; Hibernate Validator is a popular implementation. Spring Validation builds on it to provide automatic validation for Spring MVC parameters, decoupling validation logic from business code.

① @PathVariable and @RequestParam Validation

For GET requests, use @PathVariable and @RequestParam . When the number of parameters exceeds five, prefer using a DTO for better maintainability.

@RestController(value = "prettyTestController")
@RequestMapping("/pretty")
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 failures trigger MethodArgumentNotValidException .

Validation Principle

Spring MVC uses RequestResponseBodyMethodProcessor to parse @RequestBody parameters and handle @ResponseBody return values.

Parses parameters annotated with @RequestBody

Processes return values of methods annotated with @ResponseBody

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) {
            // Execute validation
            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);
}

② @RequestBody Validation

For POST/PUT requests, use @RequestBody . Adding @Validated on the DTO triggers automatic validation.

@Data
public class TestDTO {
    @NotBlank
    private String userName;

    @NotBlank
    @Length(min = 6, max = 20)
    private String password;

    @NotNull
    @Email
    private String email;
}

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

Validation failures throw ConstraintViolationException .

③ Custom Validation Rules

When built‑in JSR‑303 constraints are insufficient, you can define custom annotations and validators.

Create a custom annotation that defines error messages and attributes.

Implement a validator that contains the actual validation logic.

// 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;
    String message() default "Invalid mobile number format";
    Class
[] groups() default {};
    Class
[] payload() default {};
}

// Validator implementation
public class MobileValidator implements ConstraintValidator
{
    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(final CharSequence str) {
        Matcher m = pattern.matcher(str);
        return m.matches();
    }
}

Custom validators allow complex business rules to be expressed cleanly, keeping controllers lightweight and adhering to the Single Responsibility Principle.

Custom Exceptions and Unified Exception Handling

The original code had several drawbacks:

Exceptions were generic, lacking specific semantics.

Controllers could not differentiate responses based on exception type.

Validation errors produced a response format different from successful calls.

Defining custom exceptions and a global @RestControllerAdvice enables fine‑grained error handling and ensures all responses follow the unified Result structure, with HTTP status always 200.

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

Summary

After applying all these changes, Controller code becomes extremely concise. Each parameter and DTO validation rule is explicit, return types are uniform, and exception handling is centralized, allowing developers to focus on business logic while maintaining clean, robust, and maintainable code.

backendJavaSpringvalidationControllerExceptionHandling
Java Captain
Written by

Java Captain

Focused on Java technologies: SSM, the Spring ecosystem, microservices, MySQL, MyCat, clustering, distributed systems, middleware, Linux, networking, multithreading; occasionally covers DevOps tools like Jenkins, Nexus, Docker, ELK; shares practical tech insights and is dedicated to full‑stack Java development.

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.