1. 三个候选位置先摆出来对比一下可能很多人觉得这是个小题大做的问题不就是一个取消订单的接口吗API 层拿到订单号和用户 ID校验完权限把订单状态改成已取消最多再回滚个库存三下五除二就搞定了纠结那么多干嘛但实际做过的朋友应该知道订单取消往往是整个订单系统里最容易出问题的地方之一。它不只是改一个 status 字段那么简单要判断订单当前状态是否允许取消要处理支付成功后的退款要释放库存和优惠券可能还有发票作废、物流拦截、消息通知、风控记录等等一长串动作。这些逻辑放哪、怎么组织直接决定你这个订单模块后面好不好维护、要不要返工。我见过一个真实案例团队为了赶上线一开始把取消逻辑全塞在 Controller 里接口代码写了三百多行。后来业务要加一个“已发货订单不允许用户取消只能申请售后”的规则光排查这三百行里哪些地方要改就把人逼疯了。后来重构分了三层同样一个功能改动成本至少降了一半。所以这个问题的本质是你想把“规则判断”和“流程编排”分别放在哪里。先直观看一下三个候选位置放在 Controller直接在控制器方法里写判断逻辑调模型改状态然后返回 JSON。放在 Service在 Service 层定义一个cancelOrder($orderId, $userId)方法在方法内部完成校验、改状态、调用外部接口。放在 Order 对象给 Order 类加一个cancel()方法由 Order 自己完成状态校验和状态变更外部只负责触发。听起来好像都能实现功能但三种写法对应的代码结构完全不同长期的维护成本也天差地别。下面逐一拆解。1.1 放在 Controller看起来最快后期最痛先看一段我早期踩坑时写过的代码风格简化一下大概是这样// OrderController.php class OrderController { public function cancel(Request $request) { $orderId $request-input(order_id); $userId $request-user()-id; $order Order::where(user_id, $userId)-find($orderId); if (!$order) { return response()-json([code 404, msg 订单不存在]); } if ($order-status ! Order::STATUS_PENDING) { return response()-json([code 422, msg 当前状态不允许取消]); } if ($order-isSeckill $order-seckill_started) { return response()-json([code 422, msg 秒杀订单超过可取消时间]); } // 模拟退款 $refund RefundService::refund($order-order_no, $order-pay_amount); if ($refund-failed()) { return response()-json([code 500, msg 退款失败请稍后重试]); } // 改状态 $order-status Order::STATUS_CANCELED; $order-canceled_at now(); $order-save(); // 回滚库存 StockService::increment($order-sku_id, $order-quantity); return response()-json([code 0, msg ok]); } }第一次写会觉得很爽因为所有东西都摊开了顺着从上往下读就是整个业务流程。但问题也藏在这种“顺畅”里。订单取消的规则不是一成不变的。今天允许未支付订单直接取消明天可能加一个“拼团订单生效后不能取消”后天可能加一个“取消后要同时释放用户的会员体验券”。每来一个新需求你都要在 Controller 里找到对应位置然后小心翼翼地插入新判断生怕动到其他逻辑。时间一长Controller 方法就变成一个谁都不敢碰的大泥球。还有一个很关键的问题Controller 本身属于“HTTP 协议适配层”它管的是参数接收、鉴权、响应格式这些东西而不是业务规则。你把业务规则放进来等于把两层职责揉在一起以后想把同一个取消逻辑接到 CLI 命令、定时任务或者队列消费里就很尴尬——要么复制一份要么被迫走 HTTP 请求。1.2 放在 Service大多数 PHP 项目的主战场后来我换了一种写法把业务逻辑挪到 Service 层class OrderService { public function cancel($orderId, $userId) { $order Order::where(user_id, $userId)-findOrFail($orderId); $this-validateCanCancel($order); DB::beginTransaction(); try { $this-refundIfPaid($order); $this-markCanceled($order); $this-releaseStock($order); DB::commit(); } catch (\Exception $e) { DB::rollBack(); throw $e; } event(new OrderCanceled($order)); } // 省略各私有方法... }Controller 里的代码立刻瘦身了只剩几行调用和响应处理。这个方向没问题绝大多数 PHP 项目确实是这样做的而且短期内很实用。但这里有一个隐藏的分岔口如果你只是把原来 Controller 里的过程式逻辑原封不动搬到 Service那 Service 迟早会长成另一个大泥球。举个例子“取消订单”这个操作可能有几十个判断分支和十几步后续动作全塞在一个 Service 方法里还是写成了过程式代码。只不过是把 Controller 的肥胖转移到了 Service 的肥胖。真正面向对象的项目Service 应该是一个“编排者”它负责协调各个领域对象完成业务流程而不是把所有规则都揽在自己身上。1.3 放进 Order 对象面向对象派的主张第三种是让 Order 对象自己处理取消class Order { public function cancel() { $this-assertCanCancel(); $this-status self::STATUS_CANCELED; $this-canceled_at now(); } private function assertCanCancel() { if ($this-status self::STATUS_PAID) { throw new OrderCannotCancelException(当前状态不允许取消); } } }这个方案的优点是直观因为“取消订单”本来就是订单这个领域对象自己的行为。状态校验和状态变更在一起不会散落各处。但问题也很明显取消一个订单往往不只是改状态它可能涉及退款、库存、优惠券等跨实体的协作这些协作放不进 Order 里的一个简单方法否则 Order 就要依赖别的 Service 和 Repository最终搞出循环依赖或者让订单对象变成一个“上帝类”。所以问题不是非此即彼而是“判断规则放哪、流程编排放哪”需要分开考虑。2. 为什么 Controller 放不下订单取消这种业务逻辑这里再展开说说 Controller 的职责边界。因为很多中小项目一开始没有分层的概念路由文件里直接写逻辑的也见过不少所以我觉得值得单独拎出来讲。2.1 Controller 的唯一义务是协议适配Controller 在你的架构里扮演的角色应该是“翻译官”。它把 HTTP 请求翻译成应用层能理解的数据结构再把应用层的返回值翻译成 HTTP 响应。具体到 Laravel 或者 ThinkPHP 这类框架Controller 里允许出现的内容我认为最多就是从 Request 对象里取参数做基础格式校验比如订单号不能为空。调用认证组件拿到当前用户身份。调用 Service传入所需参数。捕获异常映射成 HTTP 状态码和 JSON 结构。一旦 Controller 里开始出现if ($order-status Order::STATUS_PAYED)这种业务判断你就该意识到边界失守了。这就像门卫开始插手公司财务一样短期看不出大问题后面一定乱。2.2 Controller 层膨胀后的真实案例说个更容易理解的例子有一次我接手一个电商项目原开发把一个“订单取消”接口写得非常全面除了改状态之外还把退款单的创建、库存的更新、甚至对账日志的写入都塞在 Controller 方法里了。一开始只有这一个取消入口确实没毛病。后来业务方提了个需求用户下单超过 30 分钟未支付系统要在每天凌晨两点自动批量取消而且取消时要记录“系统取消”和“用户取消”两个不同的原因方便客服后续对账。这个时候问题就冒出来了针对同一套取消逻辑新需求需要再写一份差不多的代码吗如果你复制 Controller 里的逻辑到命令行脚本里那后续改一个规则就要改两个地方迟早漏一处。所以把业务逻辑从 Controller 里抽离出来不是说为了什么高深的设计理念而是为了给相同业务的不同入口留出一条共用的通道。取消逻辑之所以不放 Controller就是为了避免“同样的业务规则在多个地方各写一份”。3. Service 层的事务与编排优势以及它的边界在哪既然 Controller 不适合放业务逻辑那 Service 是不是就天然最合适的呢我的看法是Service 适合当“流程编排容器”但不适合把所有业务规则都堆在里面。先看它真正的优势。3.1 事务边界为什么必须由 Service 开启只要项目用的是关系型数据库事务边界就是一个绕不开的问题。订单取消至少要经历查订单、校验状态、更新订单状态、回滚库存可能还要插入一张退款单。如果其中任何一步失败前面已执行的操作必须全部回滚否则会出现“订单已取消库存却没恢复”的数据不一致。在 PHP 的经典分层里事务必须从 Service 层开始管理而不能从 Controller 或 Model 里随意开启。原因很简单一个业务用例往往需要跨多个模型和 Repository 协作事务必须覆盖完整流程只有处于业务入口位置的 Service 能承担这个责任。举个例子假设你在 Order 对象里写了cancel()方法而且这个方法内部自己开了事务。那问题就来了如果将来订单取消时还要同时锁定一张优惠券而这个锁定动作写在另一个对象里那 Order 对象方法里的事务显然覆盖不到后面那一步。硬要覆盖你就得在 Order 对象里注入其他服务结果还是绕回到 Service 层。所以事务归 Service 层管理几乎是分层架构的必然结果。放在 Controller 里也可以开启事务但一旦 Controller 被复用比如被命令行调用事务逻辑就泄漏到了协议层很不干净。3.2 贫血模型的苦逻辑全在 ServiceOrder 变成“数据袋子”Service 层驻留业务逻辑带来了一个经典问题Order 对象越来越“贫血”。所谓的贫血模型简单说就是 Model 里全是getStatus()、setStatus()这种 getter/setter以及一个$fillable数组真正的业务规则全在 Service 里。这样的 Order 对象本质上就是一个“数据袋子”到处被人取出去判断状态在 Controller 里判断一次if ($order-status pending)在 Service 里判断一次if ($order-status paid)在队列任务里再判断一次问题是状态判断这个业务规则被散落在 N 个地方。如果哪一天订单状态从pending改成PENDING或者数值从 0 改成 1那你就得全局搜索出来一处一处的改漏一处就出一个隐蔽 Bug。真正合理的做法是把这些判断收敛到 Order 对象里提供一个canCancel()或者isCancelable()方法外部只管调用。这样即使状态枚举改了改动也只落在一处。这一点恰恰是支持“把取消逻辑放进 Order 对象”的核心论据。3.3 编排 vs 业务规则Service 真正擅长的事回到主题。我认为更准确的划分方法是业务规则、状态流转的约束尽量放到领域对象Order里。跨对象协作、事务管理、外部服务调用放到 Service 里编排。怎么理解比如“已支付订单不允许直接取消必须先走退款流程”这条规则属于 Order 自己的业务约束放在 Order 对象里合适。而“退款成功后要调用库存服务回滚库存”这种跨对象协作放在 Service 里合适。这样分完之后Service 方法就会显得很“薄”因为真正复杂的业务规则都在对象里Service 只是把这些规则一个个串起来。很多刚入行的朋友可能会误以为“Service 代码多才是业务复杂”其实恰恰相反Service 里的过程式代码越多领域对象的表达能力越弱后期越难维护。4. 把取消逻辑放进 Order 对象的功底与限制到了这一步答案其实已经比较清晰了取消逻辑里的规则判断部分确实应该往 Order 对象里放。但放进去之前要搞清楚两个问题哪些能放哪些不能放以及放了之后会不会给自己挖坑。4.1 贫血模型 vs 充血模型一次讲透贫血模型和充血模型这两个词很多人听得耳朵起茧但始终没搞明白。我用大白话解释一下。贫血模型就像一张体检单上面写满了各项数据但医生不会在体检单上做任何判断所有诊断结论都写在另一个医生手册里。充血模型则更像一个作息自律的人他清楚自己晚上几点该睡觉早上几点该起床不需要外部教练看着才能行动。放在订单场景里贫血模型的 Order 只是状态字段的载体它不关心自己是待支付还是已支付更不关心能不能被取消判断都是外面的人在替它做。而充血模型的 Order 自身就知道“我在待支付状态下才能被取消”“我已经发货了就不能直接取消”外部只需要触发它的行为即可。我见过有些团队把 Model 当纯数据类用Service 里到处是$order[status]这样的数组访问连 getter 都省了这种项目维护起来是真的头疼。所以我的态度很明确领域对象该有的业务方法不能缺哪怕你最终决定把复杂编排放 Service也至少要在 Order 上提供isCancelable()这种判断方法让规则收敛在一处。4.2 Order 对象里适合放什么具体到 Order 对象我认为适合放进去的是“状态迁移相关的业务不变量”。所谓业务不变量可以理解成任何时刻都不能被打破的规则。如果订单已经发货不能再直接取消只能走售后流程。如果订单已经完成不能再次执行取消。如果订单属于实物商品且已经出库取消时必须同步拦截物流。如果订单已经申请过取消不能重复发起。这些判断只依赖订单自身字段和几个简单的关联关系不依赖外部服务非常适合放在 Order 里。class Order { public function canCancel(): bool { return in_array($this-status, [ self::STATUS_PENDING, self::STATUS_PAID, ], true) !$this-hasRefundRequest(); } public function cancel(string $reason, string $operatorType, string $operatorId): void { if (!$this-canCancel()) { throw new OrderCannotCancelException(); } $this-status self::STATUS_CANCELED; $this-reason $reason; $this-canceled_at new \DateTimeImmutable(); $this-operator_type $operatorType; $this-operator_id $operatorId; } }注意cancel()里只做“纯内存操作”也就是只修改对象自身字段不写数据库、不调外部接口。真正把对象状态持久化到数据库的活应该由 Repository 或 Model 的save()来干。这样 Order 就是一个很纯粹的领域对象不依赖框架和数据库连接测试起来也简单。4.3 Order 对象里不能放什么说完了能放的再说不建议放的。这里我吃过亏印象比较深。不能放支付网关调用。订单取消可能涉及微信/支付宝退款这是外部服务调用不应该被 Order 对象直接依赖。否则你的 Order 类就和微信 SDK 耦合了以后想换第三方支付就是一场大改。不能放数据库事务逻辑。事务是围绕完整业务用例来的不是围绕单个实体。让 Order 自己 commit/rollback等于把事务边界僵化在一个对象里严重影响复用。不能放定时任务、队列、通知这些“周边动作”。这些应该由 Service 编排或者通过事件监听器异步触发。简单总结Order 对象负责“知道规则并做出状态响应”Service 负责“在正确的时机调用规则并处理周边影响”。各管一段职责才清楚。5. 我的推荐方案领域服务 充血实体的组合讲完了原则下面给出一套我认为在 PHP 项目里实际落地性价比最高的方案。不用引入复杂的 DDD 全套方法论只需要把上面对齐的思想落实到代码结构里。5.1 推荐的目录结构与代码骨架以 Laravel 为例我的目录会长这样app/ ├── Models/ │ └── Order.php ├── Services/ │ └── Order/ │ ├── OrderService.php │ ├── OrderCancellationService.php │ └── Exceptions/ │ └── OrderCannotCancelException.php └── Http/ └── Controllers/ └── Api/ └── OrderController.php注意我把 Order 相关的服务拆成了通用OrderService和专门的OrderCancellationService。为什么要拆因为“取消订单”这个流程足够复杂如果和其他通用查询操作混在一起OrderService会越滚越大。单独拆出来之后取消逻辑的修改不会影响到订单查询、列表、详情这些稳定代码。OrderCancellationService 大概长这样class OrderCancellationService { public function __construct( private OrderRepository $orderRepository, private RefundGateway $refundGateway, private StockService $stockService ) {} public function cancel(Order $order, string $reason, string $operatorType, string $operatorId): void { DB::beginTransaction(); try { // 1. 领域规则校验由 Order 自己决定 $order-cancel($reason, $operatorType, $operatorId); // 2. 已支付的订单先发起退款 if ($order-isPaid()) { $this-refundGateway-refund($order); } // 3. 释放库存 $this-stockService-restoreStock($order-items); // 4. 持久化订单状态 $this-orderRepository-save($order); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); // 这里可以根据异常类型决定是否要重试退款 throw $e; } // 5. 事务提交后再发事件避免事件回调里查不到数据 event(new OrderCanceled($order)); } }可以看到这个 Service 方法很薄它做的事情只是开启事务、调用 Order 领域方法、调用周边服务、提交事务、发事件。真正“能不能取消”的判断全在Order::cancel()内部。5.2 完整调用流程附关键实现说明Controller 里的调用流程就简化成三步class OrderController extends Controller { public function cancel(Request $request, OrderCancellationService $cancellationService) { $order $this-findOwnOrder($request-user(), $request-input(order_id)); try { $cancellationService-cancel( $order, $request-input(reason, 用户主动取消), OperatorType::USER, (string) $request-user()-id ); } catch (OrderCannotCancelException $e) { return response()-json([code 422, msg $e-getMessage()], 422); } catch (\Throwable $e) { logger()-error(订单取消失败, [ order_no $order-order_no, error $e-getMessage(), ]); return response()-json([code 500, msg 取消失败请稍后重试], 500); } return response()-json([code 0, msg ok]); } }这里的关键是Controller 里捕获业务异常后直接返回对应 HTTP 状态码不要再做任何业务判断。状态能不能取消、退款成没成功这些信息来自异常类型和异常消息。这样做之后Controller、Service、Model 的职责一目了然。5.3 为什么这个组合能减少未来改动这套组合的价值要放在“需求变化”里看。假设业务方改动频繁比如三个月后加一条“预售订单在支付定金后不允许用户取消只能申请客服介入”你只需要在 Order 的canCancel()里加一个判断分支再加个异常提示Service 层一行不动Controller 也不受影响。再比如“取消订单时增加积分扣回”这种需求你只需要在 Service 的cancel()方法里加一行积分服务调用Order 和 Controller 都不用动。这种扩展性对于订单这种高频变更模块来说非常值钱。提示这个方案的核心不是“把逻辑放到哪一层”而是把“易变化的规则”和“易扩展的流程”分开管理。规则收敛在对象里流程编排在 Service 里Controller 只做协议适配这是我认为综合成本最低的组合。6. 实际项目里怎么落地这个决策框架方案讲完了但我知道很多人回到真实项目里还是会犹豫自己手上的项目到底应该怎么改是不是所有系统都得这么分这里给一个决策框架大家可以拿来直接用。6.1 不同项目体量的选择建议先看项目规模。如果你维护的是一个很小的内部管理后台订单表一天几百条整个系统就五六个接口那你确实不需要一上来就搞领域服务、事件监听那一套过度设计比没有设计更痛苦。这种情况下把取消逻辑简单放到 Service 里就已经优于 Controller 了Order 对象上补一个isCancelable()方法已经足够。但如果你做的是电商、外卖、票务这类“订单是绝对核心”的系统后续大概率会有复杂的优惠、库存、物流、退款、售后链路那从第一天开始就按“Controller 薄、Service 编排、Order 承载规则”的结构来写是非常有必要的。因为重构订单系统的成本远比你想象的高越早把边界定好后面就越省心。6.2 团队协作视角下的取舍还可以从团队协作的角度来看这个问题。如果团队里既有高级工程师也有刚入门的新人把规则收敛到对象里其实是在给新人指路——他们看到 Order 类里的canCancel()就知道规则在哪里看到 Service 里的流程就知道业务从上到下是怎么走的。如果所有规则都散落在各种 Service 和 Controller 里新手上手就得靠人肉搜索效率低不说还容易改错。有人可能会担心Order 对象里的规则多了是不是会把 Model 变得很臃肿我的看法是不至于。订单领域本来就有足够多的复杂行为这些行为不放进 Order就会挤在 Service 里本质上还是违反面向对象的设计意图。真正的臃肿是订单对象里同时塞入了 HTTP 依赖、外部 SDK、数据库事务而不是多个业务方法。7. 常见问题与排查技巧实录最后整理几个我在实际开发过程中遇到的真实问题以及对应的排查思路都是踩过坑之后总结的经验。7.1 取消订单时需要事务但对象的判断不能依赖事务怎么办这个问题测试的时候很容易遇到。比如你在 Order 的cancel()里判断$this-status Order::STATUS_PENDING但外部调用方可能在同一个事务里已经把订单状态改成了paid导致 Order 读取到的是内存里的旧数据判断结果不对。解决方法是在进入取消流程前先从数据库重新读取一次最新订单状态再传入 Order 对象。我的做法是在 Service 的cancel()里显式调用$order-refresh()或者用悲观锁lockForUpdate()锁定订单行确保读到的是当前最新状态而不是 Repository 里缓存的旧数据。7.2 重复取消、幂等性问题用户可能双击按钮也可能在超时后重试请求导致同一个订单被连续取消两次。如果不在代码层面做幂等可能出现重复退款、库存多退等严重事故。我建议在Order::cancel()的规则判断里加入状态检查只有处于允许取消状态的订单才能执行取消如果已经是取消状态直接抛出异常或返回一个“已取消”的标识。在此基础上订单表里加一个唯一索引比如order_no cancel_request_id从数据库层面兜底防止并发场景下的重复操作。7.3 并发取消与状态竞态这个问题和幂等有些类似但更容易被忽视。两个请求同时进来到达 Service都读取到订单是待支付状态然后都通过了canCancel()校验一个完成了取消另一个也完成了取消而且都尝试退款这就出大问题了。我的做法是在事务里使用SELECT ... FOR UPDATE锁住订单行。具体到 Laravel 中就是$order Order::where(order_no, $orderNo)-lockForUpdate()-first();锁定之后再执行Order::cancel()和后续操作第二个请求会一直等待第一个事务提交后才能读取此时读到的订单已经是取消状态canCancel()直接返回 false第二个请求就被拦截住了。7.4 外部接口支付退款失败时的处理订单取消了状态也改了但退款接口超时了这时候整个事务要不要回滚这里需要分情况讨论。一种做法是退款失败就抛异常回滚整个事务让订单保持原状态用户下次重试。这样最简单但存在的问题是如果支付网关那边其实已经受理了退款只是回包超时那本地事务回滚后重试可能会导致重复退款。更稳妥的做法是先把订单改为“取消中”或“退款中”状态提交事务然后通过异步任务去处理退款处理完成后回调更新状态。这种做法对系统设计的要求更高但能避免很多重复退款问题。我的实际经验是在中小项目里优先选择“退款成功后再取消订单”的顺序如果退款超时不立即报失败而是记录一张退款任务表由定时任务轮询退款结果最终更新订单状态。这样既保证了业务的一致性又不会因为网络抖动导致用户无法取消。注意不管选哪种方案日志都一定要更详细一些。尤其是退款流水号、请求参数、响应报文这些信息在排查线上问题时能救命。订单取消这种涉及资金的接口宁可日志多打一点也不要事后抓瞎。
