Backend Development 22 min read

Standardizing the Controller Layer: Parameter Validation, Unified Response, and Exception Handling in Spring Boot

This article explains how to structure a Spring Boot controller by describing the four parts of a request, demonstrating elegant parameter validation, implementing a unified response wrapper with status codes, and handling both validation and business exceptions through centralized advice and custom exception classes.

Java Architect Essentials
Java Architect Essentials
Java Architect Essentials
Standardizing the Controller Layer: Parameter Validation, Unified Response, and Exception Handling in Spring Boot

Preface

This article introduces the handling of the controller layer; a complete backend request consists of four parts:

Interface address (URL)

Request method (GET, POST, PUT, DELETE, etc.)

Request data (headers and body)

Response data

The article solves three problems:

How to gracefully validate parameters when a request is received

How to uniformly process response data

How to handle exceptions thrown during business logic execution

1. Controller Layer Parameter Reception (very basic, can be skipped)

Common requests are divided into GET and POST.

@RestController
@RequestMapping("/product/product-info")
public class ProductInfoController {

    @Autowired
    ProductInfoService productInfoService;

    @GetMapping("/findById")
    public ProductInfoQueryVo findById(Integer id) {
        ...
    }

    @PostMapping("/page")
    public IPage findPage(Page page, ProductInfoQueryVo vo) {
        ...
    }
}

@RestController: Equivalent to @Controller + @ResponseBody; Spring Boot treats the class as a controller and puts all returned values into the response body.

@RequestMapping: Prefix for all requests under this controller, e.g., /product/product-info.

@GetMapping("/findById"): Marks a GET request accessible via /findById.

@PostMapping("/page"): Marks a POST request.

Parameters: Declaring ProductInfoQueryVo allows Spring to map incoming JSON fields to the object automatically.

size : 1
current : 1

productId : 1
productName : 泡脚

2. Unified Status Code

1. Return Format

To cooperate smoothly with the front end, we usually wrap the response with a status code and message. For example, code 1000 means success.

If not wrapped, the response looks like:

{
  "productId": 1,
  "productName": "泡脚",
  "productPrice": 100.00,
  "productDescription": "中药泡脚加按摩",
  "productStatus": 0,
}

After wrapping:

{
  "code": 1000,
  "msg": "请求成功",
  "data": {
    "productId": 1,
    "productName": "泡脚",
    "productPrice": 100.00,
    "productDescription": "中药泡脚加按摩",
    "productStatus": 0
  }
}

2. Packaging ResultVo

Status codes should be defined in an enum rather than hard‑coded.

First, define a StatusCode interface:

public interface StatusCode {
    int getCode();
    String getMsg();
}

Then create an enum implementing it:

@Getter
public enum ResultCode implements StatusCode {
    SUCCESS(1000, "请求成功"),
    FAILED(1001, "请求失败"),
    VALIDATE_ERROR(1002, "参数校验失败"),
    RESPONSE_PACK_ERROR(1003, "response返回包装失败");

    private int code;
    private String msg;

    ResultCode(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
}

ResultVo class with convenient constructors:

@Data
public class ResultVo {
    // status code
    private int code;
    // status message
    private String msg;
    // payload
    private Object data;

    public ResultVo(int code, String msg, Object data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    // success with data
    public ResultVo(Object data) {
        this.code = ResultCode.SUCCESS.getCode();
        this.msg = ResultCode.SUCCESS.getMsg();
        this.data = data;
    }

    // custom status code with data
    public ResultVo(StatusCode statusCode, Object data) {
        this.code = statusCode.getCode();
        this.msg = statusCode.getMsg();
        this.data = data;
    }

    // only status code
    public ResultVo(StatusCode statusCode) {
        this.code = statusCode.getCode();
        this.msg = statusCode.getMsg();
        this.data = null;
    }
}

Usage changes from return data; to return new ResultVo(data); .

3. Unified Validation

1. Original Approach

Without a unified validation, you would manually check each field:

@Data
public class ProductInfoVo {
    // product name
    private String productName;
    // product price
    private BigDecimal productPrice;
    // product status
    private Integer productStatus;
}

@PostMapping("/findByVo")
public ProductInfo findByVo(ProductInfoVo vo) {
    if (StringUtils.isNotBlank(vo.getProductName())) {
        throw new APIException("商品名称不能为空");
    }
    if (vo.getProductPrice() != null && vo.getProductPrice().compareTo(new BigDecimal(0)) < 0) {
        throw new APIException("商品价格不能为负数");
    }
    ...
    ProductInfo productInfo = new ProductInfo();
    BeanUtils.copyProperties(vo, productInfo);
    return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo)));
}

2. @Validated Parameter Validation

Adding validation annotations to the VO simplifies the code:

@Data
public class ProductInfoVo {
    @NotNull(message = "商品名称不允许为空")
    private String productName;

    @Min(value = 0, message = "商品价格不允许为负数")
    private BigDecimal productPrice;

    private Integer productStatus;
}

@PostMapping("/findByVo")
public ProductInfo findByVo(@Validated ProductInfoVo vo) {
    ProductInfo productInfo = new ProductInfo();
    BeanUtils.copyProperties(vo, productInfo);
    return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo)));
}

