How to Build a Clean, Robust Spring Controller Layer with Unified Responses and Validation

This article explains why the Controller layer is essential, identifies common pitfalls such as tangled validation and inconsistent responses, and demonstrates how to refactor Spring MVC controllers using a unified Result wrapper, ResponseBodyAdvice, proper String handling, JSR‑303 validation, custom validators, and global exception handling to produce clean, maintainable backend code.

Programmer DD
Programmer DD
Programmer DD
How to Build a Clean, Robust Spring Controller Layer with Unified Responses and Validation

An Excellent Controller Layer Logic

When it comes to Controllers, they are familiar as indispensable supporting roles that provide data interfaces; they are essential in both traditional three‑layer and modern COLA architectures, handling request reception and response while delegating business logic to Services.

Identifying Current Issues

Controller responsibilities typically include:

Receiving requests and parsing parameters

Invoking Service to execute business code (including 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("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;
    }
}

Following the listed responsibilities directly leads to several problems:

Excessive parameter validation couples business code, violating the Single Responsibility Principle.

Repeatedly throwing the same exception across services causes code duplication.

Inconsistent error and success response formats make API integration unfriendly.

Refactoring the Controller Layer

Unified Return Structure

A consistent return type is essential for both monolithic and front‑back separated projects, allowing callers to easily determine success via a status code and message rather than relying on null checks.

// Define return data structure
public interface IResult {
    Integer getCode();
    String getMessage();
}

// Common result enumeration
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 wrapper
@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);
    }

    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 use it, but writing the wrapping logic in every Controller would be repetitive, so further automation is needed.

Unified Wrapper via ResponseBodyAdvice

Spring provides ResponseBodyAdvice, which intercepts the response before HttpMessageConverter processes it, allowing us to wrap the result centrally.

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

: determines whether beforeBodyWrite should be applied. beforeBodyWrite: performs the actual wrapping.

@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // Add custom logic to exclude certain controllers 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 as is
        if (body instanceof Result) {
            return body;
        }
        return Result.success(body);
    }
}

While this unifies responses, handling String return types requires special attention because StringHttpMessageConverter processes them before the JSON converter.

Handling String Return Types

Two solutions are presented:

In beforeBodyWrite, detect String bodies and manually convert the Result object to JSON using an ObjectMapper, then set the appropriate produces media type.

Adjust the order of HttpMessageConverter instances so that MappingJackson2HttpMessageConverter precedes StringHttpMessageConverter, ensuring JSON conversion for wrapped results.

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

Parameter Validation

Spring leverages JSR‑303 (Hibernate Validator) via spring‑validation to automatically validate request parameters without coupling validation logic to business code.

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

Spring’s RequestResponseBodyMethodProcessor resolves @RequestBody arguments and performs validation, throwing MethodArgumentNotValidException or ConstraintViolationException when validation fails.

Custom Validation Rules

When built‑in constraints are insufficient, developers can create custom annotations and validators.

// 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 "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 (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 Exceptions and Global Exception Handling

Defining specific runtime exceptions allows fine‑grained handling in a centralized @RestControllerAdvice class.

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

Conclusion

After applying these changes, Controller code becomes concise, each parameter and DTO validation rule is explicit, return types are uniform, and exceptions are centrally managed, allowing developers to focus on business logic while maintaining clean, robust backend services.

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.

BackendspringvalidationControllerexception-handlingresponsebodyadvice
Programmer DD
Written by

Programmer DD

A tinkering programmer and author of "Spring Cloud Microservices in Action"

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.