重构与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里的业务逻辑,搬回到它该待的地方——领域对象里。

我习惯把重构过程分成三步走:

  1. 识别聚合根——找到业务上的核心实体
  2. 迁移业务逻辑——把Service里的方法搬进领域对象
  3. 引入值对象——用更精确的类型代替基本类型

下面我们一步步来看。

第一步:识别聚合根

聚合根是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重构贫血模型的核心逻辑:

DDD重构贫血模型——核心知识体系 ❌ 贫血模型 • 数据与行为分离 • Service膨胀(3000+行) • 业务逻辑散落各处 • 基本类型偏执 • 难以测试和复用 • 变更影响范围大 重构 ✅ DDD充血模型 • 数据与行为内聚 • Service变薄(几十行) • 业务逻辑集中在领域对象 • 值对象封装基本类型 • 易于测试和复用 • 变更影响范围小 三大步骤 ① 识别聚合根 找到核心实体 ② 迁移业务逻辑 Service→领域对象 ③ 引入值对象 封装基本类型 核心原则:让模型重新拥有行为,把该谁做的事还给谁

避坑指南

最后分享几个我踩过的坑:

  • 不要过度设计。 我见过有人把“用户姓名”也封装成值对象,结果代码变得特别啰嗦。值对象只封装有业务含义的概念,比如金额、地址、状态。
  • 聚合边界要合理。 曾经我把整个订单系统放在一个聚合里,结果每次操作都要加载全部数据,性能惨不忍睹。聚合要小,一个聚合根管理3-5个子实体就够了。
  • 基础设施不要污染领域模型。 领域对象里不要出现@Autowired、@Repository这些注解。领域层应该对基础设施一无所知。

嗯,DDD不是银弹,但它确实能解决贫血模型带来的大部分问题。关键是要动手去试,从一个小模块开始,慢慢感受变化。你会发现,代码变得有“生命”了。


公众号:蓝海资料掘金营,微信deep3321