Controller 里塞了 200 行业务代码后,我才搞懂 service 不是"把代码挪个窝"
上周重构一个老项目,controller 里躺着段 180 行的订单创建逻辑:校验参数、算优惠、扣库存、写日志、调支付、发通知,全挤在一个方法里。我新建了个 service 目录,把代码原封不动 copy 进去,文件名加个 Service 后缀,心里默念"分层完成"。
第二天需求改了一处优惠规则,我在 service 里改完,发现 controller 里还有份旧逻辑在跑。这才意识到——分层不是搬家,是重新划清边界。
我现在的拆法:controller 只当"交通协管员"
controller 就三件事:接参数、调 service、返回格式。参数校验我交给 validate 或者 dto,绝不写 `if(empty($foo))` 这种散代码。之前图省事在 controller 里做权限判断,后来同样接口要开放给定时任务调用,权限逻辑拆不出来,只能复制一份。
有个具体例子:用户提现接口。controller 只拿到 `user_id` 和 `amount`,然后一句 `$this->withdrawService->create($dto)`。提现要不要实名认证、单笔限额多少、通道是否维护——这些 controller 不问,它只负责"这个请求能不能进门"。
service 的坑:别做成"sql 搬运工"
我早期 service 里全是 `$this->model->where(...)->update(...)`,跟 controller 直接调 model 没区别。现在强迫自己:service 描述"业务动作",model 描述"数据关系"。
比如订单 service 有个 `cancel($orderId, $reason)` 方法。里面要干的事:查订单状态能不能取消、回滚库存、记录操作日志、改订单状态、触发退款流程。这些我拆成 private 方法,但全收在 service 里。model 只暴露 `findById`、`updateStatus` 这种原子操作,绝不出现 "if status == 2" 这种业务判断。
有个血泪教训:之前把"计算分销佣金"写进 model 的 accessor,后来佣金规则改了,model 里埋着业务公式,单元测试根本覆盖不到那层。挪到 service 后,至少能 mock 订单数据单独测计算逻辑。
model 我现在的底线:关联定义可以丰富,业务钩子要谨慎
ThinkPHP 的模型事件很方便,我用 `OrderModel::updated` 做状态变更通知,结果批量更新时事件没触发,漏掉一整批短信。现在模型事件只干一件事:清缓存。其他业务流明确写在 service 里,哪怕代码多几行,至少执行路径是显式的。
关联定义我倒是放得很开,`hasMany(OrderItem)`、`belongsTo(User)` 这些在 model 里写全,service 里直接 `$order->items` 用。但有个原则:不在关联里加 `where` 业务条件。之前写过 `hasMany('items')->where('is_valid', 1)`,后来后台要查全部含失效商品的订单,那个关联直接废了,得重写。
最后说个还没完全想通的点:service 之间能不能互相调?
我现在是"尽量避免"。订单 service 需要扣库存,我倾向于在 controller 里先调库存 service 冻结,再调订单 service 创建,失败了自己回滚。而不是让订单 service 内部去调库存 service——不然链路深了,异常抛出来都不知道谁触发的。
但这样 controller 又变厚了。你们怎么处理的?事件总线?还是事务模板包一层?