If validation fails, Spring throws a BindException. Example error response (code 1002):

{
  "code": 1002,
  "msg": "参数校验失败",
  "data": "商品价格不允许为负数"
}

3. Optimizing Exception Handling

BindException is intercepted by a @RestControllerAdvice:

@RestControllerAdvice
public class ControllerExceptionAdvice {
    @ExceptionHandler(BindException.class)
    public ResultVo handleBindException(BindException e) {
        ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
        return new ResultVo(ResultCode.VALIDATE_ERROR, objectError.getDefaultMessage());
    }
}

4. Unified Response

1. Unified Wrapper

Implement ResponseBodyAdvice to wrap any non‑ResultVo return value:

@RestControllerAdvice(basePackages = {"com.bugpool.leilema"})
public class ControllerResponseAdvice implements ResponseBodyAdvice
{
    @Override
    public boolean supports(MethodParameter methodParameter, Class
> aClass) {
        // Do not wrap if the return type is already ResultVo
        return !methodParameter.getParameterType().isAssignableFrom(ResultVo.class);
    }

    @Override
    public Object beforeBodyWrite(Object data, MethodParameter returnType, MediaType mediaType,
                                  Class
> aClass,
                                  ServerHttpRequest request, ServerHttpResponse response) {
        // String type needs special handling
        if (returnType.getGenericParameterType().equals(String.class)) {
            ObjectMapper objectMapper = new ObjectMapper();
            try {
                return objectMapper.writeValueAsString(new ResultVo(data));
            } catch (JsonProcessingException e) {
                throw new APIException(ResultCode.RESPONSE_PACK_ERROR, e.getMessage());
            }
        }
        // Wrap other types directly
        return new ResultVo(data);
    }
}

Now a controller method can simply return the PO:

@PostMapping("/findByVo")
public ProductInfo findByVo(@Validated ProductInfoVo vo) {
    ProductInfo productInfo = new ProductInfo();
    BeanUtils.copyProperties(vo, productInfo);
    return productInfoService.getOne(new QueryWrapper(productInfo));
}

The response will automatically be formatted as:

{
  "code": 1000,
  "msg": "请求成功",
  "data": {
    "productId": 1,
    "productName": "泡脚",
    "productPrice": 100.00,
    "productDescription": "中药泡脚加按摩",
    "productStatus": 0
  }
}

2. Excluding Certain Endpoints

Some endpoints (e.g., health checks) should not be wrapped. Define an annotation:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NotControllerResponseAdvice {}

Modify the advice to skip methods annotated with it:

@Override
public boolean supports(MethodParameter methodParameter, Class
> aClass) {
    return !(methodParameter.getParameterType().isAssignableFrom(ResultVo.class)
             || methodParameter.hasMethodAnnotation(NotControllerResponseAdvice.class));
}

Apply to health endpoint:

@RestController
public class HealthController {
    @GetMapping("/health")
    @NotControllerResponseAdvice
    public String health() {
        return "success";
    }
}

5. Unified Exception

Define business exception codes:

@Getter
public enum AppCode implements StatusCode {
    APP_ERROR(2000, "业务异常"),
    PRICE_ERROR(2001, "价格异常");

    private int code;
    private String msg;

    AppCode(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
}

Custom exception class:

@Getter
public class APIException extends RuntimeException {
    private int code;
    private String msg;

    // manual setting
    public APIException(StatusCode statusCode, String message) {
        super(message);
        this.code = statusCode.getCode();
        this.msg = statusCode.getMsg();
    }

    // default uses APP_ERROR
    public APIException(String message) {
        super(message);
        this.code = AppCode.APP_ERROR.getCode();
        this.msg = AppCode.APP_ERROR.getMsg();
    }
}

Centralized exception handling:

@RestControllerAdvice
public class ControllerExceptionAdvice {
    @ExceptionHandler(BindException.class)
    public ResultVo handleBindException(BindException e) {
        ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
        return new ResultVo(ResultCode.VALIDATE_ERROR, objectError.getDefaultMessage());
    }

    @ExceptionHandler(APIException.class)
    public ResultVo handleAPIException(APIException e) {
        // log.error(e.getMessage(), e); // TODO: integrate logger
        return new ResultVo(e.getCode(), e.getMsg(), e.getMessage());
    }
}

Usage in service or controller:

if (orderMaster == null) {
    throw new APIException(AppCode.ORDER_NOT_EXIST, "订单号不存在:" + orderId);
}

Result:

{
  "code": 2003,
  "msg": "订单不存在",
  "data": "订单号不存在:1998"
}

With this setup, developers only need to throw APIException for business errors, while the framework takes care of consistent response formatting, validation error handling, and logging.

JavaException HandlingValidationSpring BootControllerResponse Wrapper
Java Architect Essentials
Written by

Java Architect Essentials

Committed to sharing quality articles and tutorials to help Java programmers progress from junior to mid-level to senior architect. We curate high-quality learning resources, interview questions, videos, and projects from across the internet to help you systematically improve your Java architecture skills. Follow and reply '1024' to get Java programming resources. Learn together, grow 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.