参数校验放在 Controller 还是 Service

这篇文章把 Controller 校验和 Service 校验的职责边界拆开讲清楚:结构校验优先放边界层,业务校验必须放核心层,并给出一套能直接落地的 Spring Boot 代码组织方式。

校验写在 Controller 还是 Service?

你一定见过这种“战争现场”:

  • A 同学:校验就该放 Controller,请求进来先挡住,省得 Service 里一堆 if!
  • B 同学:校验必须放 Service,不然绕过 Controller(RPC/定时任务/消息消费)直接炸!
  • C 同学:都别吵了,我全放一起……结果半年后谁也不敢动。

这事之所以吵不完,是因为大家把“校验”当成了一个东西。实际上它至少是 两类完全不同的校验

结构校验(输入校验 / 参数校验):字段是否为空、长度、格式、范围、枚举值是否有效……

业务校验(规则校验 / 领域校验):库存是否足够、状态是否允许变更、是否有权限、是否重复下单、金额计算是否合理……

一句话定调:

结构校验优先放 Controller(边界层),业务校验必须放 Service(核心层)。
并且:Service 永远要能自证安全(即便 Controller 已校验)。

下面我用“能落地”的方式,把争议彻底拆开讲清楚。


01 先别急着站队:你说的“校验”到底是哪一种?

结构校验(边界层该做的事)

这类校验的目标是:保证进入系统的数据“形态正确、字段齐全”
典型包括:

  • 必填:@NotNull @NotBlank
  • 长度:@Size
  • 格式:@Email @Pattern
  • 数值范围:@Min @Max @Positive
  • 枚举:自定义注解校验枚举值
  • JSON 解析、类型转换错误

这些校验有个特点:不依赖数据库、不依赖上下文,不会查库存、不会查权限。

它更像是“门禁”。

最适合放在 Controller:离用户最近、失败更快、错误信息更清晰。


业务校验(核心层必须做的事)

这类校验的目标是:保证业务规则不被破坏

典型包括:

  • 库存不足不能下单
  • 订单已支付不能取消
  • 同一用户同一商品 30 秒内不能重复下单
  • 只有管理员能删除
  • 金额必须等于明细之和(防篡改)

这些校验有个特点:依赖业务上下文,可能查库、可能调用外部服务、可能需要事务一致性。

它更像是 “业务规则的护栏”:确保流程不越界、数据不被破坏。

必须放在 Service:因为 Service 才是真正的业务边界。


02 为什么“只放 Controller”一定会翻车?

因为你的系统不只有 HTTP 入口。

今天你只有 Controller;明天你会有:

  • 消息消费(Kafka/RabbitMQ)
  • 定时任务
  • 内部 RPC / gRPC
  • 批处理脚本
  • 甚至别的同事直接 @Autowired service 调方法

如果业务规则只写在 Controller,那么绕过 Controller 的入口都会变成“无门禁的侧门”。

结论:业务校验不在 Service,就等于没写。


03 那“全放 Service”是不是最安全?

安全是安全,但很多团队会因此走向另一个极端:

  • Service 里堆满 if (dto.getXxx() == null) 这种结构校验
  • 错误信息难读,接口体验差
  • Controller 变得“空壳”,但 Service 膨胀成“泥球”

结构校验如果全放在 Service,就会把 “入口过滤” 和 “业务规则” 混在一起,Service 迅速膨胀,维护成本直线上升。


04 最佳实践:两层校验,职责清清楚楚

规则 1:Controller 做结构校验(边界校验)

  • DTO + Bean Validation 在 Controller 入口拦住
  • 错误信息更贴近请求字段
  • 失败更快更省资源

规则 2:Service 做业务校验(规则校验/领域校验)

  • 任何能破坏业务一致性的规则,必须在 Service
  • Service 要保证:不管从哪里被调用,业务都不会被绕过

规则 3:同一条规则只写一次(避免重复)

  • 结构规则:只在边界层(Controller / Adapter)
  • 业务规则:只在核心层(Service / Domain)

记住这个口诀:
“边界保形,核心保真。”
(边界保证数据形态正确,核心保证业务真实正确。)


05 可直接抄的代码结构(Spring Boot 示例)

1)Controller:DTO + JSR-380 参数校验

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public Long create(@Valid @RequestBody CreateOrderRequest req) {
        return orderService.createOrder(req);
    }
}

@Data
public class CreateOrderRequest {

    @NotNull(message = "用户ID不能为空")
    private Long userId;

    @NotNull(message = "商品ID不能为空")
    private Long productId;

    @NotNull(message = "购买数量不能为空")
    @Min(value = 1, message = "购买数量至少为1")
    @Max(value = 999, message = "购买数量不能超过999")
    private Integer count;

