Chapter 08
架构与陷阱:把构件装进容器,并校准判断力
前面建好了所有构件——聚合、值对象、领域事件、仓储、领域服务。本章把它们装进"依赖指向内"的容器,并校准判断力:分清 DDD 说什么、架构说怎么,知道何时该退回 CRUD。
本章你将建立的 schema
- 一条依赖规则:源码依赖只指向内,领域不依赖任何东西——六边形、整洁、洋葱是同一规则的三种画法
- 用了六边形 ≠ 在做 DDD:架构是交付机制,统一语言/聚合/上下文映射才是 DDD
- CQRS 与事件溯源都是"按需"补充,不是 DDD 的前置条件
- 限界上下文 ≠ 微服务 1:1:模块化单体优先,服务从边界涌现
- 何时不用 DDD:通用/支撑域、短命工具、无领域专家 → 退回事务脚本 / CRUD
前七章造的是构件。本章问两个不同的问题:这些构件装在什么形状的代码结构里(架构),以及——更要紧的——这一摊业务到底该不该上全套 DDD(判断力)。前一半讲六边形/整洁/洋葱与两个补充模式(CQRS、事件溯源);后一半把刀口转向自己,列出何时退回 CRUD,以及一张陷阱合集。贯穿全章的还是「云市」:销售上下文的 Order 聚合、它的 OrderRepository,以及库存上下文的 StockItem。
8.1六边形架构 / 端口与适配器 Hexagonal:依赖只指向内
六边形架构(Hexagonal Architecture,又名端口与适配器 Ports & Adapters):领域在最里层、不依赖任何外部技术;所有数据库、HTTP、UI 都是外层适配器,源码依赖一律指向内。
前面六章把领域逻辑(不变量、状态机、定价规则)当宝贝一样保护起来。但只要 Order 这个类 import javax.persistence.*、字段上挂 @Entity、方法里调 JDBC,领域就被数据库技术绑死了——换存储、写单元测试、跨上下文复用都被拖累。六边形给出一条硬规则把这件事制度化:领域不许知道任何外部技术的存在。
底层机制(比文档深一层):六边形的全部内容是一条依赖规则(dependency rule)——源码层面的 import 依赖只能指向内,最内层是领域模型,它不 import 任何东西(除了语言标准库)。问题随之而来:领域要存订单,怎么存?它又不许依赖数据库。答案是依赖倒置(Dependency Inversion):领域层自己声明一个端口接口(如 OrderRepository),表达"我需要一个能存取订单的东西";基础设施层写一个适配器(如 JpaOrderRepository)去 implements 这个接口。这样接口归领域、实现归基础设施,依赖箭头就从"基础设施"指向"领域"——而不是反过来。运行期靠 IoC 容器把适配器实例注入领域用到的端口(这正是 01 章 Spring 的 DI 在 DDD 里的位置)。
场景走查:OrderRepository 端口 + JpaOrderRepository 适配器
「云市」销售上下文要存订单。端口接口放在领域层,用领域语言说话(add / orderOfId,不是 save / findById);JPA 适配器放在基础设施层,去实现它。
// 包: com.yunshi.sales.domain —— 领域层
// 不 import 任何 JPA / SQL / Spring 持久化类型。整个文件只认识领域概念。
public interface OrderRepository {
void add(Order order); // 加入一个新聚合
Order orderOfId(OrderId orderId); // 按标识取回聚合
void remove(Order order);
}
// 包: com.yunshi.sales.infrastructure —— 基础设施层
// 这里才允许 import JPA。它依赖领域(implements 领域的接口),领域不依赖它。
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaMapper jpa; // Spring Data 仓库,纯技术细节
public JpaOrderRepository(OrderJpaMapper jpa) { this.jpa = jpa; }
@Override
public void add(Order order) {
jpa.save(OrderRecord.fromDomain(order)); // 领域对象 ↔ 持久化记录互转
}
@Override
public Order orderOfId(OrderId orderId) {
return jpa.findById(orderId.value())
.map(OrderRecord::toDomain)
.orElseThrow(() -> new OrderNotFound(orderId));
}
@Override
public void remove(Order order) { jpa.deleteById(order.id().value()); }
}
关键不在代码量,而在箭头方向:JpaOrderRepository 这个文件 import 了领域的 Order / OrderRepository;领域那个文件一行都没 import 基础设施。删掉整个基础设施包,领域仍能编译、仍能用内存假实现做单元测试。这就是"领域不依赖任何东西"落到 import 语句上的样子。
import 领域、实现领域声明的端口;领域反过来一无所知。换数据库 = 换一个适配器,碰不到核心。与下一节的关系:这条"依赖指向内"的规则不止六边形一种画法。整洁架构和洋葱架构画的是同一件事,只是换成同心圆。
8.2整洁 / 洋葱架构 Clean / Onion:同一规则的同心圆,且 ≠ DDD
整洁架构(Clean Architecture)与洋葱架构(Onion Architecture)是同一条依赖规则的同心圆表述;它们是交付机制,用了它们不等于在做 DDD。
团队常把"我们上了整洁架构"当成"我们在做 DDD"的证明。这是一次概念混淆,代价是真把领域当回事的功夫没下:分了层,但 Order 仍是贫血数据袋,业务逻辑全在 Service 里——架构对了,领域空了。要拆掉这层误会,先看清这两件事处在不同维度。
底层机制(比文档深一层):洋葱(Palermo)和整洁(Martin)都把代码画成同心圆——最内是领域模型/实体,外面一圈是应用服务/用例,再外面是接口适配器,最外是框架与驱动。圆环之间只有一条约束,和六边形那条字字相同:依赖只能从外指向内,内层不知道外层。换句话说,六边形、整洁、洋葱是同一规则的三种画法,不是三种架构。它们解决的是"代码怎么分层、依赖怎么摆",属于怎么做(how)。
而 DDD 解决的是做什么(what):和领域专家一起提炼统一语言(Ubiquitous Language)、按真正不变量划聚合(Aggregate)、在限界上下文之间做上下文映射(Context Mapping)。这些都是关于"模型长什么样"的决定,一行架构图都画不出来。所以——
整洁 / 洋葱 / 六边形给你一个干净的容器。容器里装的是不是真领域模型,是另一回事。一个有统一语言、有富血聚合、有显式上下文映射的分层 MVC,比一个画着完美同心圆、内层却是贫血数据袋的工程更接近 DDD。架构是必要的脚手架,不是 DDD 的充分条件。
与下一节的关系:上面三种架构都假设"读和写共用一个模型"。当读写的形状真正分叉时,CQRS 把这个假设拆开。
8.3CQRS:读写分模型,是补充不是前置
CQRS(Command Query Responsibility Segregation,命令查询职责分离):写用领域模型走聚合保不变量,读用专门的查询模型直接拼成页面要的形状——两边是两套模型。
「云市」的 Order 聚合为了守不变量长得很"立体"(聚合根 + OrderLine[] + 状态机)。但订单列表页只想要一张扁平表:订单号、买家名、总额、状态、下单时间。用聚合去拼这张列表,要么把聚合撑大、要么 N+1 查询。写和读的最优形状不一样,硬塞进同一个模型,两头都别扭。
底层机制(比文档深一层):CQRS 把命令(改状态)和查询(读状态)分给两套模型。命令侧:应用服务 load 出 Order 聚合、调方法、过仓储——这条路必须走聚合,因为它要保不变量、用乐观锁(05 章)。查询侧:直接对着读库或视图写 SQL,组装成 OrderSummaryView 这样的扁平 DTO,完全绕过聚合和仓储,不经过任何领域不变量。轻量做法(CQRS-lite)两侧共库,只是分两条代码路径;重量做法两侧分库,写库发事件、读库异步投影,于是引入最终一致(07 章那条线又出现了)。
CQRS 是补充,不是前置条件。只在读写形状或负载真正分叉时用:复杂报表/列表、读远多于写、读写要独立扩展。给「云市」每一个聚合都配一套命令模型 + 查询模型 + 投影器,是把一个局部优化升级成全局复杂度——大多数支撑型聚合,一个仓储 + 几个查询方法就够了。
与下一节的关系:CQRS 的重量版要写库发事件。再往前一步——干脆只存事件、不存当前状态——就是事件溯源。
8.4事件溯源 Event Sourcing:按需用,且 Kafka 不是事件存储
事件溯源(Event Sourcing):不存对象的当前状态,只存它经历过的事件序列;当前状态靠把事件依次重放(replay)算出来。
传统持久化只留"现在余额是 100",丢掉了"怎么变成 100 的"。有些领域里,历史本身就是业务:账务要逐笔审计、风控要回放当时决策、运营要看一个 Order 从 CREATED 到 COMPLETED 每一步谁在何时做了什么。事件溯源把这串过程当作唯一真相来源(source of truth)存下来。
底层机制(比文档深一层):聚合的每一次状态变更都先产出一个领域事件(OrderPlaced、OrderPaid、StockReserved……,命名仍用 07 章的过去式),按发生顺序追加进事件存储。要取回聚合当前状态,就把它的事件序列从头重放。这天然带来审计、时间旅行、按需重建读模型的能力。代价也实打实:查询当前状态要重放(常配快照优化)、事件 schema 演化很难、整个团队的心智模型要换。所以——
事件溯源是按需的重型工具,只在审计 / 回放 / 强时序是核心需求时用,且通常只用在核心域的少数聚合上。CRUD 业务、MVP、短命系统不要用——你会为一个用不上的能力付全部的复杂度。「云市」里,账务和订单状态轨迹这类才值得;一个后台的"运营公告"聚合绝不值得。
一个反复出现的失败模式:拿 Kafka 当事件存储来做事件溯源。Kafka 是分发层(消息总线),不是事件存储。事件溯源要求按实体(聚合实例)取回那一条事件流,并在追加时做乐观并发控制(防止两个写入者基于同一版本并发追加)——这正是 05 章聚合作为版本号单位的延伸。Kafka 按分区/主题组织,不提供"按聚合 ID 取流 + 版本冲突检测"这两件事。要做事件溯源,用 EventStoreDB 这类事件存储;Kafka 留给"把已落库的事件分发出去"。
团队说:"我们已经用 Kafka 发 OrderPlaced 给库存上下文了,那把订单的全部历史事件也都堆进一个 Kafka 主题,不就等于事件溯源了?" 这个推理哪里断了?
停 10 秒,再展开
断在"取回单个聚合的状态"这一步。事件溯源要能只读出 orderId=42 这一条订单的全部事件、按序重放得到它的当前状态,并在写入时检测版本冲突。Kafka 主题是按分区顺序消费的日志,没有"给我 orderId=42 的事件流"这种按实体的随机检索,也没有针对某个聚合实例的乐观锁。它能分发事件,不能当聚合的真相来源被按实例查询和并发保护。发事件用 Kafka 没问题;把它当事件存储用,缺的恰是事件溯源赖以成立的两件事。
与下一节的关系:CQRS、事件溯源都是"按上下文、仅核心域投入"的局部决定。同样的克制,要用在一个更大的边界上——限界上下文和微服务的关系。
8.5限界上下文 ≠ 微服务 1:1:服务从边界涌现
一个限界上下文可以、但不必对应一个微服务;模块化单体优先,服务在有真实部署/扩展/团队压力时才从边界涌现。
"一个限界上下文 = 一个微服务"曾是被广泛传播的硬规则。照做的团队拿到的常是:五个上下文五个服务,本可进程内调用的 OrderPlaced 被迫走网络、跨服务事务变成分布式难题、运维成本翻几倍——而业务量根本不需要独立扩展。这是把语义边界误当成了部署边界。
底层机制(比文档深一层):限界上下文(03 章)是语义/模型边界——同一个词在边界两侧是不同的类(「云市」的 Product 在 Catalog 是胖展示对象、在 Sales 是下单快照)。微服务是部署/运行时边界——能独立部署、独立扩展、独立数据库、独立团队负责。这是两个维度:前者关于代码怎么建模,后者关于进程怎么切分。它们可以对齐,但不强制 1:1。
当前共识(2026)已经把硬规则推翻,换成更克制的顺序:
- 先做模块化单体(modular monolith):上下文之间用模块/包边界划清,进程内调用,一套部署。边界在代码里是真实的,运维成本却接近单体。
- 让服务从边界涌现:当某个上下文出现真实的独立部署节奏、独立扩展需求(如库存上下文流量是订单的十倍)、或独立团队所有权时,再沿那条已经划好的上下文边界把它切出去成一个服务。
- 边界划对了,这一刀很好切;边界没划对,拆成微服务只是把烂泥摊到网络上。所以先把上下文边界做对,比先拆服务重要得多。
与下一节的关系:以上全是"上了 DDD 之后怎么摆"。但有一个更前置的问题没问——这摊业务到底该不该上 DDD。
8.6何时不用 DDD:退回事务脚本 / CRUD
通用域、支撑域、短命工具、拿不到领域专家的场景,应退回事务脚本 / Active Record / CRUD 脚手架;对这类子域,贫血 + Service 层是合理默认。
02 章的核心论断是均匀地用 DDD 最贵:聚合、值对象、仓储、领域服务、上下文映射这套功夫只在复杂的核心域上回本。把它平铺到每个 CRUD 后台,得到的是大量样板、被架构淹没的简单逻辑、以及团队对 DDD 的反感。所以"不用 DDD"是一个需要被主动做出的设计决定,不是偷懒。
底层机制(比文档深一层):三个信号指向"退回简单做法"——
- 子域类型是通用/支撑:通用域(如「云市」的发票、对账)能买就买、能用现成框架就用;支撑域(如运营后台、字典维护)逻辑浅,CRUD 足够。只有核心域(Sales 的下单与定价)值得全套。
- 短命工具 / MVP:活不过半年的内部脚本、待验证的原型,建模投资来不及回本。
- 没有领域专家:统一语言要和懂业务的人共建。没有专家,提炼不出真正不变量,建出来的"聚合"是凭空想象,还不如老实 CRUD。
退回的具体形态:事务脚本(Transaction Script)——一个用例一段过程式代码,从上到下把这件事办完;Active Record——对象直接映射一行表、自带增删改查;CRUD 脚手架——框架按表生成的标准增删改查。对这些子域,OrderController → OrderService → OrderDao 这种贫血对象 + Service 层结构是合理默认,不是反模式。
Fowler 早年那篇《Anemic Domain Model》把"贫血模型"列为反模式。但后续讨论里他和社区都软化了立场:贫血是不是反模式,取决于子域。在一个本质就是 CRUD 的支撑子域上,把数据和行为分开(贫血对象 + Service 层)是恰当的、甚至更清晰的选择——它只在复杂核心域里才是问题,因为那里业务规则散进 Service、对象退化成数据袋会让真正的复杂度无处安放。一句话:贫血模型是"用错地方"的反模式,不是"哪都不能用"的反模式。
与下一节的关系:把上面所有判断浓缩成一张表——常见陷阱各自的根因和修复。
8.7常见陷阱合集:根因 → 修复
把前八章踩过的失败模式列在一张表里:每一行先点根因(违反了哪条原则),再给修复方向。
| 陷阱 | 根因(违反了哪条) | 修复方向 |
|---|---|---|
| 上帝聚合(God Aggregate) | 把 has-a 对象图当聚合,按对象关联而非真正不变量划边界 → 巨大聚合成乐观锁争用热点(05 章) | 按真正不变量重切:只把"必须同事务保持一致"的对象留在内;其余按标识引用、移到独立聚合,跨聚合走最终一致 |
| 按表建聚合 | 从数据库表关联倒推聚合,让存储结构决定领域模型(02 / 05 章) | 先从行为和不变量推出聚合,再让仓储把它映射到表;表是适配器的事,不是领域的形状 |
| ORM 注解泄漏进领域 | @Entity / @Column 打在 Order 上,领域 import 了持久化技术,违反依赖规则(8.1) |
领域类保持纯净;在基础设施层用独立的持久化记录 + 映射器(OrderRecord.fromDomain)做转换 |
| 跨聚合事务 | 一个事务里改多个聚合(下单同时扣 StockItem),违反"一个事务一个聚合"(05 / 07 章) |
一个事务只改一个聚合;跨聚合用领域事件 + 最终一致(OrderPlaced → 异步预留库存) |
| 共享内核腐化 | 把本该各自建模的 Product 塞进一个"共享"模块,两个上下文被迫共用一个长满矛盾字段的类(03 章一词多义) |
共享内核只留真正稳定、双方都同意的极小核;其余在边界处用防腐层(ACL)翻译,各上下文留各自的模型 |
| 把 EDA 当领域事件 | 把技术集成消息("缓存失效""行已更新")当领域事件,事件里没有领域语言(07 章) | 领域事件是业务事实、用统一语言命名过去式(OrderPaid);纯技术消息归集成层,别混进领域 |
| 给 CRUD 套全套 DDD | 在通用/支撑域、短命工具上铺聚合 + 仓储 + 上下文映射,违反"均匀用 DDD 最贵"(8.6) | 对这类子域退回事务脚本 / Active Record / CRUD;把建模预算省给核心域 |
七行陷阱可以归到一句话:DDD 的每个构件都对应一条原则,越界用就成陷阱。聚合对应一致性边界、依赖规则对应技术隔离、领域事件对应业务事实、全套 DDD 对应核心域投资——把它们用在违背各自前提的地方,就是这张表里的每一行。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 六边形 / 整洁 / 洋葱共有的那条"依赖规则"具体指向哪个方向?领域层为什么能声明
OrderRepository接口、却由基础设施层去实现它而不违反这条规则? - 一个团队画了完美的整洁架构同心圆,但
Order仍是只有 getter/setter 的数据袋。它在做 DDD 吗?用一句话说清架构和 DDD 处在什么关系。 - CQRS 和事件溯源分别在什么条件下才该用?为什么说它们是补充而不是 DDD 的前置?
- 为什么"一个限界上下文 = 一个微服务"是被推翻的硬规则?限界上下文和微服务各自是哪个维度的边界?
- 给出至少两个"应该退回 CRUD / 事务脚本、不上全套 DDD"的信号。在这类子域里,贫血 + Service 层是反模式吗?
答案(先做完再展开)
- 源码依赖只指向内,最内层领域不依赖任何外部技术。靠依赖倒置:接口(端口)属于领域层、由领域自己声明;
JpaOrderRepository在基础设施层implements它,于是依赖箭头从基础设施指向领域——符合"指向内"。 - 不算(或至少没在做 DDD 的核心)。架构(整洁/洋葱/六边形)是怎么做的交付机制,DDD 是做什么——统一语言、按不变量划聚合、上下文映射。有干净容器但装着贫血模型,离 DDD 比一个富血的分层 MVC 更远。
- CQRS:读写形状/负载真正分叉时(复杂列表报表、读远多于写、读写要独立扩展);事件溯源:审计/回放/强时序是核心需求时,且通常只用在核心域少数聚合。它们都是按上下文、仅核心域投入的补充,不是 DDD 成立的前提——CRUD/MVP 不该用。
- 因为它把语义边界误当部署边界:强行 1:1 会把进程内调用变网络、把单聚合事务变分布式难题。限界上下文是模型/语义维度的边界,微服务是部署/运行时维度的边界。共识:模块化单体优先,服务在有真实压力时从边界涌现。
- 信号:①子域是通用/支撑而非核心;②短命工具/MVP;③拿不到领域专家。在这类子域里,贫血 + Service 层不是反模式,而是合理默认(Fowler 后期也软化为"贫血是否反模式取决于子域")——它只在复杂核心域里才是问题。
给这个"全套 DDD 包了 CRUD 后台"的场景判过度设计
「云市」团队给运营后台的"系统公告"模块上了全套 DDD:建了 Announcement 聚合根(内含 AnnouncementContent 值对象、PublishWindow 值对象)、AnnouncementRepository 端口 + JPA 适配器、AnnouncementPublishingService 领域服务,发布时还发 AnnouncementPublished 领域事件走异步投影到一个 CQRS 读模型。该模块的全部业务是:标题、正文、起止时间,增删改查,运营手动点发布。请判断这是不是过度设计,指出违反了本章哪条判断,并给出替代方案。
提示(卡住再展开)
是过度设计。对照 8.6 的三道关卡:①子域类型——运营公告是支撑域,不是核心域;②复杂度——只有标题/正文/时间 + 增删改查,没有真正不变量("起 ≤ 止"这种校验一个 if 就够,撑不起聚合);③这里也谈不上需要领域专家深挖。三关全不过,按图 8.2 应走右边那个简单框。CQRS + 事件溯源式的异步投影更是无源之水:读写形状没分叉、没有审计/回放需求(8.3 / 8.4 的使用边界都不满足)。替代:一张 announcement 表 + Announcement(Active Record 或贫血实体)+ AnnouncementService 做几个增删改查方法 + 标准 CRUD 控制器。把省下的聚合/事件/CQRS 预算,留给 Sales 的下单与定价那个真正的核心域。