重构与DDD:用领域驱动设计重构贫血模型
说实话,我见过太多项目一开始跑得飞快,半年后改个需求要三天。为什么?因为代码里全是贫血模型——对象只有getter/setter,业务逻辑散落在Service里。你想想看,这跟用Excel管理业务有啥区别?
今天我们就聊聊怎么用领域驱动设计(DDD)把贫血模型救回来。这不是什么高大上的理论,而是我踩过坑之后总结出的实战套路。
贫血模型到底“贫”在哪?
先看一段典型的贫血模型代码。嗯,我敢打赌你肯定见过:
// 贫血模型:Order只是个数据容器
public class Order {
private Long id;
private String status;
private List<OrderItem> items;
private BigDecimal totalAmount;
// 只有getter/setter,没有业务方法
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
// ... 省略其他getter/setter
}
// 业务逻辑全在Service里
public class OrderService {
public void submitOrder(Order order) {
// 校验订单项
if (order.getItems() == null || order.getItems().isEmpty()) {
throw new IllegalArgumentException("订单项不能为空");
}
// 计算总金额
BigDecimal total = BigDecimal.ZERO;
for (OrderItem item : order.getItems()) {
total = total.add(item.getPrice().multiply(new BigDecimal(item.getQuantity())));
}
order.setTotalAmount(total);
// 设置状态
order.setStatus("SUBMITTED");
// 保存...
}
}
看出来问题了吗?Order对象就是个“哑巴”,它不知道自己什么时候合法、怎么计算金额、状态怎么流转。所有判断逻辑都散落在Service里。我在项目中遇到过,一个订单相关的Service最后膨胀到3000多行,改一个逻辑要翻半天代码。
核心问题:贫血模型把数据和行为分离了。数据在Model里,行为在Service里。这违反了面向对象的“封装”原则,也导致业务逻辑难以复用和测试。
DDD怎么治这个病?
DDD的核心思想很简单:让模型重新拥有行为。把原来散落在Service里的业务逻辑,搬回到它该待的地方——领域对象里。
我习惯把重构过程分成三步走:
- 识别聚合根——找到业务上的核心实体
- 迁移业务逻辑——把Service里的方法搬进领域对象
- 引入值对象——用更精确的类型代替基本类型
下面我们一步步来看。
第一步:识别聚合根
聚合根是DDD里的一个关键概念。说白了,就是一个能统领全局的实体。比如订单系统里,Order就是聚合根,OrderItem是它下面的子实体。你不能直接操作OrderItem,必须通过Order来操作。
怎么识别?我有个简单的方法:问自己“如果没有这个对象,业务还能跑吗?” 订单没了,整个交易系统就废了。但订单项没了,订单本身还可以存在(空订单)。所以Order是聚合根。
第二步:迁移业务逻辑
这是最核心的一步。我们把Service里的方法,一个一个搬进领域对象。来看重构后的代码:
// 充血模型:Order有了行为
public class Order {
private Long id;
private OrderStatus status;
private List<OrderItem> items;
private Money totalAmount;
// 提交订单——业务逻辑内聚在领域对象中
public void submit() {
// 校验逻辑放在这里,而不是Service里
if (items == null || items.isEmpty()) {
throw new BusinessException("订单项不能为空");
}
// 状态流转也在这里
if (status != OrderStatus.DRAFT) {
throw new BusinessException("只有草稿状态的订单才能提交");
}
this.totalAmount = calculateTotal();
this.status = OrderStatus.SUBMITTED;
}
// 计算总金额——领域对象的职责
private Money calculateTotal() {
return items.stream()
.map(OrderItem::getSubTotal)
.reduce(Money.ZERO, Money::add);
}
// 添加订单项——通过聚合根操作子实体
public void addItem(Product product, int quantity) {
if (status != OrderStatus.DRAFT) {
throw new BusinessException("只能修改草稿订单");
}
items.add(new OrderItem(product, quantity));
}
}
// Service变得很薄
public class OrderApplicationService {
private OrderRepository orderRepository;
public void submitOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
order.submit(); // 直接调用领域方法
orderRepository.save(order);
}
}
你看,Service从3000行变成了几十行。它只负责协调和持久化,真正的业务判断都在Order里。这样做的好处是:业务逻辑可测试、可复用、可理解。
我的经验:迁移时不要一次性全搬完。我习惯先搬最核心的1-2个方法,跑通测试后再逐步迁移。这样风险小,也容易让团队接受。
第三步:引入值对象
贫血模型的另一个通病是“基本类型偏执”。比如金额用BigDecimal、状态用String、地址用String。这会导致什么问题?
- 你没法保证金额一定是正数
- 你没法保证状态一定是合法值
- 你没法保证地址格式正确
DDD的做法是用值对象来封装这些概念。值对象是不可变的,自带校验逻辑:
// 值对象:Money
public class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
// 校验逻辑内聚在构造方法中
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new BusinessException("金额不能为负数");
}
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new BusinessException("货币类型不一致");
}
return new Money(this.amount.add(other.amount), this.currency);
}
// 没有setter,只有业务方法
public BigDecimal getAmount() { return amount; }
public Currency getCurrency() { return currency; }
}
// 值对象:OrderStatus
public enum OrderStatus {
DRAFT, SUBMITTED, PAID, SHIPPED, COMPLETED, CANCELLED;
public boolean canTransitionTo(OrderStatus target) {
// 状态流转规则集中管理
switch (this) {
case DRAFT: return target == SUBMITTED || target == CANCELLED;
case SUBMITTED: return target == PAID || target == CANCELLED;
case PAID: return target == SHIPPED;
case SHIPPED: return target == COMPLETED;
default: return false;
}
}
}
用了值对象之后,非法状态根本构造不出来。你想想看,这比在Service里到处写if判断要安全多少倍?
注意:值对象一定要设计成不可变的。我见过有人把值对象加上setter,结果到处被修改,最后查bug查到崩溃。记住:值对象一旦创建,就不应该再改变。
重构前后的对比
为了让你看得更清楚,我整理了一个对比表:
| 维度 | 贫血模型 | DDD充血模型 |
|---|---|---|
| 业务逻辑位置 | 散落在Service中 | 内聚在领域对象中 |
| 数据校验 | 调用方各自校验 | 对象自身保证合法性 |
| 可测试性 | 需要Mock大量依赖 | 直接new对象测试 |
| 变更影响范围 | 改一个逻辑要改多处 | 只改领域对象 |
| 代码量 | Service膨胀,Model萎缩 | Service变薄,Model变厚 |
说白了,DDD就是把“该谁做的事”还给谁。数据和行为本来就应该在一起,强行拆开只会让系统越来越难维护。
核心知识体系
下面这张图总结了DDD重构贫血模型的核心逻辑:
避坑指南
最后分享几个我踩过的坑:
- 不要过度设计。 我见过有人把“用户姓名”也封装成值对象,结果代码变得特别啰嗦。值对象只封装有业务含义的概念,比如金额、地址、状态。
- 聚合边界要合理。 曾经我把整个订单系统放在一个聚合里,结果每次操作都要加载全部数据,性能惨不忍睹。聚合要小,一个聚合根管理3-5个子实体就够了。
- 基础设施不要污染领域模型。 领域对象里不要出现@Autowired、@Repository这些注解。领域层应该对基础设施一无所知。
嗯,DDD不是银弹,但它确实能解决贫血模型带来的大部分问题。关键是要动手去试,从一个小模块开始,慢慢感受变化。你会发现,代码变得有“生命”了。
公众号:蓝海资料掘金营,微信deep3321