让我们快速入门DDD

292 阅读19分钟

欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。

引言

本篇用于快速入门DDD。

除了介绍基本概念,从第一性原理出发看为什么我们需要DDD。

AI使用声明:本篇使用了AI。

1 从第一性原理出发,为什么我们需要DDD?

Eric Evans在《领域驱动设计》中指出,软件复杂性主要来自两个方面:

  1. 偶然复杂性:由技术选择或实现方式带来的复杂性

    • 可通过更好的技术或工具解决
    • 例如:选择更合适的框架、改进部署流程
  2. 本质复杂性:由业务问题本身固有的复杂性

    • 无法消除,只能通过建模来管理
    • DDD主要解决这类复杂性

DDD的核心价值在于:通过领域建模将本质复杂性转化为可管理的形式,而不是试图消除它。是一套以业务领域为中心,分析和解决复杂业务问题的思维框架

在深入DDD解决方案前,我们需要明白传统开发方法在面对复杂业务时的问题,并思考这些问题要如何解决。

1.1 需求与实现的偏差:业务与技术的鸿沟

传统开发中,沟通成本高。 业务人员用业务语言描述需求,技术人员用技术语言实现系统,思考需求时的偏差容易导致功能不符合业务预期。 比如:

  • 业务语义:订单"确认"意味着库存锁定、信用检查、准备发货
  • 技术语义:订单"确认"可能只是status字段从"DRAFT"变为"CONFIRMED"

这个时候,我们就需要建立通用语言,使代码命名与业务术语一致。 通过领域模型直接映射业务概念,使代码成为业务规则的精确表达。

比如:

// 通用语言体现在代码中
public class Order {
    // 方法名直接使用业务术语
    public void confirm() {  // 而不是"setStatusToConfirmed"
        // 业务规则...
    }
    
    public void cancel() {   // 而不是"deleteOrder"
        // 业务规则...
    }
}

// 领域事件使用业务语言
public class OrderConfirmedEvent {  // 而不是"OrderStatusChangedEvent"
    // ...
}

1.1.1 通用语言建立小技巧

通用语言(Ubiquitous Language)不是开个会、定个术语表就完事了。它是一个动态的、不断演进的过程。

  • 怎么做:忘掉数据库表和 API 接口,和领域专家(比如销售、财务、仓管)坐在一起,让他们讲故事
    • 听名词:当他们说到“客户”、“订单”、“商品”、“发票”、“促销活动”时,把这些词记下来。这些就是**领域对象(实体/值对象)**的候选者。
    • 听动词:当他们说到“客户  订单”、“我们 审核 订单”、“仓库 拣货”、“财务 开具 发票”、“系统 应用 促销”时,把这些动词记下来。这些就是领域行为(方法)领域事件的候选者。
    • 听规则:“只有已支付的订单才能发货”、“VIP客户的订单优先处理”、“一个订单最多只能包含 50 种商品”。这些就是需要封装在领域对象内部的业务规则
  • 产出:你会得到一份充满业务术语的词汇表。关键一步是:让这些词汇直接成为你的代码命名
    • 类名:Customer, Order, Invoice
    • 方法名:placeOrder(), approve(), issueInvoice()
    • 状态(枚举):OrderStatus.PAID, OrderStatus.SHIPPED

当你能指着一段代码 if (order.isPaid()) { shipmentService.ship(order) },而领域专家能看懂并确认“对,逻辑就是这样”时,通用语言就建立起来了。

1.2 贫血模型导致的业务逻辑分散

DDD将传统三层架构中,仅作为数据容器的对象称为贫血模型,使用贫血模型时,业务逻辑集中在Service层,比如:

// 贫血模型示例
public class Order {
    private Long id;
    private List<OrderItem> items;
    private OrderStatus status;
    // 仅有getter/setter
}
  
public class OrderService {
  
    public void addItem(Order order, Product product, int quantity) {
        // 业务逻辑全部在Service层
        if (order.getStatus() != OrderStatus.DRAFT) {
            throw new IllegalStateException("...");
        }
        // 复杂的业务规则检查...
        order.getItems().add(new OrderItem(product, quantity));
    }
      
    public void confirmOrder(Order order) {
        // 1. 检查订单是否为空
        if (order.getItems().isEmpty()) {
            throw new IllegalStateException("Cannot confirm empty order");
        }
        
        // 2. 检查客户信用
        if (!creditService.isCreditValid(order.getCustomerId())) {
            throw new CreditException();
        }
        
        // 3. 更新状态
        order.setStatus("CONFIRMED");
        order.setConfirmedAt(new Date());
    }
      
}

