让我们快速入门DDD

欢迎来到啾啾的博客🐱。

记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。

有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。

引言

本篇用于快速入门DDD。

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

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

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

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

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

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

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

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

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

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

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

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

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

比如:

java 复制代码
// 通用语言体现在代码中
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层,比如:

java 复制代码
// 贫血模型示例
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. 记录审计日志

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

java 复制代码
// 在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进行绑定是比较方便的,比如:

java 复制代码
# 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 对象包含了用户信息、商品详情、库存状态、物流信息等所有逻辑,它会变得无比臃肿,这同样是一坨💩。

虽然逻辑内聚了,但对象自身的边界变得模糊,和系统其他部分仍然是高耦合。

当"销售上下文"要给商品加一个"促销标签"字段时,这个字段对"仓储上下文"是无用的噪音。 当"仓储上下文"要修改库存的计算逻辑时,可能会无意中影响到"销售上下文"的显示逻辑。

  • 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 是两个可以独立部署的应用,它们各自拥有自己的数据库和模型。

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

这种通信,在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 等。(传统写法也是这样的)
  • 有一个唯一标识和时间戳:便于追踪和排序。
java 复制代码
// 1. 事件本身:一个不可变的DTO
public class OrderPaidEvent {
    private final UUID eventId;
    private final Instant occurredOn;
    private final Long orderId;
    private final Long customerId;

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

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

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

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

  • 错误图示
  • 伪代码
java 复制代码
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)**进程异步地、可靠地轮询"发件箱表",并将事件真正地发布到消息中间件。

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

java 复制代码
//发布方: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)或进程内的事件总线中。

java 复制代码
// 伪代码:应用服务层或框架负责分发
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())
    }
}

分发工作图示:

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

java 复制代码
// 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,不可变,相等性基于属性值
聚合 一致性边界内的对象集群 由聚合根控制,保证事务一致性
聚合根 聚合的入口点 唯一可外部引用的对象,负责维护聚合内一致性
领域服务 处理跨聚合的业务逻辑 无状态,方法表示领域概念
领域事件 领域中发生的重要事实 发生在领域层,表示状态变化
仓储 聚合的持久化抽象 接口在领域层,实现在基础设施层
工厂 复杂对象创建的封装 隐藏创建细节,确保对象初始状态合法
相关推荐
Moonbit1 小时前
# 量子位 AI 沙龙回顾丨用 MoonBit Pilot 解答 AI Coding 的未来
后端
码事漫谈1 小时前
C++ vector越界问题完全解决方案:从基础防护到现代C++新特性
后端
老张聊数据集成2 小时前
数据分析师如何构建自己的底层逻辑?
后端·数据分析
咕噜分发企业签名APP加固彭于晏2 小时前
市面上有多少智能体平台
前端·后端
掘金一周3 小时前
我开源了一款 Canvas “瑞士军刀”,十几种“特效与工具”开箱即用 | 掘金一周 8.14
前端·人工智能·后端
村姑飞来了3 小时前
Spring 扩展:动态使某个 @Import 方式导入的 @Configuration 类失效
后端
开心就好20253 小时前
前端性能优化移动端网页滚动卡顿与掉帧问题实战
后端
语落心生3 小时前
如何利用Paimon做流量定时检查? --- 试试标签表
后端