Backend Development 18 min read

Improving Controller Layer Logic, Unified Response Structure, and Parameter Validation in Spring Boot

This article explains how to design a clean Controller layer in Spring Boot, introduces a unified response format with Result and ResponseBodyAdvice, demonstrates parameter validation using JSR‑303 and Spring Validation, and shows custom validation annotations and global exception handling to keep controller code concise and maintainable.

Top Architect
Top Architect
Top Architect
Improving Controller Layer Logic, Unified Response Structure, and Parameter Validation in Spring Boot

Hello, I am a top architect.

An Excellent Controller Layer Logic

The Controller provides data interfaces and acts as an indispensable supporting role in both traditional three‑tier and modern COLA architectures. It mainly receives requests, delegates business execution to services, handles exceptions, and returns responses.

Current Problems

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

Identical exceptions are thrown in many places, causing code duplication.

Response formats for success and error are inconsistent, making API integration unfriendly.

Refactoring the Controller Layer

Unified Return Structure

Using a unified return type makes it clear whether an API call succeeded, regardless of front‑end/back‑end separation.

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

// Common result enum
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");
    // ... getters and constructors omitted
}

// Unified response wrapper
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result
{
    private Integer code;
    private String message;
    private T data;
    // static factory methods for success/failure omitted for brevity
}

After defining the wrapper, each controller can simply return Result.success(data) . To avoid repetitive wrapping, we use Spring's ResponseBodyAdvice :

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

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

Parameter Validation

JSR‑303 (validation‑api) and its popular implementation Hibernate Validator provide a standard way to validate parameters. Spring Validation wraps these rules for @RequestParam , @PathVariable , and @RequestBody .

1️⃣ @PathVariable and @RequestParam Validation

@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 getByAccount(@RequestParam @NotBlank @Email String email) {
        TestDTO dto = new TestDTO();
        dto.setEmail(email);
        return dto;
    }
}

If validation fails, Spring throws MethodArgumentNotValidException (for body) or ConstraintViolationException (for path/query parameters).

2️⃣ @RequestBody Validation

// DTO
@Data
public class TestDTO {
    @NotBlank
    private String userName;
    @NotBlank @Length(min = 6, max = 20)
    private String password;
    @NotNull @Email
    private String email;
}

// Controller
@RestController
@RequestMapping("/pretty")
public class TestController {
    @PostMapping("/test-validation")
    public void testValidation(@RequestBody @Validated TestDTO testDTO) {
        testService.save(testDTO);
    }
}

3️⃣ Custom Validation Rules

// Custom annotation
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@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;
    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) return pattern.matcher(value).matches();
        if (StringUtils.hasText(value)) return pattern.matcher(value).matches();
        return true;
    }
}

Custom Exceptions and Global Exception Handling

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

// Global 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) {
        StringBuilder sb = new StringBuilder("Validation failed: ");
        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()); }
}

Conclusion

After applying these changes, controller code becomes concise, each parameter’s validation rules are explicit, return data is uniform, and exceptions are centrally handled, allowing developers to focus on business logic while keeping the codebase clean and maintainable.

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