在这样的模式下,我们习惯将数据与行为分离,可能相同业务规则在多处重复实现,难以保证业务一致性。

比如添加订单场景,订单添加商品后,需要同时:

  1. 检查库存
  2. 计算价格
  3. 更新订单总额
  4. 记录审计日志

在传统开发中,这些可能分散在不同地方:

// 在OrderService中
public void addItem(Order order, Product product, int quantity) {
    // 检查库存...
    // 添加商品...
}

// 在另一个Service中
public void calculateTotal(Order order) {
    // 计算总价...
}

// 在另一个地方
public void logOrderChange(Order order, String changeType) {
    // 记录日志...
}

订单添加商品后进行的这些操作没有强制执行机制,每个需要执行OrderService中addItem的地方,都需要每次check list执行后续一系列操作,在复杂的业务代码所处的可能混乱的项目环境中,也许会造成操作缺失。(代码改全了么?)

也就是没有一个业务操作整体的原子性封装,来确保每次操作的一致性,同时,也不利于迭代修改。

因此,我们需要将一个复杂的业务操作视作原子整体,且抽离出来、消灭掉分散在各处的逻辑,而这样的代码,我们将其与“唯一不变的”业务操作对象Order进行绑定是比较方便的,比如:

# order对象的方法,一个方法调用完成所有必要操作
order.addProduct(product, quantity); 

# 方法实现,所有相关操作封装在一个方法内
public void addProduct(Product product, int quantity) {
    // 1. 状态检查
    if (status != OrderStatus.DRAFT) {
        throw new DomainException("Cannot modify submitted order");
    }
    
    // 2. 库存检查
    if (product.getAvailableStock() < quantity) {
        throw new DomainException("Insufficient stock");
    }
    
    // 3. 创建订单行
    OrderLine line = new OrderLine(product, quantity);
    lines.add(line);
    
    // 4. 更新订单状态 - 自动包含在操作中
    recalculateTotal();
    
    // 5. 记录审计 - 作为领域事件自动触发
    addDomainEvent(new ProductAddedEvent(this.id, product.getId(), quantity));
}

而这种数据容器,在DDD中被称为“充血模型”。

充血模型:将业务逻辑封装在对象内部。 这种含有行为和规则的对象,是对领域知识的表达,也被称为领域对象。

1.3 系统边界模糊导致的高耦合

光有充血模型是不够的。

  • 问题 想象一下,如果一个巨大的 Order 对象包含了用户信息、商品详情、库存状态、物流信息等所有逻辑,它会变得无比臃肿,这同样是一坨💩。

image.png 虽然逻辑内聚了,但对象自身的边界变得模糊,和系统其他部分仍然是高耦合。
当“销售上下文”要给商品加一个“促销标签”字段时,这个字段对“仓储上下文”是无用的噪音。 当“仓储上下文”要修改库存的计算逻辑时,可能会无意中影响到“销售上下文”的显示逻辑。

  • so 所以,我们需要和划分微服务模块一样,划分好我们的业务边界,或者说是语义边界。

简单来说,商品领域要是商品相关的,职责要清晰。 在其它的涉及商品的领域中,商品相关的行为和规则也要是属于那个领域。

DDD提供了“限界上下文”与“聚合”、“聚合根”来帮忙进行边界划分。 我们也借助DDD的概念来继续理解问题与解决方案。

限界上下文模型应用的明确边界每个上下文有独立的模型和术语,边界即集成点
聚合一致性边界内的对象集群由聚合根控制,保证事务一致性
聚合根聚合的入口点唯一可外部引用的对象,负责维护聚合内一致性

1.3.1 限界上下文

限界上下文是 DDD 中最重要的战略设计模式。它定义了一个语义边界。在这个边界内,领域模型(比如“商品”这个词)有明确、无歧义的含义。

比如,在“销售上下文”中,“商品”关心的是价格、名称和描述。而在“仓储上下文”中,“商品”可能只关心库存量、位置和尺寸。 这样就避免了用一个大而全的“商品”对象来满足所有需求,从源头上分离了关注点,降低了耦合。 对象在不同领域行为和规则不同,边界清晰。这样的对象在修改时就不会牵一发动全身。

简单来说,限界上下文的思想要求我们将同一个业务概念(如“商品”)建模成不同的对象。 行为就在模型本身,但“模型”不止一个。

  • 销售上下文里,有一个 Product 模型。它的职责是销售,所以它的数据是“价格、名称、描述”,它的行为是“打折(applyDiscount)”、“上架(publish)”。
  • 仓储上下文里,有另一个 Product 模型。它的职责是库存管理,所以它的数据是“库存量、位置、尺寸”,它的行为是“入库(restock)”、“预留库存(reserveStock)”。

