分层写到"贫血"与"充血"之间:我用"命令对象"模式把 controller 从"参数搬运工"里救出来的实践
之前按常规 MVC 拆完,controller 里还是挤满了 $request->get_param('foo') → validate_foo() → service->do_something($foo, $bar, $baz) 的流水线代码。十几个参数一多,改个字段名要动三四个文件,service 方法签名膨胀得像气球。
后来试了下把"用户意图"封装成命令对象,结构突然清爽了。核心就一层:
// 不再传散参,传一个"意图"
class Create_Reward_Command {
public int $user_id;
public string $reward_type;
public ?array $meta = null;
// 校验内聚在这里,不是 controller 也不是 service
public function validate(): Validation_Result {
$rules = [
'reward_type' => in_array($this->reward_type, ['daily', 'event'], true),
'meta' => $this->reward_type === 'event'
? !empty($this->meta['event_id'])
: true,
];
return new Validation_Result($rules);
}
}
controller 缩到只剩骨架:
public function handle( WP_REST_Request $request ): WP_REST_Response {
$cmd = Create_Reward_Command::from_request( $request ); // 工厂方法做类型转换
if ( ! $cmd->validate()->passes() ) {
return new WP_REST_Response( $cmd->validate()->errors(), 400 );
}
// 只认命令对象,不认原始请求
$result = $this->reward_service->execute( $cmd );
return new WP_REST_Response( $result->to_array(), 201 );
}
service 层的变化更关键。以前它是"参数解析器+业务逻辑+事务协调"三合一,现在只干编排:
public function execute( Create_Reward_Command $cmd ): Reward_Result {
// 防腐:命令进来时已经是"已校验的合法意图"
// 这里只关心"怎么完成",不关心"参数对不对"
return $this->transactional( function() use ( $cmd ) {
$reward = $this->factory->create( $cmd->to_spec() );
$this->event_bus->dispatch( new Reward_Created( $reward ) );
return Reward_Result::from_entity( $reward );
});
}
model 层我刻意做了"反向依赖"——命令对象不直接引用 model,而是 model 提供 from_spec(Reward_Spec $spec) 的工厂入口。这样命令对象活在"应用层",model 活在"领域层",编译期(好吧,PHP 没有,但 IDE 能检查)就能拦住跨层引用。
踩过的一个坑:一开始把 WP_REST_Request 的解析逻辑塞进了命令对象的 __construct,结果写单元测试时要 mock 整个请求对象。后来拆成 from_request 工厂 + 纯数组构造器,测试时直接 new Create_Reward_Command(['user_id' => 1, ...]) 就行,和 HTTP 层彻底脱钩。
另一个意外收获是日志。命令对象天然带"可序列化上下文",异常时直接 json_encode($cmd) 丢进日志,比从前在 controller 里手动组装 ['action' => 'create_reward', 'params' => [...]] 省事得多,也杜绝了"日志里的字段名和实际参数对不上"的慢性 bug。
现在我的目录结构长这样,供参考:
src/
Commands/ # 意图 + 校验 + 工厂
Create_Reward_Command.php
Services/ # 只接收 Command,返回 Result
Reward_Service.php
Models/ # 纯领域逻辑,不知 HTTP 为何物
Reward.php
Results/ # 显式的输出契约
Reward_Result.php
还在纠结的是"查询"怎么弄。命令模式写操作很顺,但列表页那种"N 个筛选条件组合 + 分页 + 排序"的场景,再套命令对象有点重。目前折中方案是单独一个 Queries/ 目录,用"查询对象"而不是命令对象,允许更灵活的链式组装。这块还没跑通大流量场景,有实践过的可以聊聊。

