第2章:领域驱动设计(DDD)入门:战略设计与战术设计

说实话,很多团队做微服务拆分,最后都拆成了一团乱麻。为什么?因为大家只盯着技术边界,却忘了业务边界。我当年带一个电商项目,硬是把订单和支付拆成了两个服务,结果每次跨服务调用都像在走钢丝。后来学了DDD,才明白问题出在哪——限界上下文没划对。

这一章,我就带你从战略设计和战术设计两个层面,把DDD的核心摸透。嗯,别怕,我不会讲太多理论,更多是我踩过的坑和总结的经验。

2.1 战略设计:限界上下文

战略设计,说白了就是画地图。你要先搞清楚:你的业务领域到底有多大?哪些东西该归在一起?哪些东西必须分开?

限界上下文是DDD里最核心的概念之一。它定义了一个明确的边界,在这个边界内,所有的术语、概念、业务规则都是一致的。出了这个边界,同一个词可能意思完全不同。

举个例子:在电商系统里,“订单”这个词,在下单上下文里指的是“用户提交的购物清单”,在物流上下文里指的是“需要配送的包裹”,在财务上下文里指的是“待结算的交易记录”。你看,同一个词,三个意思。如果不划清边界,代码里就会乱成一锅粥。

核心原则:每个限界上下文内部,保持语言统一(通用语言)。上下文之间,通过防腐层(Anti-Corruption Layer)隔离。

我习惯用事件风暴来识别限界上下文。拉上业务方、产品、开发,一起贴便签纸。把所有的业务事件、命令、聚合都列出来,然后自然就能看出哪些事件是强相关的,哪些是弱相关的。强相关的归到一个上下文里,弱相关的就拆出去。

我的经验:限界上下文的大小,没有标准答案。但有一个判断标准——一个上下文应该能独立演进。如果两个模块总是要一起改,那它们就不该分开。

2.2 战术设计:实体、值对象、聚合

战略设计画好了地图,战术设计就是具体怎么盖房子了。战术设计里有三个核心概念:实体值对象聚合。我一个个说。

2.2.1 实体(Entity)

实体,就是有唯一标识的对象。它的身份不会因为属性变化而改变。比如一个用户,不管他改了多少次名字、换了多少次手机号,他还是那个用户——因为他的用户ID没变。

我曾经犯过一个错:把用户的ID和邮箱绑在一起,觉得邮箱就是唯一标识。结果用户一换邮箱,整个系统就乱了。嗯,血的教训。

// 实体示例:用户
public class User {
    private UserId id;      // 唯一标识,值对象
    private String name;    // 属性可以变
    private String email;   // 属性可以变

    public User(UserId id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    // 实体通过ID判断相等
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return id.equals(user.id);
    }

    @Override
    public int hashCode() {
        return id.hashCode();
    }
}

2.2.2 值对象(Value Object)

值对象,没有唯一标识,只靠属性值来定义。比如一个地址、一笔金额、一个颜色。两个地址如果省市区完全一样,那它们就是同一个地址——不需要ID。

值对象有个特点:不可变。一旦创建就不能改。想改?那就新建一个。这能避免很多并发问题。

// 值对象示例:地址
public class Address {
    private final String province;
    private final String city;
    private final String district;
    private final String detail;

    public Address(String province, String city, String district, String detail) {
        this.province = province;
        this.city = city;
        this.district = district;
        this.detail = detail;
    }

    // 值对象通过所有属性判断相等
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Address address = (Address) o;
        return province.equals(address.province) &&
               city.equals(address.city) &&
               district.equals(address.district) &&
               detail.equals(address.detail);
    }

    @Override
    public int hashCode() {
        return Objects.hash(province, city, district, detail);
    }
}

避坑指南:我曾经把“金额”设计成实体,给它加了个ID。结果每次转账都要查一次数据库,性能惨不忍睹。后来改成值对象,直接传值,快多了。记住:能用值对象就别用实体

2.2.3 聚合(Aggregate)

聚合,是一组相关对象的集合。它有一个聚合根,作为外部访问的唯一入口。外部不能直接操作聚合内部的实体或值对象,只能通过聚合根来操作。

为什么要有聚合?为了保证数据一致性。你想想看,一个订单包含订单项、收货地址、支付信息。如果外部可以直接修改订单项,那订单总金额就可能对不上。聚合根就是守门员,所有修改都得经过它。

// 聚合示例:订单(聚合根)
public class Order {
    private OrderId id;                    // 聚合根ID
    private List<OrderItem> items;         // 内部实体
    private Address shippingAddress;       // 内部值对象
    private Money totalAmount;             // 内部值对象
    private OrderStatus status;

    // 外部只能通过聚合根的方法来操作
    public void addItem(Product product, int quantity) {
        // 业务规则:只能添加未支付的订单
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException("只能修改待支付订单");
        }
        OrderItem item = new OrderItem(product, quantity);
        items.add(item);
        recalculateTotal();
    }

    public void pay() {
        // 业务规则:订单金额必须大于0
        if (totalAmount.isZero()) {
            throw new IllegalStateException("订单金额不能为0");
        }
        this.status = OrderStatus.PAID;
    }

    private void recalculateTotal() {
        this.totalAmount = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.ZERO, Money::add);
    }
}

聚合设计原则:

  • 聚合要尽量小,只包含真正需要强一致性的对象
  • 聚合根是唯一的外部访问入口
  • 一个事务只修改一个聚合
  • 聚合之间通过事件驱动来最终一致

2.3 DDD在微服务拆分中的应用

好了,理论讲完了。现在说说怎么用DDD来拆微服务。我个人习惯分三步走:

  1. 识别限界上下文:用事件风暴把业务领域拆成若干个上下文。每个上下文就是一个潜在的微服务。
  2. 设计聚合:在每个上下文内部,识别出聚合和聚合根。聚合就是微服务内部的最小业务单元。
  3. 定义上下文映射:搞清楚上下文之间的关系——是合作关系?还是客户-供应商关系?还是共享内核?不同的关系决定了微服务之间的通信方式。
上下文关系 通信方式 适用场景
合作关系 事件驱动 两个上下文需要协同完成一个业务流程
客户-供应商 API调用 一个上下文依赖另一个上下文的数据
共享内核 共享库 两个上下文共享一小部分核心模型
防腐层 适配器模式 需要隔离外部系统的变化

举个例子。我在做一个SaaS租户系统时,一开始把“租户”和“用户”放在一个服务里。结果租户管理要升级,用户服务也得跟着停。后来按DDD拆成两个上下文:租户上下文负责租户的创建、配置、计费;用户上下文负责用户的登录、权限、个人信息。两个上下文通过事件来同步——租户创建成功后,发一个事件,用户上下文收到后初始化默认管理员账号。这样两边互不干扰,各自独立部署。

我的建议:不要一开始就追求完美的DDD设计。先按直觉拆,然后通过重构慢慢调整。DDD是迭代出来的,不是设计出来的。我见过太多团队花三个月画完美的领域模型,结果一落地就发现跟现实对不上。

最后说一句:DDD不是银弹。它解决的是业务复杂度的问题,不是技术复杂度的问题。如果你的业务逻辑很简单,用CRUD就够了,别硬上DDD。但如果你跟我一样,面对的是那种业务规则多、变化频繁、团队又大的系统,那DDD绝对值得你花时间学。