在代码中,“限界上下文”通常通过物理隔离来体现:

  • 包命名空间:com.mycorp.sales.domain.Product 和 com.mycorp.warehouse.domain.Product。
  • 模块/项目:在 Maven 或 Gradle 项目中,sales-module 和 warehouse-module。
  • 微服务:最彻底的隔离方式,SalesService 和 WarehouseService 是两个可以独立部署的应用,它们各自拥有自己的数据库和模型。

image.png

很显然,一个复杂业务往往涉及多个领域,比如上图当订单支付成功后,商品需要进行“仓储限界上下文”的"出库"操作。所以我们的“上下文”之建模需要进行通信以完成一个复杂业务。

这种通信,在DDD中被称为领域事件。 单在了解领域事件之前,我们有必要先了解一下聚合、聚合根。

1.3.2 聚合、聚合根

在复杂的业务场景中,一些对象在概念和业务规则上是紧密耦合的。例如,一个“订单”和它的“订单项”必须作为一个整体来理解和修改,单独修改一个“订单项”的价格而不更新“订单”总价是没有意义的。

为了管理这种一致性,DDD 提出了聚合的概念。

1. 什么是聚合 (Aggregate)?

聚合是一个概念边界,圈定了一组业务上紧密关联的对象(包括实体和值对象),并将它们视为一个数据修改和持久化的基本单元。

  • 目标:保证边界内的数据在任何操作后都处于一致的状态。
  • 例子:一个订单聚合可能包含:
    • Order 实体(作为聚合根)
    • OrderItem 实体的列表
    • ShippingAddress 值对象

2. 什么是聚合根 (Aggregate Root)?

聚合根是聚合内部的一个特定实体,被指定为整个聚合的“管理者”。它是外部世界与这个聚合交互的唯一入口。

聚合的设计规则:

  1. 封装与控制:外部对象只能持有对聚合根的引用。它们绝对不能直接引用或修改聚合内部的其他对象。所有操作都必须通过聚合根暴露的方法来执行。这符合“最小暴露原则”。
  2. 事务一致性:数据库的事务边界应该以聚合为单位。对一个聚合的任何修改(比如添加订单项、修改地址)都应该在一个事务中完成,要么全部成功,要么全部失败。
  3. 统一的生命周期:当删除聚合根时,聚合边界内的所有其他对象也必须被一并删除。

示例: 在“订单聚合”中,Order 是聚合根,OrderItem 是其内部实体。

  • 正确做法:order.addProduct(...)。由聚合根 Order 来控制如何添加 OrderItem,并在其方法内部保证总价、状态等一系列规则的正确执行。
  • 错误做法:order.getItems().add(...)。这种做法绕过了聚合根的控制,直接操作其内部集合。它破坏了封装和一致性,是 DDD 中严格禁止的。

1.3.3 领域事件(Domain Events)

领域事件的实现和“一般通信”(比如直接的 API 调用)有着根本区别。

通信方式耦合度关注点核心思想
领域事件 (Domain Event)松耦合 (Loose Coupling)“发生了什么” (What Happened)发布/订阅模式。发布方(如销售上下文)只负责宣告“订单已支付”,它不关心谁在听,也不关心听众要做什么。系统是事件驱动的。
远程过程调用 (RPC/API Call)紧耦合 (Tight Coupling)“去做某件事” (Do Something)命令模式。调用方(如销售上下文)必须明确知道它要命令谁(仓储上下文),以及命令它做什么(shipProduct())。系统是命令驱动的。
其代码实现如下:

首先,使用一个简单的、不可变的数据对象(DTO/POJO),用来承载事件信息。它应该:

  • 以过去时态命名:如 OrderPaidEvent, ProductShippedEvent。
  • 包含必要信息:只包含订阅者需要的最少信息,而不是整个聚合根。比如订单 ID、支付时间、用户 ID 等。(传统写法也是这样的)
  • 有一个唯一标识和时间戳:便于追踪和排序。
// 1. 事件本身:一个不可变的DTO
public class OrderPaidEvent {
    private final UUID eventId;
    private final Instant occurredOn;
    private final Long orderId;
    private final Long customerId;

    // 构造函数, getters...
}

然后,我们需要一个事件的发布者,很显然,是我们的充血模型,聚合根。

