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.
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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Programmer DD
A tinkering programmer and author of "Spring Cloud Microservices in Action"
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.
