插件分层不是"把文件拆开":我如何用"请求生命周期"重新划定 controller/service/model 的权责边界
之前写过一篇 controller 里塞 SQL 的重构经历,那次是"从乱到整齐"。这次想聊更细的一层:整齐之后,怎么让各层真正"各司其职",而不是换个地方堆代码。
先说我的踩坑现场。有个支付回调插件,最初分层是这样:
- Controller:接收网关 POST,做签名验证、参数过滤、状态判断,然后直接调
$wpdb->update - Service:一个
PaymentService::process()方法,接收 7 个参数,内部 switch-case 处理 4 种支付状态 - Model:只有
get_order_by_id(),纯查询
看起来分了,但 controller 里还是有一堆"如果状态是 X 就改库存,如果是 Y 就改积分"的业务分支。service 成了"高级传话筒",model 根本不进写操作。
问题爆发在一次需求变更:同一个支付成功事件,既要改订单,又要触发分销佣金计算,还要发模板消息。我在 controller 里加了 3 个钩子调用,结果单元测试崩了——controller 依赖了 3 个外部模块的钩子注册顺序,换个环境顺序变了,测试就挂。
那次重构让我换了个视角:不是按"代码类型"分层,而是按"请求生命周期"分层。
我的新边界定义
Controller = HTTP 翻译层
只干三件事:拿原始输入、做最基础的格式/安全校验、把 HTTP 语义转成业务语义。然后立刻交给 service。
// 之前:controller 里判断支付状态,决定调哪个 model 方法
// 现在:只负责"把网关数据变成内部能懂的语言"
public function callback() {
$payload = $this->get_gateway_payload();
if ( ! $this->validator->verify_sign( $payload ) ) {
return $this->gateway->response_failed( 'sign_error' );
}
// 翻译:网关状态码 → 内部事件类型
$event = $this->translator->to_internal_event( $payload );
// 进业务层,controller 到此为止
$result = $this->service->handle( $event );
return $this->gateway->response_by_result( $result );
}
关键点:controller 不碰 WP_Error 以外的业务错误,不决定"成功之后该干什么"。
Service = 编排层,不是"实现层"
service 的核心是用例编排:这个业务场景需要哪些领域动作、按什么顺序、失败怎么回滚。具体怎么改数据库,交给 model;具体怎么发通知,交给事件分发。
public function handle( PaymentEvent $event ) {
// 用例:支付成功 = 改订单状态 + 记录流水 + 触发后续业务
return $this->transaction->run( function() use ( $event ) {
$order = $this->order_model->lock_and_get( $event->order_id );
if ( ! $order->can_transition_to( $event->target_status ) ) {
return Result::ignored( 'invalid_transition' );
}
$this->order_model->transition( $order, $event->target_status );
$this->ledger_model->record( $event );
// 后续业务通过事件解耦,service 不直接调佣金/消息模块
$this->dispatcher->emit( new PaymentCompleted( $order ) );
return Result::success( $order );
} );
}
注意 Result 对象:我不用 WP_Error 在 service 层抛来抛去,也不直接抛异常中断流程。成功、忽略、失败都是值,controller 再决定怎么翻译成 HTTP 响应。
Model = 领域状态机,不是"CRUD 包装"
之前 model 只有 get_* 和 update_*,现在我把状态变更规则收进 model:
class OrderModel {
private array $valid_transitions = [
'pending' => [ 'paid', 'cancelled' ],
'paid' => [ 'shipped', 'refunded' ],
// ...
];
public function can_transition_to( Order $order, string $to ): bool {
$from = $order->status;
return in_array( $to, $this->valid_transitions[ $from ] ?? [], true );
}
public function transition( Order $order, string $to ): void {
if ( ! $this->can_transition_to( $order, $to ) ) {
throw new InvalidTransition( "$order->status -> $to" );
}
// 实际更新,带乐观锁版本号检查
$this->persist( $order->with_status( $to ) );
}
}
model 抛异常,因为"状态非法"是程序错误,不是业务分支。service 层的 transaction->run 会捕获并回滚。
一个反直觉的决定:允许"薄 service"
有些场景确实简单,比如后台列表页的"切换状态"按钮。这种时候我宁可让 service 只有两行:
public function toggle( int $id ): Result {
return $this->item_model->toggle_status( $id );
}
也不把 controller 直接连到 model。因为"要不要加前置检查、要不要记操作日志"这类决策,是可能变的。留一层 service,改动范围可控。
WordPress 特化的坑
分层在 WordPress 里有个天然张力:钩子是全局的,很容易"跨层穿透"。我的做法是:
- controller 层不直接挂
add_action,只暴露register_routes()这类显式方法 - service 层用内部事件对象,只在最外层插件启动文件里做"事件 → WordPress 钩子"的桥接
- model 层完全不感知 WordPress,方便抽出去做纯单元测试
// 插件入口文件:唯一的"钩子胶水层"
add_action( 'my_plugin_payment_completed', function( $order ) {
// 这里才触达 WordPress 生态的其他插件
do_action( 'my_plugin_commission_calculate', $order );
do_action( 'my_plugin_notification_send', $order );
} );
现在我的目录结构
src/
Http/
Controllers/ # 只处理 HTTP 输入输出
Middleware/ # 签名验证、速率限制
Application/
Services/ # 用例编排,允许薄
DTOs/ # Request/Response 之外,还有 Event/Command
Domain/
Models/ # 状态机、业务规则
Repositories/ # 接口,WP 实现在 Infrastructure
Events/ # 纯对象,无框架依赖
Infrastructure/
WordPress/
WpOptionRepository.php
WpHookDispatcher.php
Database/
PdoTransaction.php
不是最简方案,但解决了我的核心痛点:测试时能绕过 WordPress 全局状态,运行时又能扎进 WordPress 生态。
你们分层时有没有遇到过"看起来拆了,实际没拆"的幻觉?或者 WordPress 钩子和分层架构打架的经历?