这里需要注意的是,事件发布这个过程是有两个操作的,“业务操作成功(事务提交)+操作后发布事件”。 这种涉及多个领域的事件发布(通信),因为往往是多个服务间的(跨进程的),一般需要使用消息中间件进行发布。

我们要保证两个操作是原子性的,以应对可能的网络波动与服务异常等故障。

  • 错误图示

image.png

  • 伪代码
public void payOrder(Long orderId) {
    // 1. 开始数据库事务
    Transaction tx = database.beginTransaction();
    try {
        // 2. 加载聚合,执行业务逻辑
        Order order = orderRepository.findById(orderId);
        order.markAsPaid();
        
        // 3. 保存聚合状态到数据库
        orderRepository.save(order);
        
        // 4. 提交数据库事务
        tx.commit(); // <-- 此时,订单状态 'PAID' 已被永久写入数据库

    } catch (Exception e) {
        tx.rollback();
        throw e;
    }

    // 5. 事务成功了,现在发布事件
    eventBus.publish(new OrderPaidEvent(orderId)); // <-- 问题就在这里,可能不会发布
}

或者
@Transactional
public void payOrder(Long orderId) { // <-- 步骤1:事务开始

    // 步骤2:执行业务代码
    Order order = orderRepository.findById(orderId);
    order.markAsPaid();
    orderRepository.save(order); // <-- 数据库收到指令,但变更被“挂起”在事务中

    // 步骤3:执行消息发布代码
    messageBroker.publish(new OrderPaidEvent(orderId)); // <-- 消息被真实地、立刻地发送出去

    // 步骤4:方法体结束,准备提交
} // <-- 步骤5:COMMIT失败,触发ROLLBACK

我们无法通过本地事务来强制两个操作的原子性。

解决这个问题最经典、最可靠的实现方式是发件箱模式 (Outbox Pattern)

即,增加一个分发者,将“发布事件”这个外部通信操作,转化为一个数据库写入操作(即“在发件箱表中插入一条事件记录”),并将这个写入操作与核心的业务数据变更放在同一个数据库事务中,从而利用数据库的原子性保证两者“同生共死”。
由一个独立的**分发者(Dispatcher)**进程异步地、可靠地轮询“发件箱表”,并将事件真正地发布到消息中间件。

聚合根在完成自己的业务逻辑、改变自身状态后,注册一个领域事件,而不是立即发送它。

//发布方:Order聚合根
public class Order {
    private Long id;
    private OrderStatus status;
    private List<DomainEvent> domainEvents = new ArrayList<>(); // 存储待发布的事件

    public void markAsPaid() {
        if (this.status != OrderStatus.PENDING) {
            throw new IllegalStateException("Order is not in pending state.");
        }
        this.status = OrderStatus.PAID;
        
        // 注册事件,而不是发送它
        this.domainEvents.add(new OrderPaidEvent(this.id, this.customerId));
    }

    public List<DomainEvent> getDomainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }

    public void clearDomainEvents() {
        this.domainEvents.clear();
    }
}

分发者,事件分发器 (The Dispatcher)。它的职责是在业务操作(通常是数据库事务)成功提交后,遍历聚合根,拿出所有注册的事件,并将它们发布到消息中间件(如 RabbitMQ, Kafka)或进程内的事件总线中。

// 伪代码:应用服务层或框架负责分发
public class OrderApplicationService {
    // ...
    @Transactional
    public void payOrder(Long orderId) {
        // 1. 加载聚合
        Order order = orderRepository.findById(orderId);
        
        // 2. 执行业务逻辑
        order.markAsPaid();
        
        // 3. 持久化聚合(这会触发事件分发)
        orderRepository.save(order); 
        // 框架的save方法在提交事务后,会调用eventDispatcher.dispatch(order.getDomainEvents())
    }
}

分发工作图示:

image.png

事件发布后,另一个限界上下文(或同一个上下文中的其他聚合)中的组件。它监听特定类型的事件,并执行相应的业务逻辑。

// 4. 订阅方:在仓储上下文中
@Service
public class WarehouseEventHandler {

    private final WarehouseService warehouseService;
    
    // ... 构造函数注入

    @TransactionalEventListener // Spring框架注解,表示在事务提交后处理事件
    public void handleOrderPaidEvent(OrderPaidEvent event) {
        // 监听到“订单已支付”事件
        // 执行自己的业务逻辑
        log.info("Order {} has been paid, initiating shipment process.", event.getOrderId());
        warehouseService.initiateShipmentForOrder(event.getOrderId());
    }
}

1.4 复杂业务逻辑难以表达和维护

仅仅明白使用充血模型来抽象、统一管理业务领域的行为与规则还不够,我们需要让模型内部的逻辑更清晰、更易于维护。

