第30章:领域驱动设计(DDD)

聊到微服务架构,有个话题绕不开——领域驱动设计。说实话,我见过太多团队把微服务拆得七零八落,最后发现每个服务都在做类似的事情,数据冗余得一塌糊涂。问题出在哪?说白了,就是没搞懂业务边界在哪。

DDD 不是一套新框架,也不是什么银弹。它是一套思维方式。我习惯把它理解为「用代码还原业务现实」的方法论。今天咱们就把它掰开揉碎,看看核心概念到底怎么落地。

实体(Entity):有身份证的对象

实体是什么?简单说,就是有唯一标识、会变化、但身份不变的东西。比如一个用户,他改了名字、换了手机号,他还是他。这个「他」就是实体。

我在项目中遇到过这样的坑:把订单号当实体的唯一标识,结果订单状态变了,整个对象就重建了。这不对。实体的标识应该从出生到消亡都不变。

// 实体示例:用户
public class User {
    private final UserId userId;  // 值对象作为标识
    private String name;
    private Email email;

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

    public void changeEmail(Email newEmail) {
        // 业务规则:邮箱不能为空
        if (newEmail == null) {
            throw new IllegalArgumentException("邮箱不能为空");
        }
        this.email = newEmail;
    }

    // equals 和 hashCode 基于 userId
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return userId.equals(user.userId);
    }

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

看到了吗?equalshashCode 只依赖 userId。这才是实体的正确姿势。

值对象(Value Object):描述事物,不关心是谁

值对象没有身份。两个值对象只要属性相同,就是同一个东西。比如地址、金额、颜色。你想想看,一个地址「北京市朝阳区xxx」,换了个房子,地址变了,但地址本身没有「身份」这个概念。

核心区别:实体看「是谁」,值对象看「是什么」。

// 值对象示例:金额
public class Money {
    private final BigDecimal amount;
    private final String currency;

