「设计模式与范式」系列 Day04
写在前面
业务开发天天在写的 Controller-Service-DAO 三层架构,是绝大多数人入行第一天就学会的标准套路。但如果我告诉你,这套写法在很多人眼里是反模式(anti-pattern),是彻头彻尾的面向过程编程,你是不是要愣一下------这不是最正统的 Java Web 开发方式吗?这篇就用一个账户扣款的真实需求,把这件事的来龙去脉讲清楚:到底哪里"面向过程"了,充血模型和 DDD 又是怎么解决这个问题的。
一、是什么:贫血模型和充血模型的核心区别
MVC 三层架构里,每一层对应的实体类(VO/BO/DO)通常只是一组字段加 getter/setter,本身不包含任何业务逻辑,这就是贫血模型(Anemic Domain Model) ------数据和操作数据的逻辑是分离的,逻辑全部堆在 Service 里。充血模型正好相反:数据和操作这些数据的业务逻辑被封装到同一个类里,这个类自己就知道该怎么正确地改变自己。
| 贫血模型 | 充血模型 | |
|---|---|---|
| 数据和逻辑的关系 | 分离:类只有字段,逻辑在 Service | 合一:类里既有字段也有业务方法 |
| 是否满足封装 | 不满足,字段随便被外部读写 | 满足,只能通过业务方法修改自身状态 |
| 编程风格 | 面向过程 | 面向对象 |
判断标准很简单:一个类的字段能不能被外部随意 set,还是必须通过一个有业务含义的方法(比如 debit())才能改------前者是贫血,后者是充血。
二、为什么:贫血模型为什么这么流行,DDD 又想解决什么
面向对象的封装,本意是让数据的读写规则由数据自己的类说了算,外部不能绕过这层规则乱改。贫血模型把数据结构和操作它的逻辑拆开放在两个类里,相当于放弃了这层约束------任何拿到这个对象的代码都能直接改字段,改成什么值都不会有人拦。这也是它被扣上"反模式"帽子的原因:形式上还是类和对象,本质上是面向过程那一套"数据结构 + 一堆函数"。
那为什么这么"不正宗"的写法反而成了业界标准?几个很现实的原因:
- 大部分业务系统本来就简单:一个后台管理系统的 CRUD 接口,逻辑就是查库、拼数据、存库,压根没有复杂的业务规则需要封装,花精力设计充血模型纯属浪费。
- 充血模型的设计门槛更高:贫血模型写的时候不用先想清楚"这个类到底该对外暴露哪些操作",改完字段直接存库就行;充血模型要求你在动手前就想明白这个领域对象的业务规则和方法边界,这一步很多人跳不过去。
- 思维惯性:大部分人学的第一套架构就是贫血 MVC,转型到充血/DDD 有真实的学习成本。
**领域驱动设计(DDD)**就是在这个背景下被重新提起的------它本质上是一套指导"如何解耦业务、划分领域模型"的方法论,微服务的流行又反过来加速了它的普及,因为微服务拆分本身也是在按业务边界切分系统。落到代码层面,DDD 风格的实现依然是三层架构,真正的区别只在 Service 层:贫血模型的 Service 层只有 Service 类,充血模型的 Service 层拆成了"很薄的 Service 类 + 很厚的 Domain 类",Domain 对应的就是贫血模型里的 BO,只不过它把业务规则也一起封装了进去。
三、怎么用:一次账户扣款需求的设计走查
拿一个几乎所有系统都会遇到的需求练手:账户余额扣款------扣款前要校验余额是否充足,扣款后要记一笔流水。
贫血版:逻辑全堆在 Service 里
java
// AccountDO:纯数据,没有任何业务方法
public class AccountDO {
private Long id;
private BigDecimal balance;
// getter/setter 略,balance 可以被外部随便 set 成任意值
}
public class AccountService {
public void debit(Long accountId, BigDecimal amount) {
AccountDO account = accountMapper.selectById(accountId);
if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientBalanceException();
}
account.setBalance(account.getBalance().subtract(amount)); // 谁都能这么改
accountMapper.updateById(account);
flowMapper.insert(new FlowDO(accountId, amount, "DEBIT"));
}
}
问题就出在 setBalance:它是 public 的,意味着任何一段代码,不管有没有先做余额校验,都能把 balance 改成任意值甚至负数。校验逻辑只是 Service 里的一段代码,不是这个数据结构必须遵守的规则。
充血版:业务规则焊在 Domain 上
java
// Account:数据和业务规则封装在一起
public class Account {
private final Long id;
private BigDecimal balance;
public void debit(BigDecimal amount) {
if (balance.compareTo(amount) < 0) {
throw new InsufficientBalanceException();
}
balance = balance.subtract(amount);
}
// 没有 setBalance,balance 只能通过 debit/credit 这类业务方法改变
}
public class AccountService {
@Transactional
public void debit(Long accountId, BigDecimal amount) {
Account account = accountRepository.find(accountId);
account.debit(amount); // 业务规则在 Domain 内部自我校验
accountRepository.save(account);
flowService.record(accountId, amount, "DEBIT"); // 跨领域协作交给 Service
}
}
Account 没有 setBalance,唯一能改变余额的入口是 debit(),而这个方法自己就保证了"余额不能扣成负数"这条规则------不管谁调用、从哪里调用,这条规则都不可能被绕过。这才是封装本来该有的样子。
两种分层方式对比:
Service 变薄之后,剩下的职责是什么
业务规则搬进 Domain 之后,Service 并没有被彻底删掉,它还留着三类活:
- 和 Repository 打交道:不让 Domain 直接依赖持久化框架,保持领域模型的独立性和可复用性,这一步的解耦让 Domain 可以脱离具体的存储实现被复用。
- 跨领域的业务编排 :比如上面例子里扣款之后要记流水,
Account和"流水"是两个领域,把它们串起来是 Service 的活,不该塞进Account里。 - 非功能性和第三方交互:事务控制、幂等校验、发通知邮件这类和核心业务规则无关的事,同样留在 Service。
常见的坑:是不是每一层都要跟着充血
不是。这次改造里,Controller 和 Repository 两层依然是贫血的,而且没有必要跟着充血------Controller 用的 VO 只是一个纯粹的 DTO,Repository 操作的 DO 生命周期很短(一次查询/更新就结束),这两层本身承载的逻辑都很薄,业务复杂度全部集中在 Service/Domain 这一层,硬要给 VO、DO 也加上业务方法只是徒增复杂度。
什么系统值得做这次改造
贫血模型适合逻辑简单、SQL 驱动的 CRUD 系统------接口需要什么数据就写一条对应的 SQL,业务逻辑包在 SQL 里,简单直接。但系统一旦复杂起来,这种"每个接口配一条专用 SQL"的写法复用性很差,代码会越堆越乱;充血 DDD 需要前期投入大量精力做业务调研、梳理领域模型,换来的是这层领域模型可以被多个场景复用、易维护------系统越复杂,前期在领域建模上多花的时间就越值得。简单系统上 DDD,多半是过度设计。
四、面试追问
Q1:为什么说基于贫血模型的传统 MVC 开发模式是反模式?
因为它把数据结构(BO/DO)和操作这些数据的业务逻辑(Service)拆成了两个东西,破坏了面向对象的封装特性------数据本身不再对自己的合法状态负责,任何代码都能绕过业务规则直接改字段。这本质上是"数据结构 + 函数"的面向过程编程风格,只是恰好也用 class 来组织代码。
Q2:贫血模型这么"不正宗",为什么还是业界主流?
一是大部分业务系统本身足够简单,就是 CRUD,没有复杂业务规则需要封装;二是充血模型设计门槛更高,要求写代码前先想清楚这个领域对象该暴露哪些操作,很多人和团队跳不过这道槛;三是思维惯性,大部分人入行学的第一套架构就是贫血 MVC,转型有真实成本。
Q3:充血模型下,Service 变薄了,那它还负责什么?
三类事:和 Repository 交互,保持 Domain 不依赖具体持久化实现;跨领域模型的业务编排,比如扣款之后联动记流水;以及事务、幂等、发通知这类非功能性和第三方系统交互的工作。核心业务规则本身则交给 Domain 自己封装。
Q4:既然提倡充血模型,为什么 Controller 和 Repository 层还保持贫血,不用也改造?
因为这两层的复杂度和贫血模型时相比基本没变化:Controller 的 VO 只是一个纯粹的 DTO,Repository 的 DO 生命周期只在一次查询/更新之内,两层本身都不承载复杂业务逻辑。业务复杂度是集中在 Service/Domain 这一层的,只有这一层才值得做充血改造。
Q5:什么样的项目应该考虑上充血模型的 DDD,什么样的不应该?
逻辑简单、SQL 驱动的 CRUD 系统适合继续用贫血模型,简单直接,没必要为了"正统"强行做领域建模。业务规则复杂、需要长期演进和复用的系统更适合 DDD------因为它换来的是一个可复用、易维护的领域模型层,前期在业务调研和建模上多投入的时间是值得的;简单系统硬套 DDD,只会是一次不划算的过度设计。
下一篇预告
Day05 面对复杂系统的开发如何思考:设计原则到底解决了什么问题。