欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。
引言
本篇用于快速入门DDD。
除了介绍基本概念,从第一性原理出发看为什么我们需要DDD。
AI使用声明:本篇使用了AI。
1 从第一性原理出发,为什么我们需要DDD?
Eric Evans在《领域驱动设计》中指出,软件复杂性主要来自两个方面:
-
偶然复杂性:由技术选择或实现方式带来的复杂性
- 可通过更好的技术或工具解决
- 例如:选择更合适的框架、改进部署流程
-
本质复杂性:由业务问题本身固有的复杂性
- 无法消除,只能通过建模来管理
- 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());
}
}
在这样的模式下,我们习惯将数据与行为分离,可能相同业务规则在多处重复实现,难以保证业务一致性。
比如添加订单场景,订单添加商品后,需要同时:
- 检查库存
- 计算价格
- 更新订单总额
- 记录审计日志
在传统开发中,这些可能分散在不同地方:
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)?
聚合根是聚合内部的一个特定实体,被指定为整个聚合的"管理者"。它是外部世界与这个聚合交互的唯一入口。
聚合的设计规则:
- 封装与控制 :外部对象只能持有对聚合根的引用。它们绝对不能直接引用或修改聚合内部的其他对象。所有操作都必须通过聚合根暴露的方法来执行。这符合"最小暴露原则"。
- 事务一致性:数据库的事务边界应该以聚合为单位。对一个聚合的任何修改(比如添加订单项、修改地址)都应该在一个事务中完成,要么全部成功,要么全部失败。
- 统一的生命周期:当删除聚合根时,聚合边界内的所有其他对象也必须被一并删除。
示例: 在"订单聚合"中,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有以下的模式:
- 实体 (Entity) vs. 值对象 (Value Object)
- 是什么:实体有唯一的身份标识(ID),生命周期很重要(比如 Order)。值对象没有唯一标识,由其属性定义,通常是不可变的(比如 Money 对象或 Address 地址对象)。
- 解决什么问题:通过区分这两者,可以简化模型。值对象的不可变性让系统更稳定,可以被安全地共享。例如,你可以用一个 Money 值对象(包含金额和币种)来代替简单的 BigDecimal,这样 price.add(otherPrice) 就比 price.add(otherPrice) 更具业务含义,且能处理币种不同的问题。
- 领域服务 (Domain Service)
- 是什么:当一段业务逻辑不属于任何一个实体或值对象时,就可以把它放在领域服务中。
- 解决什么问题:避免让实体承担不属于它的职责。例如,"转账"操作,它涉及到两个 Account 实体。这个逻辑放在 sourceAccount.transferTo(targetAccount) 中可能不合适(为什么是源账户而不是目标账户负责?),放在领域服务 TransferService.transfer(source, target, amount) 中就非常清晰。
- 规约 (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,不可变,相等性基于属性值 |
聚合 | 一致性边界内的对象集群 | 由聚合根控制,保证事务一致性 |
聚合根 | 聚合的入口点 | 唯一可外部引用的对象,负责维护聚合内一致性 |
领域服务 | 处理跨聚合的业务逻辑 | 无状态,方法表示领域概念 |
领域事件 | 领域中发生的重要事实 | 发生在领域层,表示状态变化 |
仓储 | 聚合的持久化抽象 | 接口在领域层,实现在基础设施层 |
工厂 | 复杂对象创建的封装 | 隐藏创建细节,确保对象初始状态合法 |