    public Money(BigDecimal amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("币种不一致");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.equals(money.amount) && currency.equals(money.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
}

值对象是不可变的。一旦创建,就不能改。要改?新建一个。这能避免很多并发问题。我曾经在电商系统里把订单金额做成可变字段,结果并发扣款时数据全乱了。嗯,从那以后我再也不敢让值对象可变。

聚合(Aggregate):一致性边界

聚合是一组相关对象的集合,对外暴露一个根实体。所有外部访问都必须通过聚合根。为什么?为了保证数据一致性。

举个例子:订单和订单项。你不能直接修改订单项的数量,必须通过订单这个聚合根来操作。否则可能出现订单总金额和明细对不上的情况。

// 聚合根:订单
public class Order {
    private final OrderId orderId;
    private List<OrderItem> items;
    private OrderStatus status;
    private Money totalAmount;

    public void addItem(Product product, int quantity) {
        // 业务规则:已支付的订单不能加商品
        if (status == OrderStatus.PAID) {
            throw new IllegalStateException("已支付订单不能修改");
        }
        OrderItem item = new OrderItem(product, quantity);
        items.add(item);
        recalculateTotal();
    }

    public void pay() {
        // 业务规则:空订单不能支付
        if (items.isEmpty()) {
            throw new IllegalStateException("空订单不能支付");
        }
        this.status = OrderStatus.PAID;
        // 发布领域事件
        DomainEventPublisher.publish(new OrderPaidEvent(this.orderId));
    }

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

我的习惯:一个聚合不要太大。我见过一个聚合包含几十个实体,结果每次加载都慢得要命。聚合的粒度应该跟事务边界一致。如果一个操作需要锁住太多数据,说明聚合拆得不够细。

领域事件(Domain Event):发生了什么?

领域事件记录「已经发生的事」。比如「订单已支付」「用户已注册」。它们是不可变的,通常用过去时命名。

为什么要用领域事件?解耦。订单支付后,需要发短信、更新库存、通知物流。如果这些逻辑都写在订单服务里,耦合度太高。用事件驱动,订单只管发布事件,其他服务自己订阅。

// 领域事件
public class OrderPaidEvent {
    private final OrderId orderId;
    private final LocalDateTime occurredOn;

    public OrderPaidEvent(OrderId orderId) {
        this.orderId = orderId;
        this.occurredOn = LocalDateTime.now();
    }

    // getters...
}

// 事件处理器
public class InventoryEventHandler {
    @EventListener
    public void handle(OrderPaidEvent event) {
        // 扣减库存
        inventoryService.deductStock(event.getOrderId());
    }
}

注意:领域事件不要滥用。我见过有人把每个字段变化都发布一个事件,结果事件风暴比业务逻辑还复杂。只在「业务上有意义」的时刻发布事件。

限界上下文(Bounded Context):划清地盘

这是 DDD 里最容易被忽略、但最重要的概念。限界上下文就是「一个明确的边界」,在这个边界内,术语有统一的含义。

举个例子:「用户」在电商系统里,下单上下文里叫「买家」,物流上下文里叫「收件人」,客服上下文里叫「客户」。同一个人的不同角色,在不同上下文里有不同的属性和行为。

上下文 实体名称 关注属性
订单上下文 买家 收货地址、积分
物流上下文 收件人 姓名、电话、地址
客服上下文 客户 历史投诉、等级

我在项目中遇到过这样的问题:两个团队用同一个「用户」对象,结果一个加了字段,另一个就崩了。后来我们划清了限界上下文,各自维护自己的模型,通过防腐层(Anti-Corruption Layer)做转换,问题就解决了。

DDD 与微服务的关系

很多人问:DDD 和微服务到底什么关系?我的回答是:DDD 是微服务拆分的「手术刀」。

没有 DDD 的微服务,很容易拆成「技术分层」——一个服务管数据库,一个服务管业务逻辑,一个服务管展示。这不是微服务,这是分布式单体。

正确的做法是:每个微服务对应一个限界上下文。订单上下文一个服务,支付上下文一个服务,库存上下文一个服务。每个服务有自己的数据库、自己的领域模型、自己的部署单元。

核心原则:微服务的边界 = 限界上下文的边界。一个上下文一个服务,不要跨上下文共享数据库。

你想想看,如果订单服务和支付服务共享一个数据库,那它们还能独立部署吗?改个表结构就得两边一起改,这不又回到单体了?

下面这张图展示了 DDD 与微服务的映射关系:

DDD 与微服务映射关系 限界上下文 订单上下文 支付上下文 库存上下文 微服务 订单服务 支付服务 库存服务 独立数据库 订单数据库 支付数据库 库存数据库 每个限界上下文对应一个微服务,拥有独立的数据库

看到这张图就明白了:DDD 帮我们找到业务边界,微服务帮我们实现技术边界。两者是天然的搭档。

实践中的避坑指南

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

  • 不要一开始就追求完美建模。 我见过有人花三个月画领域模型,结果业务变了,模型全废。DDD 是迭代的,先跑起来再优化。
  • 聚合不要太大。 一个聚合包含几十个实体,加载慢、并发冲突多。我习惯一个聚合不超过 5 个实体。
  • 领域事件不要滥用。 只在「业务有明确含义」的时刻发布。比如「订单已支付」有意义,「用户点击了按钮」没意义。
  • 限界上下文之间用防腐层。 不要直接引用另一个上下文的实体。用 DTO 或接口做隔离。

我的建议:如果你刚开始接触 DDD,先从一个核心业务场景入手。比如电商的订单流程,把实体、值对象、聚合、领域事件都走一遍。别想着全公司推广,先让一个团队跑通,再慢慢扩展。

DDD 不是银弹,但它能帮你把复杂业务理清楚。尤其是做微服务架构,没有 DDD 的指引,很容易拆成「技术微服务」——每个服务管一层,而不是管一个业务。记住:微服务的边界,就是业务的边界。


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