    @NotBlank(message = "收货地址不能为空")
    @Size(max = 200, message = "收货地址不能超过200字")
    private String address;
}

这层的目标:让请求“合格入场”。


2)统一返回参数错误(建议必配)

别让前端看到一堆难懂的异常堆栈。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> handleValidationException(MethodArgumentNotValidException e) {
        List<String> errors = e.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(err -> err.getField() + ": " + err.getDefaultMessage())
            .toList();

        return ResponseEntity.badRequest().body(errors);
    }
}

这样前端拿到的是明确字段错误,而不是含糊其辞的 500。


3)Service:只负责业务校验 + 业务动作

@Service
public class OrderService {

    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;

    public OrderService(ProductRepository productRepository,
                        OrderRepository orderRepository) {
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
    }

    @Transactional
    public Long createOrder(CreateOrderRequest req) {
        Product product = productRepository.findById(req.getProductId())
            .orElseThrow(() -> new BizException("商品不存在"));

        if (product.getStock() < req.getCount()) {
            throw new BizException("库存不足");
        }

        boolean duplicated = orderRepository.existsRecentOrder(
            req.getUserId(), req.getProductId(), Duration.ofSeconds(30)
        );
        if (duplicated) {
            throw new BizException("请勿重复下单");
        }

        product.decreaseStock(req.getCount());

        Order order = new Order(req.getUserId(), req.getProductId(),
            req.getCount(), req.getAddress());
        orderRepository.save(order);

        return order.getId();
    }
}

注意这里做的都不是“字段有没有传”,而是“这笔订单在业务上能不能成立”。


06 再进一步:RPC、消息、定时任务也遵守同一原则

很多人只在 Web 接口里讲校验,一到别的入口就乱了。

其实原则完全一样:

  • Web Controller:做结构校验
  • MQ Consumer:做消息结构校验
  • RPC Adapter:做协议参数校验
  • Scheduler/Job Handler:做任务入参校验
  • Service:统一兜底业务校验

也就是说,每个入口都要做自己的边界校验,但核心规则仍然只能由 Service 负责。

这才是可扩展的架构。


07 什么情况下校验可以继续下沉到 Domain?

如果你的项目复杂到一定程度,Service 也会继续变厚。

这时可以再往下走一步:把“稳定的业务规则”下沉到领域对象或领域服务。

比如:

public class Order {

    public void cancel() {
        if (this.status == OrderStatus.PAID) {
            throw new BizException("已支付订单不能取消");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

这条规则本质上属于订单自身,而不是 Controller,也不只是应用服务流程的一部分。

所以更完整的分层会变成:

  • Controller:结构校验
  • Application Service:流程编排 + 调用领域对象
  • Domain:核心业务不变量

但如果你现在还在讨论“校验放 Controller 还是 Service”,通常先把 Controller / Service 边界理顺 就够了,别一上来就过度设计。


08 面试里怎么回答,才像真的做过项目?

如果面试官问你: “参数校验放 Controller 还是 Service?”

你不要只回一句“都可以,看团队”。

更好的回答方式是:

我会把校验拆成两类看。
结构校验,比如必填、长度、格式、范围,这类放在 Controller 或其他入口层,用 DTO + Bean Validation 来做,失败更早、提示更清晰。
业务校验,比如库存、状态流转、权限、幂等、重复提交,这类必须放在 Service,因为 Service 才是业务边界,不能依赖 Controller 唯一入口。
如果系统还有 RPC、MQ、定时任务等入口,它们也应该做各自的结构校验,但业务规则仍然由 Service 统一兜底。
简单说就是:边界层保输入合法,核心层保业务正确。

这就不是“背八股”,而是真正理解了分层职责


09 最后给一个工程化判断标准

以后再遇到“这个校验写哪”的争论,你就问自己两句话:

问题 1:这条规则是在判断“数据长得像不像话”吗?

如果是:

  • 判空
  • 长度
  • 格式
  • 枚举值
  • 类型转换

Controller / 入口层

问题 2:这条规则是在判断“业务能不能这么做”吗?

如果是:

  • 有没有权限
  • 状态能不能流转
  • 库存够不够
  • 能不能重复提交
  • 金额是否合理

Service / 领域层

用这个标准,基本不会错。


总结

这场争论真正的答案不是“Controller”或者“Service”二选一,而是:

  • 结构校验放边界
  • 业务校验放核心
  • Service 必须具备自我保护能力

所以最合理的结论就是:

Controller 负责让错误请求别进来,Service 负责让错误业务别发生。

这才是一个长期可维护系统该有的样子。