# 0x00 引言
不知不觉已经工作五年,距离上一篇刚入职的时候意气风发洋洋洒洒写下的《我的TDD》也已经过去了四年了。后面几年在工作的拉扯中逐渐失去了写下自己的观察结论的欲望。希望自己能够重新拾起这个习惯,毕竟写作也是加深思考的一环。
让我们直达正题。
这次《我的DDD》主要想梳理的是我认知中的DDD(领域驱动设计,Domain Driven Design)和真实落地写代码中间的差距,以及落地的时候会遇到哪些问题和权衡。
本篇文章包含:
+ 0x01:DDD、整洁架构、六边形架构概念简述
+ 0x02:接受程度适中的项目架构
+ 0x03:理想与现实——DDD实践中和理想的差距与妥协
我在整理和思考这些内容的过程中,对软件工程和DDD的认知又加深了一些,希望这篇文章也能够帮到你。
# 0x01 概念简述
DDD、整洁架构、六边形架构相关概念的澄清和讨论不在本文的重点中。这一小节主要阐述在众多的核心概念中,落地实践常用的几个概念。熟悉的朋友可以直接跳过:
+ **通用语言 (Ubiquitous Language):** 团队成员(包括领域专家和开发者)共同使用的统一语言,确保对业务概念的共同理解。
- **聚合 (Aggregate):** 领域模型中的一个或一组相关联的实体和值对象,被视为一个事务一致性边界。聚合根是聚合的入口,所有对聚合内部的访问和修改都必须通过聚合根。
- **实体 (Entity):** 具有唯一标识和生命周期,行为和数据紧密结合。
- **值对象 (Value Object):** 没有唯一标识,由其属性值定义,不可变。
- **领域服务 (Domain Service):** 当某些业务操作不自然地属于任何一个实体或值对象时,这些操作可以封装在领域服务中。它们通常是无状态的,协调领域对象来完成业务。
- **仓储 (Repository):** 定义了领域对象如何被存储和恢复的抽象接口。它不关心底层实现细节,只关注领域对象的聚合根。
- **用例(UseCase):** 定义了系统在响应特定参与者(用户或其他系统)目标时所执行的功能或一系列交互步骤。
上述常用核心概念的常用范畴,仅针对单个微服务中的用语。针对更广的系统或者企业架构设计,他们的使用频率是相差较大的。
在我所经历过的四五个项目(包含DDD实践的,那些大乱炖的不算)中,针对DDD的认知和实践各有不同,但整体而言还是可以遵循——抽象业务能力和具体实现和用例的解耦——DDD的核心理念与作用。
# 0x02 项目架构
不同的项目会有自己不同的实践,我觉得这个无可厚非,在同一个团队中合作,澄清了关键的矛盾点之外,一些不痛不痒的点我的态度是遵循:共识大于正确。
下面给出了两种项目架构:六边形架构和应用CQRS的六边形架构。我也曾遇到过基于整洁架构的实践,但是我认为整洁架构无论是在代码上还是在结构上都过于繁杂(也有可能是我经历的项目的实践的问题),没有纳入到我觉得合适的结构中来。
六边形架构是比较常见的适合DDD实践的结构了,他理解起来也简单明了(在掌握了DDD核心理念的情况下)。