以提升充血模型的可维护性,具体来说有,DDD有以下的模式:

  1. 实体 (Entity) vs. 值对象 (Value Object)
    • 是什么:实体有唯一的身份标识(ID),生命周期很重要(比如 Order)。值对象没有唯一标识,由其属性定义,通常是不可变的(比如 Money 对象或 Address 地址对象)。
    • 解决什么问题:通过区分这两者,可以简化模型。值对象的不可变性让系统更稳定,可以被安全地共享。例如,你可以用一个 Money 值对象(包含金额和币种)来代替简单的 BigDecimal,这样 price.add(otherPrice) 就比 price.add(otherPrice) 更具业务含义,且能处理币种不同的问题。
  2. 领域服务 (Domain Service)
    • 是什么:当一段业务逻辑不属于任何一个实体或值对象时,就可以把它放在领域服务中。
    • 解决什么问题:避免让实体承担不属于它的职责。例如,“转账”操作,它涉及到两个 Account 实体。这个逻辑放在 sourceAccount.transferTo(targetAccount) 中可能不合适(为什么是源账户而不是目标账户负责?),放在领域服务 TransferService.transfer(source, target, amount) 中就非常清晰。
  3. 规约 (Specification)
    • 是什么:将复杂的业务规则(比如查询条件、验证逻辑)封装成一个独立的对象。
    • 解决什么问题:从你的 if-else 噩梦中解脱出来。例如,你可以创建一个 ReadyForShipmentSpecification 对象,它内部包含了所有判断订单是否可以发货的逻辑(已支付、库存充足、地址有效等)。你的代码就可以写成 if (readyForShipmentSpec.isSatisfiedBy(order)) { ... },而不是一大堆 if 判断。这个规约还可以被复用和组合。

用一系列“小工具”将复杂的业务逻辑拆解成一个个高内聚、低耦合且业务语义丰富的对象的。

DDD 的核心目标,就是通过上述所有的战略(限界上下文)和战术(聚合、实体、值对象等)设计模式,来直面并精心管理业务固有的“本质复杂性”,同时通过提供清晰的建模和开发范式,最大限度地减少因技术实现混乱而引入的“偶然复杂性”。

2 DDD基础概念与术语表

DDD的关键概念:

2.1 战略设计层(Strategic Design)

  • 领域(Domain):软件要解决的核心业务问题范围,如电商中的"订单管理"、银行中的"贷款审批"
  • 子域(Subdomain):将大领域拆分为更小、更易管理的部分,分为核心子域、支撑子域和通用子域
  • 限界上下文(Bounded Context):明确定义模型应用的边界,在边界内术语和模型有特定含义
  • 通用语言(Ubiquitous Language):开发团队与领域专家共同使用的、精确表达领域概念的语言
  • 上下文映射(Context Mapping):描述不同限界上下文之间关系的模式

2.2 战术设计层(Tactical Design)

  • 实体(Entity):具有唯一标识且生命周期持续的对象,如"用户"、"订单"
  • 值对象(Value Object):描述事物特征但无唯一标识的对象,如"地址"、"货币金额"
  • 聚合(Aggregate):一组相关对象的集合,由聚合根(Aggregate Root)统一管理
  • 领域服务(Domain Service):处理跨多个领域对象的业务逻辑
  • 领域事件(Domain Event):表示领域中发生的重要事情
  • 仓储(Repository):提供聚合的持久化抽象
  • 工厂(Factory):封装复杂对象的创建逻辑

2.3 DDD术语表

术语定义技术实现要点
领域软件试图解决的业务问题范围识别核心业务能力,区分核心域与支撑域
子域领域内的细分区域按业务重要性分为核心子域、支撑子域和通用子域
限界上下文模型应用的明确边界每个上下文有独立的模型和术语,边界即集成点
通用语言开发团队与领域专家共享的语言代码命名、文档、讨论都使用同一套术语
实体具有唯一标识的对象通常有ID属性,生命周期长,状态可变
值对象通过属性值定义的对象无ID,不可变,相等性基于属性值
聚合一致性边界内的对象集群由聚合根控制,保证事务一致性
聚合根聚合的入口点唯一可外部引用的对象,负责维护聚合内一致性
领域服务处理跨聚合的业务逻辑无状态,方法表示领域概念
领域事件领域中发生的重要事实发生在领域层,表示状态变化
仓储聚合的持久化抽象接口在领域层,实现在基础设施层
工厂复杂对象创建的封装隐藏创建细节,确保对象初始状态合法