controller 里塞了 800 行业务判断,model 却只会 `return $wpdb->get_results`:我把"贫血模型"喂胖之后,发现 service 根本无事可做
上周重构一个三年前的会员积分插件,打开代码差点窒息——controller 里塞着「查询用户等级→计算折扣→写积分流水→发通知邮件」全套流程,model 层只有裸 SQL,service 文件夹里躺着一个空文件。典型的"controller 过劳死,model 饿得贫血,service 查无此人"。
这次拆完我换了个思路:不是按"代码放哪层"来分,而是按"谁对变化负责"来切。记录一下我的新边界,可能和你惯用的不一样。
一、controller 只干"接线"的活
以前我的 controller 长这样:
public function handle_exchange() {
$user_id = get_current_user_id();
$item_id = absint( $_POST['item_id'] );
// 查库存
$stock = $wpdb->get_var( $wpdb->prepare( ... ) );
if ( $stock < 1 ) wp_send_json_error( '没货了' );
// 查用户积分够不够
$points = get_user_meta( $user_id, 'points', true );
$item = $wpdb->get_row( ... );
if ( $points < $item->price ) wp_send_json_error( '积分不足' );
// 扣积分、减库存、写流水、发邮件...
// 80 行过去了
}
现在 controller 就剩这些:
public function handle_exchange() {
try {
$dto = ExchangeRequest::from_post( $_POST );
$result = $this->exchange_service->execute( $dto );
wp_send_json_success( $result->to_array() );
} catch ( ValidationException $e ) {
wp_send_json_error( $e->getMessage(), 422 );
} catch ( InsufficientPointsException $e ) {
wp_send_json_error( $e->getMessage(), 403 );
}
}
所有"如果...就..."的业务判断全赶出去。controller 只管:接输入→转 DTO→调 service→抓异常→吐格式。HTTP 状态码、JSON 结构、nonce 校验这些"Web 味"的东西留在这,业务味的一点不要。
二、model 不是"数据库皮",是"领域对象"
我之前对 model 的理解就是"封装 $wpdb",结果每个 model 都是 CRUD 工具人。现在让它胖一点,但只胖在"业务规则能内聚"的地方:
class UserPoints {
private int $user_id;
private int $balance;
public function __construct( int $user_id, int $balance ) {
$this->user_id = $user_id;
$this->balance = $balance;
}
public function debit( int $amount, string $reason ): PointsTransaction {
if ( $amount > $this->balance ) {
throw new InsufficientPointsException(
"需 {$amount},仅有 {$this->balance}"
);
}
// 注意:这里不碰数据库,只生成"待执行"的领域事件
return new PointsTransaction( $this->user_id, -$amount, $reason );
}
public function get_balance(): int {
return $this->balance;
}
}
关键点:model 不 save()。它负责"这笔账对不对",不负责"落库"。这样单元测试直接 new 出来就能跑,不用 mock $wpdb。
三、service 是"编排员",不是"搬运工"
以前我以为 service 就是"controller 太长,砍一半放中间"。现在它的职责是用例级别的事务边界:
class ExchangeService {
public function __construct(
private PointsRepository $points_repo,
private StockRepository $stock_repo,
private TransactionRepository $trans_repo,
private EventDispatcher $events
) {}
public function execute( ExchangeRequest $dto ): ExchangeResult {
return $this->trans_repo->transaction( function() use ( $dto ) {
// 1. 加载聚合根
$user_points = $this->points_repo->of_user( $dto->user_id );
$stock = $this->stock_repo->of_item( $dto->item_id );
// 2. 领域对象自己做判断
$stock->reserve( 1 );
$transaction = $user_points->debit( $stock->get_price(), 'exchange' );
// 3. 持久化(只有这里碰数据库)
$this->stock_repo->save( $stock );
$this->points_repo->save( $user_points );
$this->trans_repo->save( $transaction );
// 4. 副作用抛出去异步处理
$this->events->dispatch( new Exchanged( $transaction ) );
return new ExchangeResult( $transaction->get_id(), $user_points->get_balance() );
} );
}
}
service 里的顺序就是业务故事:加载→判断→改状态→保存→发事件。没有 if 嵌套地狱,因为判断全在 model 里。没有直接 SQL,因为仓储接口挡着呢。
四、repository 不是 model,是"持久化翻译官"
我把数据访问拆了一层 repository,model 和数据库表不再 1:1。比如积分余额存在 usermeta,流水存在自定义表,但调用方无感知:
interface PointsRepository {
public function of_user( int $user_id ): UserPoints;
public function save( UserPoints $points ): void;
}
class WpPointsRepository implements PointsRepository {
public function of_user( int $user_id ): UserPoints {
$balance = (int) get_user_meta( $user_id, 'myplugin_points', true );
return new UserPoints( $user_id, $balance );
}
public function save( UserPoints $points ): void {
update_user_meta( $points->get_user_id(), 'myplugin_points', $points->get_balance() );
}
}
哪天要把积分迁到独立表?换个别名 `DbPointsRepository` 实现接口就行,service 一行不改。
五、我踩的坑:别为了"干净"而干净
第一次拆的时候走火入魔,一个简单查询也搞了 DTO→Repository→Service→Controller 四层,同事吐槽"取个配置像过安检"。现在我的原则:
- 纯读取、无业务判断的:controller 直接调 repository,service 跳过
- 只有一行 CRUD 的:model 里放个静态工厂方法,不必强上 repository
- WordPress 钩子回调:如果就是改个 option,别硬套三层,但要在注释里标"此处直接操作,未来若加逻辑需下沉"
分层是手段,不是 KPI。最终看的是"改需求时,我要打开几个文件"。
你们拆的时候有没有遇到过"拆完发现 service 就一行,不如不拆"的情况?后来怎么平衡的?