#### 1. 核心分层概览与职责
1. **Inbound Adapter (入站适配器 - 蓝色)** :位于系统最外层,负责接收外部请求(如 `Controller` 处理 HTTP 请求,`Listener` 处理消息),并将请求转化为 `DTO`(Data Transfer Object)传递给应用层。
2. **Application (应用层 - 黄色)** :作为协调者,根据 CQRS 分为 `Application(Command)` 和 `Application(Query)`。
- `Application(Command)`:处理修改系统状态的命令操作,编排领域逻辑,管理事务,并调用持久化服务。
- `Application(Query)`:处理查询系统状态的请求,直接访问读模型,绕过领域层。这层是可选的,也可以通过RepositoryImpl直接查询数据库。
3. **Domain (领域层 - 红色)** :系统的核心,包含所有业务规则和领域逻辑。它保持纯粹,不依赖任何外部技术细节。
4. **Outbound Adapter (出站适配器 - 灰色)** :系统的另一外层,负责实现领域层和应用层定义的抽象接口,与具体外部技术(如数据库、第三方服务)进行交互。
#### 2. 核心依赖关系说明
- **`Inbound Adapter` -> `Application`:**
- `Inbound Adapter` 将接收到的 `DTO` 传递给 `Application` 层的 `CommandUseCase` 或 `QueryUseCase`。这是直接的调用依赖。
- **`Application(Command)` -> `Domain`:**
- `CommandUseCase` 接收 `Inbound Adapter` 传入的 `DTO`,并可能将其转换为 `Domain` 层定义的 `Command` 对象(`Command` 的**结构定义**位于 `Domain` 层)。
- `CommandUseCase` **调用** `Domain` 层内的 `DomianEntity` (聚合根) 或 `DomainService` 的方法,触发核心业务逻辑。
- `CommandUseCase` 负责从 `Repository` **查询/加载** `DomainEntities`,并在业务逻辑执行后,将修改后的 `DomainEntities` 通过 `Repository` **保存/持久化**。
- **`Application(Query)` -> `Outbound Adapter`:**
- `QueryUseCase` 直接**依赖并调用** `Outbound Adapter` 中 `QueryRepositoryImpl` 实现的 `QueryRepository` 接口。
- `QueryUseCase` 从 `QueryRepositoryImpl` 获取查询数据(`Query data`),这些数据通常是直接映射的 `DTO` 或 `PO`,绕过领域层,避免与实际业务操作无关的内容入侵领域层。
- **`Outbound Adapter` -> `Domain` (通过接口实现):**
- `RepositoryImpl` 和 `GatewayImpl` **实现** (`implements`) 了 `Domain` 层定义的 `Repository` 接口和 `Gateway` 接口, `QueryRepositoryImpl` **实现** (`implements`) 了 `Application(Query)` 层定义的 `QueryRepository` 接口。
- 这些实现类会 `use` (使用)`PO` (Persistent Object) 进行数据映射和实际的数据库操作,`QueryRepositoryImpl` 也可能直接 `use DTO` 返回数据。
- **`DomainEntities` 与 `PO` 的关系:**
- 在 `RepositoryImpl` 中,负责将 `DomainEntities` 与 `PO` 进行双向转换,以完成数据在领域模型和持久化模型之间的映射。
# 0x03 理想与现实——实际落地时的问题与权衡
在过去的五年里,除了掰扯业务,关于架构和设计是我们讨论最多的地方。下面列举一些和DDD落地相关的一些问题,以及我和Gemini讨论后他的回答。下面主要以Gemini的整理为主(因为我感觉他的回答更权威、完整),有需要的话我会做一些补充。
## Q1:Repository和Gateway接口定义的位置
`Repository` 和 `Gateway` 的接口定义,应该放在 `Domain` 层还是 `Application` 层?它们各自有什么道理?
**A1:** 这是一个经典争议,但根据 DDD 和整洁架构的核心原则,**将 `Repository` 和 `Gateway` 的接口定义在 `Domain` 层是更符合规范和推荐的做法。**
- **理由(定义在 `Domain` 层):**
- **领域对持久化和外部协作的“需求”:** `Repository` 接口表达了领域模型自身对数据存取的需求,而 `Gateway` 接口表达了领域在执行业务时对外部协作的需求。这些需求是领域行为所决定的。
- **依赖倒置原则的体现:** `Domain` 层作为高层模块,定义了抽象接口。`Outbound Adapter`(低层模块)负责实现这些接口,从而确保了依赖关系从外向内,使 `Domain` 层保持纯粹,不依赖具体技术细节。
- **领域模型表达力:** 将接口定义在 `Domain` 层,使得领域模型能更完整地表达其行为和对外部(抽象)的依赖。
- **可测试性:** 领域层在单元测试时,可以轻松地 Mock 这些接口,无需依赖真实的外部环境。
- **不同观点(定义在 `Application` 层):** 有些人认为 `Repository` 和 `Gateway` 是为满足应用用例而存在的基础设施关注点,不应“污染”领域层。
- **驳斥:** 这种观点是对“领域纯净性”的过度解读。领域层的纯净是指不依赖具体实现,但它可以且应该依赖抽象接口来表达自身需求。
总结而言,要从设计的角度出发。
如果一个业务实体有仓储和外部的**需求**,则他就应该拥有Repository和Gateway的定义。而不是看这两个接口所调用的地方。
**并且:这两个接口的实现类提供的能力本身也可以大于等于接口所需的能力**,所以在非读写分离下,那些非标准化查询(例如列表、摘要等,不包含完整的实体信息)的定义会“污染”领域层所定义的Repository接口是可以避免的。
## Q2:Repository和Gateway调用的位置
接上一问题:如果 `Repository` 接口定义在 `Domain` 层,那么其 `save` 动作(调用 `Repository.save()`)应该放在 `Application` 层的 `UseCase` 中,还是 `Domain` 层的 `DomainService` 中?这是否与 `Repository` 接口在 `Domain` 层相互矛盾?
**A2:** **`Repository.save()` 动作通常应该放在 `Application` 层的 `UseCase` (应用服务) 中。**
- **原因(放在 `UseCase` 中):**
- **用例的完整性与事务边界:** `UseCase` 代表一个完整的业务操作用例,通常对应一个事务单元。`save` 操作作为事务的提交点,放在 `UseCase` 中能清晰定义整个业务流程的原子性。
- **协调者角色:** `UseCase` 是领域层和外部世界之间的协调者,负责驱动聚合根的生命周期管理(包括加载和保存)。
**为什么不放在 `DomainService` 中:** `DomainService` 的核心职责是封装领域逻辑,不应承担事务管理和数据持久化的职责,否则会污染其纯粹性。
**不矛盾解释:** `Repository` 接口定义在 `Domain` 层,其意义在于表达“领域模型需要被持久化和恢复”的**抽象契约**,这体现了依赖倒置原则。 而 `save` 动作的调用发生在 `Application` 层,是因为 `Application` 层是整个业务用例的协调者和事务边界的管理者。 领域层只定义了“我需要一个可以存取我的机制”,但谁来“驱动”这个机制,是更高层次的协调者 (`Application` 层) 的职责。
## Q3:大数据量情况下的性能和设计的权衡
在处理聚合根包含大量子实体(如 10 万个 `OrderItem`)的场景时,为了性能,我们可能只查询并修改其中的 100 个 Item。在不拆分独立聚合根的前提下,如何权衡聚合根的完整性和性能?`Order` 聚合根应该如何暴露这种部分加载和修改的动作?
**A2:** 核心挑战在于不加载所有子实体的情况下,如何维护聚合根的不变性。
- **不推荐:** 将 `Repository` 传入聚合根,这违反其自封装性、职责膨胀。
- **推荐方案及权衡:**
1. **首选方案:重新审视聚合根边界,将大型子实体(如 `OrderItem`)提升为独立聚合根**.
- **机制:** `Order` 聚合根只包含对 `OrderItem` 的引用(即 `OrderItemId` 的列表,或者甚至不持有列表,仅通过 `OrderId` 作为关联键),而 `OrderItem` 自身成为一个独立的聚合根。
- **优点:** 完美遵循 DDD 原则,每个聚合根有独立事务边界,提高并发性,领域模型更加清晰。
- **挑战:** 失去了 `Order` 对所有 `OrderItem` 的直接强一致性保证。通常需要通过**领域事件**来实现跨聚合根的**最终一致性**(例如,`OrderItem` 价格变化时发布事件,由 `Order` 聚合根或专门的事件处理器异步更新 `Order` 的总价)。
2. **次选方案(在无法拆分聚合根时的妥协):保持子实体为内部实体,但通过聚合根外部协调与智能加载**。
- **机制:** `ApplicationService` 或 `DomainService` 协调,通过 `Repository` **智能加载**少量所需 `OrderItem`,然后传递给 `Order` 聚合根的方法执行业务逻辑. `RepositoryImpl` 负责智能持久化。
- **挑战:** `Repository` 实现复杂,涉及全局不变性时仍需**领域事件**实现最终一致性。
其实很多时候一个实体是否能成为聚合根,完全取决于我们怎么看待业务。针对订单和订单项这种场景,并不一定是说订单项就必须是订单聚合内的了,当业务足够复杂,订单项也可以自成一派。
## Q3:Command边界的划分
针对实体有修改操作的动作,我们可以很清晰的认知到他是一个修改动作,需要经过领域层。但类似订单 PDF 导出(查询订单信息并调用 PDF 服务)这样的场景,他实际上并没有对订单本身做修改,那这种场景是Command还是Query?
**A3:** 这更倾向于是一个 **Command 动作**。
- **理由:** 它表达了明确的用户意图(“导出”),并会产生副作用(生成并交付 PDF 文件).
- **`Domain` 层的角色:**
- `Domain` 层**不应直接暴露**如 `exportPdf()` 等与技术格式相关的业务方法.
- `Domain` 层可暴露获取**用于生成 PDF 的“业务数据”**的方法,或封装与 PDF 内容相关的业务规则. 例如,`Order` 聚合根可以提供 `getExportDetails()` 方法返回所需业务数据的 `Value Object`。
- **更佳实践(结合 CQRS):** `Application` 层的 `UseCase` 协调整个流程。它会**利用查询路径**通过 `QueryRepository` 获取为 PDF 导出优化的扁平化 `OrderPdfDTO` (读模型数据),然后调用外部的 `PdfGenerationGateway` 完成 PDF 生成。
# 0xFF 结语
原本不想写那么多,但还是洋洋洒洒写了3000多字。感觉真的想把某些事情讲清楚,真的少不了该有的文字和图片。
还是那句话,软件工程没有银弹,身为程序员要做的事情就是把代码写对、写清楚、写顺。
不得不说和gemini一起讨论并引用他的话作为文章内容确实很省事,并且也能让我从中学到不少内容。
AI的时代真好啊。
【心得】我的DDD——理想与现实