接手过不少“号称用了MVC”的项目,框架是Spring MVC、ASP.NET MVC或者自己搭的路由,目录结构也分了Controller、Service、Model,看起来规规矩矩。但一打开代码,Controller里塞满了业务判断,Service类动辄上千行,Model直接变成了只有getter和setter的“数据袋子”,真正的业务逻辑散落在各个方法里,改一个规则要翻好几个文件。这种项目表面是OOP MVC,实际上已经退化成了“三层大泥球”。这句“OOP MVC(Model负责数据和业务逻辑)”听起来像是教科书里的一句话,但真把它落到代码里的人很少。这篇文章我想把这件事讲透:Model到底是干什么的、为什么业务逻辑应该放在Model里、什么时候你可以不放、以及怎么把已经写烂的代码一步步救回来。适合被Controller和Service层折磨过的后端开发者看,也适合刚接触分层架构、想搞清楚“MVC到底要怎么分”的新手。
1. 先搞清楚:Controller、View、Model这三层分别该干什么
很多人以为MVC就是把代码按Controller、View、Model三个文件夹放好,Controller接收请求、View显示页面、Model操作数据库,完事。这个理解不算错,但漏掉了最关键的分工逻辑。
1.1 Controller的“瘦”是被MVC的结构逼出来的
Controller的本职工作是:接收用户输入、解析请求参数、调用业务处理、把结果交给View去展示。它像一个前台接待员,客人来了登记一下、领到对应的工位,至于工作怎么做,接待员不参与。但实际项目里Controller经常变成“什么都干”的万能层——校验参数、判断权限、计算价格、更新状态、拼接返回数据全堆在里面。一旦业务规则变了,比如满减门槛从100改成200,你得去Controller里翻那个if条件,找到还不一定找得全。
正确做法是Controller只做三件事:
- 把HTTP请求里的参数解析成可用的对象或基本类型
- 调用真正干活的业务入口(可以是一个领域服务,也可以是Model自身的方法)
- 拿到结果后决定渲染哪个View或者返回什么JSON
Controller变瘦之后,最直接的好处是路由层面的逻辑可以单独测试,不依赖业务规则的状态。你想验证某个URL返回什么格式、参数绑定对不对,直接跑接口测试就行,不需要关心背后的价格算法是满100减10还是满200减30。
1.2 View的职责是展示,不是计算
View负责把数据呈现给用户。在Web项目里就是HTML模板,在桌面应用里就是界面控件,在移动端就是页面布局。很多人忽略的一点是:View里不该出现业务规则。像“如果订单金额大于100就显示包邮标签”这种判断,表面上是个展示逻辑,实际上已经涉及定价规则了。将来包邮门槛变成150,除了Controller要改,模板也得跟着改,同一个规则拆在两处,漏改一处就出bug。
展示层能做的事情只有两件:读数据,按条件显示。如果你发现自己不得不在模板里写价格计算、状态拼接、时间格式转换以外的复杂判断,说明后端给View的数据结构设计得不够友好——这时候应该去调整Controller组装的数据结构,而不是往模板里堆逻辑。
1.3 Model在经典MVC里的真实位置
回到MVC最初的Smalltalk定义:Model是“业务对象”和“业务规则”的集合,它不关心自己怎么被显示,也不关心用户怎么操作,它只忠实地表达“业务是什么”。订单就是订单,订单有金额、有状态、能确认、能取消、能计算总价——这些“能做什么”和“怎么算”都是Model自己的事。
后来Web开发流行起来,三层架构(表现层、业务层、数据访问层)和MVC经常被混在一起讲,Model的位置就被挤变了形。很多人把Model理解成了“数据库表的映射”——一个类对应一张表,属性对应字段,仅此而已。这种Model严格来说叫“数据实体”,它承担不了业务逻辑,一有规则就只能往上抛给Service或者Controller。
经典MVC里Model是核心,Controller和View都围绕Model转。Controller修改Model,View观察Model的变化来刷新显示。放到现在的Web开发里,虽然不需要严格实现观察者模式,但分层思想是通用的:业务逻辑应该离数据最近,而不是离HTTP请求最近。数据在哪,规则就该在哪,这既是OOP的封装要求,也是MVC给Model定下的原始职责。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Model的双重身份:既是数据载体,又是业务规则的“家”
标题里“Model负责数据和业务逻辑”不是并列的两件事,而是一件事——数据承载业务状态,业务逻辑保护这个状态的变化过程。把这两者拆开,就会产生最常见的贫血模型问题。
2.1 贫血模型到底哪里不好
贫血模型(Anemic Model)指的是类里只有属性和getter/setter,没有行为。订单类是贫血的,因为它只有一个Order对象、几个属性、无数get/set方法,金额计算在外面、状态流转在外面、能否取消的判断也在外面。这个概念由Martin Fowler提出,他认为这是一种“反模式”。
贫血模型的坏处不是“代码不能跑”,而是规则散了、边界没了。举个真实例子:
php复制// 贫血模型:Order只是一个数据袋子
class Order {
public string $id;
public string $status; // pending / paid / shipped / cancelled
public float $totalAmount;
public float $paidAmount;
}
php复制// 业务逻辑散落在Service里
class OrderService {
public function cancel(Order $order): void {
if ($order->status === 'paid') {
// 已支付订单要退款
$this->refund($order->paidAmount);
$order->status = 'cancelled';
} else if ($order->status === 'pending') {
$order->status = 'cancelled';
} else {
throw new Exception('当前状态不能取消');
}
}
}
这段代码单看没毛病。问题是项目里还有另一个地方也可能需要“取消订单”——比如后台管理员操作、定时任务超时未支付自动取消、用户端操作。每多一个来源,你就多一份if判断。改规则的时候,比如“已支付的订单必须审核后才能取消”,你得满项目找哪些地方写了取消逻辑,漏一个就是线上事故。
充血模型(Rich Model)把行为放回Model里,业务规则只有一个家:
php复制class Order {
public string $id;
public string $status;
public float $totalAmount;
public float $paidAmount;
public function cancel(): void {
if ($this->status === 'paid' || $this->status === 'pending') {
if ($this->status === 'paid') {
$this->refund();
}
$this->status = 'cancelled';
} else {
throw new Exception('当前状态不能取消');
}
}
private function refund(): void {
// 触发退款逻辑
}
}
所有调用方都走$order->cancel(),规则只维护这一份,不可能出现两个地方不一致。这才是“业务逻辑放在Model里”最直接的价值。
2.2 业务不变量:这是“为什么放在Model里”的底层逻辑
OOP设计里有句话叫“对象保证自己的不变量”。翻译成人话:一个订单对象从创建到销毁,任何时刻都应该处于合法状态。已取消的订单不能再支付、已发货的订单不能再取消、退款金额不能超过实付金额——这些约束不应该靠外部代码“自觉遵守”,而应该由Model自己强制保证。
如果Order类只暴露getter/setter,外部代码可以直接$order->status = 'shipped'把一个取消状态的订单改成已发货,没有任何人能拦住。这种代码跑久了,数据就开始“脏”。而把状态变更封装成方法,方法内部做校验,就能保证无论谁来调用,非法状态转换都不可能发生。这就是封装的意义,也是标题里“Model负责业务逻辑”的真正含义——业务逻辑是Model的防守线,守住这条线,数据才安全。
2.3 “数据行为不分家”怎么理解
我在给团队讲这个点的时候,喜欢打一个比方:**人这个对象,有身高体重(数据),也有走路跑步(行为)。如果只给你一份写满身高体重的简历,你说不出一米八的人能不能跑完马拉松——你得让他实际跑一次才知道。Model也是这样,光有一堆属性,你不知道这个对象能干什么、不能干什么,所有判断都得外部代码去猜。**把行为放回数据旁边,这个对象才“活”过来,才有了自治能力。
这不只是面向对象的审美问题,是可维护性的实际问题。数据在哪,行为的入口就在哪,新来的人看代码时,打开Order类就能知道订单能干什么、不能干什么,不用在Service里翻半天。
3. 一个订单系统的完整拆解:Model怎么承载核心业务逻辑
理论说再多,不如一个完整的例子有说服力。我用一个电商订单来演示“Model负责数据和业务逻辑”是怎么落地的。
3.1 需求里哪些逻辑属于Model,哪些属于外部服务
假设订单相关需求是这样的:
- 订单包含商品明细、数量、单价、优惠、运费
- 待支付订单可以取消
- 支付成功后订单状态变为已支付
- 已支付订单可以申请发货
- 发货后订单完成
- 撤销订单要校验是否在可撤销时间内
按“职责就近”原则来分:
| 业务逻辑 | 归属 | 理由 |
|---|---|---|
| 计算订单总价 | Order模型 | 只有订单自己知道所有明细和优惠 |
| 状态流转判断(能否取消、能否支付) | Order模型 | 状态是订单的属性,状态的合法性由订单自身保证 |
| 保存到数据库 | Repository/DAO | 这是数据访问技术,不是业务规则 |
| 扣减库存 | 领域服务(或通过模型方法传入依赖) | 涉及订单之外的其他聚合,需要协调多个对象 |
| 发送通知 | 领域服务 / 事件监听 | 订单本身不关心消息怎么发 |
这里有一个关键点:**并非所有业务逻辑都必须物理放进Model里,而是要求业务规则的决策点靠近数据。**库存扣减这种跨对象的操作,必然需要一个服务来协调,但“能不能扣、扣多少”的规则,依然可以委托给库存模型自己判断。
3.2 充血订单模型的代码长什么样
php复制class Order {
public function __construct(
public readonly string $id,
public readonly string $userId,
public readonly array $items, // 商品明细列表
private string $status = 'pending',
private ?string $paidAt = null,
public readonly float $discount = 0.0,
public readonly float $shippingFee = 0.0,
) {}
/** 订单商品原价合计 */
public function subtotal(): float {
return array_reduce(
$this->items,
fn(float $carry, OrderItem $item) => $carry + $item->quantity * $item->unitPrice,
0.0
);
}
/** 应付金额 = 原价 - 优惠 + 运费 */
public function totalAmount(): float {
return max(0, $this->subtotal() - $this->discount + $this->shippingFee);
}
/** 是否已支付 */
public function isPaid(): bool {
return $this->status === 'paid';
}
/** 能否取消 */
public function canBeCancelled(): bool {
return in_array($this->status, ['pending', 'paid'], true);
}
/** 取消订单:只有待支付和已支付状态可以取消 */
public function cancel(): void {
if (!$this->canBeCancelled()) {
throw new DomainException('当前状态不允许取消');
}
if ($this->isPaid()) {
// 已支付的订单取消后需要退款
$this->status = 'refunding';
return;
}
$this->status = 'cancelled';
}
/** 支付成功回调 */
public function markAsPaid(DateTimeInterface $paidAt): void {
if ($this->status !== 'pending') {
throw new DomainException('只有待支付订单可以确认支付');
}
$this->status = 'paid';
$this->paidAt = $paidAt;
}
}
注意几个设计细节:
- 所有状态变更方法都有前置校验,非法流转直接抛异常,让错误尽早暴露。
subtotal()、totalAmount()是计算逻辑,放在Model里,而不是每个Controller调用处各算一遍。- 外部依赖(数据库、消息队列)都没有直接出现在Model里,Model保持纯净,测试时不需要mock数据库。
3.3 需要外部协作的逻辑怎么处理
订单创建时可能要扣减库存,这是典型的跨对象业务。我推荐的做法是把外部依赖作为方法参数传入,而不是直接在Model里new一个仓储:
php复制class Order {
public function confirm(InventoryGateway $inventory): void {
if ($this->status !== 'draft') {
throw new DomainException('订单不是草稿状态');
}
foreach ($this->items as $item) {
$inventory->deduct($item->sku, $item->quantity);
}
$this->status = 'confirmed';
}
}
这样Model依然不依赖具体实现,调用方传入的是接口,测试时可以传入mock对象。业务规则(状态校验、库存扣减的决策)留在Model里,技术细节(怎么连数据库、怎么发送消息)留在外部实现——“业务逻辑在Model”和“技术依赖不进Model”完全不矛盾。
4. 为什么业务逻辑总是写着写着就跑到Service里去了
理论说得再清楚,实际项目里还是会跑偏。我总结过几个高频原因,每个都是真实的坑,踩过一次就记住了。
4.1 框架先入为主,把“Action/Service”当成了业务逻辑的默认位置
Spring MVC和ASP.NET MVC都允许你定义一个Service层或者Manager层,很多人自然而然地觉得“Service就是写业务逻辑的地方”。框架给你的类不叫BusinessLogic,叫Service,你就把所有东西都往里面塞——这是框架设计在暗示你,但MVC的原始语义里,业务逻辑的归属是Model。
我的建议是:**Service类用来做“应用服务”层面的协调,比如事务边界、调用多个模型、编排外部接口,而不是用来写模型自身该有的规则判断。**如果Service里的方法总是以if(订单状态==xxx)开头,且这些逻辑只操作单个模型的状态,那它十有八九应该搬进Model。
4.2 图省事,不愿意重新打开Model去加方法
很多人写代码的习惯是:先写了Order类,后来又有了新需求,“订单取消要加一个限制条件”,打开OrderService找到cancel方法,顺手加一条if。改完测试跑了,就完了。很少有人会停下来想:这个条件是不是应该放到Order类的cancel方法里?
这种“顺手”就是业务逻辑外溢的起点。一次两次看不出问题,日积月累,Order类永远是空壳,Service越来越胖。到最后Service里几百个业务方法,每个方法都依赖Order的getter,谁也理不清。
4.3 对测试的误解让人不敢把逻辑放Model
有的团队不敢把业务逻辑放进Model,是因为觉得Model连接数据库,测试起来麻烦。这其实是把“数据实体”和“领域模型”搞混了。充血模型的Order类如果是纯净的(不依赖ORM、不依赖数据库连接),那它就是最好测的类——直接new一个Order,调用方法,断言状态和返回值,不需要任何mock数据库的操作。
反而不把逻辑放Model的代码最难测:你想测“取消订单”这个规则,得先准备一个Service实例,再准备一个Order,可能还得mock仓储层、mock配置项,光测试环境搭建就够烦了。
4.4 判断业务逻辑放对了没有:三句自问自答
我在code review的时候,经常让同事做三个检查:
- 这个规则如果要改,你需要动几个文件?理想答案是1个(对应的Model方法),最多2个(Model+调用该方法的领域服务)。
- 如果有人绕过Service直接操作Model,逻辑会被绕过吗?如果会,说明规则没放到Model里。
- 写单元测试的时候,是测Model方法直观,还是测Service方法直观?如果测Model方法需要mock一堆东西,说明Model不纯净,依赖太多。
这三个问题能快速定位逻辑摆放是否合理。想改规则要动三四个文件、绕过Service就能把状态改得乱七八糟、测一个兜底规则要准备整个环境——不用犹豫,逻辑放错地方了。
5. 贫血模型不是十恶不赦:什么时候该“充血”,什么时候没必要
看到这里,有人可能会觉得:好,那我就把所有Model都改成充血模型,把所有逻辑都塞进去。先别急,现实项目里不是所有Model都必须充血,有时候贫血反而是更合适的方案。
5.1 这样的场景,贫血模型完全够用
如果你的Model纯粹是数据的传输载体,比如:
- 数据库查询结果直接映射成对象,供给报表页面展示
- 第三方API返回的DTO,直接透传给前端
- 配置项、字典、枚举等元数据
- 简单的CRUD后台,没有复杂状态流转和业务规则
这些场景里,Model就是数据的容器,没有行为需要挂载。硬要给它塞业务逻辑,反而显得不伦不类——一个报表查询对象,你怎么给它定义“业务规则”?
5.2 什么样的情况必须充血
判断标准不是“类能不能写方法”,而是业务规则是否存在于这个对象本身的生命周期中。订单、支付单、库存、账户、会员——这类有明确状态流转、有金额计算、有强约束条件的核心业务对象,几乎都应该充血。它们的规则如果放到外面,就会像前面说的,散落一地。
我总结过一张决策表,可以拿来参考:
| 判断维度 | 适合贫血模型 | 适合充血模型 |
|---|---|---|
| 状态值 | 只有1个状态,没有流转 | 多个状态,流转规则复杂 |
| 业务规则 | 规则简单,几乎没有约束 | 有强不变量,规则复杂且集中 |
| 使用方式 | 只读展示,不改动 | 会被业务频繁修改状态 |
| 生命周期 | 短暂,用完即弃 | 贯穿多个业务用例,需要保持一致性 |
很多团队喜欢“全贫血”或者“全充血”,我的经验是混合制:核心业务对象充血,辅助性的DTO、查询对象贫血,这才是工程上最舒服的形态。
5.3 “框架的Model”和“领域模型的Model”是两回事
Spring MVC里有个Model接口,ASP.NET MVC里Model是绑定的视图模型,各种框架都爱把名字叫Model,但这和我们在说的“承载业务逻辑的领域模型”不是同一个东西。
- 框架的Model:请求参数的载体,解决的是“HTTP数据怎么传给后端”的问题,它天生贫血,这是对的。
- 领域模型的Model:业务逻辑的归属,解决的是“规则代码放哪里”的问题,它应该充血。
混用这两个概念,就会犯一个典型错误:拿着框架的Model去套业务,结果发现它只是个数据袋子,于是得出结论“Model就是数据袋子”,然后把业务逻辑全堆到Service。**框架的名字叫Model,不代表它接替了领域模型的职责。**搞清楚这一层,很多困惑都会消解。
6. 实际项目里怎么把“贫血Model”一步步改造成“充血Model”
如果你现在面对的是一个已经写了两三年的老项目,Model全是getter/setter,Service全是业务逻辑,先别急着推翻重写。大重构失败率极高,但渐进式重构是可行的。我实操过几次,下面这套步骤比较稳。
6.1 第一步:找一个“高价值低风险”的核心实体下手
不要试图一次把所有Model都改造完。先挑一个业务规则最集中、改动收益最高的实体,通常是订单、用户、账户这类。对我来说,订单最合适,因为它的状态流转规则明确,Service里散落的逻辑最多。选定一个实体,范围小、目标清晰,风险也可控。
6.2 第二步:把散落的规则整理成清单
打开IDE搜索所有对这个实体做状态变更的代码,整理成一份规则清单。比如“订单取消”这个动作,可能分布在:
- 用户端取消订单的Service方法
- 定时任务超时自动取消的方法
- 后台管理员强制取消的方法
每个方法里都有if ($order->status === 'pending')这样的判断。把这些规则统一整理成一条:“待支付/已支付的订单可以取消;已支付订单取消需要触发退款;其他状态不可取消。”这就是要搬进Model的业务规则。
6.3 第三步:给Model加方法,把逻辑逐步搬进去
在Order类里新增cancel()方法,把整理好的规则放进去。然后修改调用方,把原来Service里的一大段if判断替换成$order->cancel()。
不要一次性改完所有调用方。每改一个调用方,就跑一遍该功能的测试,确保行为没有变化。这个阶段的核心原则是:先有规则清单,再改实现,最后逐个切换调用方。
6.4 第四步:给Model方法补测试
逻辑搬进Model之后,立刻补单元测试。测试的维度是:
- 正常状态流转:pending → cancelled
- 边界状态:paid → refunding(已支付取消)
- 非法状态:shipped → cancelled(发货后不能取消)
- 金额计算:含优惠、含运费、优惠大于总额的情况
Mock外部依赖(比如退款网关)之后,测试这些场景非常快。有了这层测试保护,后面再改状态流转规则,心里就有底了。
6.5 第五步:检查并处理“方法需要外部依赖”的情况
搬逻辑的过程中一定会遇到“这个方法需要查数据库”“需要调用外部接口”的情况。我的处理顺序是:
- 能把外部依赖作为参数传入的就作为参数传入(前面说的
confirm(InventoryGateway $inventory)就是这样)。 - 依赖太宽泛、参数太多的,就把这个逻辑留在领域服务里,但让服务调用Model里的决策方法。比如库存不足的判断可以封装成
Stock::canDeduct($sku, $quantity),服务只负责拿着结果去做技术操作。
原则是:决策逻辑尽量进Model,技术执行可以留外面。
6.6 第六步:反复迭代,每次只动一个实体
完成一个实体的改造后,写个简短记录,下次继续处理下一个实体。整个过程可能持续几周,但每次改动范围小、回归风险低,不会影响线上稳定。这样分批重构完,Service会明显瘦下来,Controller更薄,去找业务规则时打开对应的Model类就能看到全貌。
7. 落地过程中的几个常见卡点
最后补充几个我在实际改造中经常遇到的卡点,没有统一的教科书答案,只有一些管用的经验。
7.1 Model里要不要用ORM实体
如果你的ORM实体和领域模型是同一个类,改造时最大的麻烦是ORM会强制你加一些和业务无关的东西(比如必须有无参构造函数、允许setter被外部调用)。我的做法是:能不分开就不分开,实在被迫分开就用“转换器”在两个对象之间做映射,不要让ORM的约束污染领域逻辑。
什么时候必须分?比如ORM实体要求公开无参构造,而领域模型要求创建时必须传入必要字段、保证状态合法,这两个模型关注点不同,分开更好。代价是代码量增加,但换来的是业务逻辑的纯粹性。
7.2 团队习惯怎么扭过来
光改代码不改习惯,重构完还会长回去。我在团队里推行过一个很简单的规则:**代码review时,看到Service方法里对某个对象的状态做了三层以上if判断,就停下来问一句——这段规则是不是应该放到那个对象里面去?**用提问代替命令,反而更容易让大家接受。慢慢地,团队就形成了“先想逻辑属于谁,再动手写代码”的共识。
7.3 什么时候该放弃改造
不是所有代码都值得重构。如果项目即将重写、代码已经烂到无法维护、或者测试覆盖率为零且改动风险巨大,那不如存着力气干票大的。如果项目还处于活跃开发期,高频迭代,那么渐进式改造就是性价比极高的技术投资,每改造一个实体,后续改需求的成本就降低一分。
拿我自己的经历来说,改造完订单系统之后,后来产品提“支付成功后24小时内可以取消”的需求,我只需要在Order类里加一个判断条件,跑一遍单测,改完直接上线。放在改造前,这个需求要动四五个Service方法,还得担心哪里漏改了。这就是“Model负责数据和业务逻辑”这句话真实的回报——前期的架构投入,最终都通过后期的改需求效率还回来了。
