适用读者:要把 DDD 从"概念"落成"目录结构 + 编译约束"的架构师;正在被"MVC 里塞了 8 个分布式组件"折磨的人。
前置:S18-A01(边界)、S18-J06(战术建模)。Maven 多模块与工程规范见 S06。
覆盖旧文:A-1535(451 点)、A-1536(391 点,除问题碰撞段)、A-1529(92 点)、A-1530(239 点)、A-1533(编排/触发器 5 点)——合计 1,176 点。
扩展点:E-S18-017(依赖方向的可执行校验)、E-S18-018(类型分包 vs 领域分包)。
上一篇:S18-A01 · 限界上下文与上下文映射(战略 DDD)
下一篇:S18-A03 · MVC 到 DDD 的重构与迁移。
1. 一句话结论
DDD 的工程结构不是"多几个包",而是"把依赖方向钉死":领域层对外零依赖,所有外部资源都通过领域层定义的接口倒置进来。 结构只是手段,能被编译器和测试守住的依赖方向才是资产。
A-1535 把问题讲得比多数教材直白:工程结构存在的目的"是为了承载工程系统开发的模型划分,定义工程服务开发过程中实施标准"——说白了就是哪个层访问数据库、哪个层用缓存、哪个层调外部接口、哪个层做功能实现。
MVC 的问题不是"三层太少",是没有为分布式技术栈预留位置:A-1535 的比喻是"老城区突然引入很多外来人口,不知道怎么安置"。于是在 OrderServiceImpl 里同时出现 RedisTemplate、NacosConfig、RocketMQTemplate、Dubbo——这正是现代 Java 工程腐化的起点。
2. 六边形、洋葱与 COLA 的共同点
A-1535 列了时间线:2004 年 Evans 的《Domain Driven Design》、2005 年 Cockburn 的六边形、2008 年 Palermo 的洋葱。这些架构不是为 DDD 发明的,但都恰好契合 DDD。再加阿里 COLA(偏整洁架构)。
| 骨架 | 入口怎么分 | 外部依赖怎么放 | 特点 |
|---|---|---|---|
| 六边形 Hexagonal | 统一放 trigger(接口/消息/任务一个入口) |
统一放 infrastructure |
入口与出口对称,最贴合"端口-适配器" |
| 洋葱 Onion | 外层 Controller,内层 Domain | 外层 Repository 实现 | 强调"依赖只能向内" |
| 整洁 Clean | Use Case 层显式 | 适配器层(对内/对外两类) | 用例层独立,适合复杂应用逻辑 |
| COLA(阿里) | adapter + app |
infrastructure |
提供脚手架与规范,工程化程度高(见 alibaba/COLA ) |
| 小傅哥分层(旧文) | trigger / app / api / types / case |
infrastructure / gateway |
面向国内常见微服务栈的落地版 |
共同点只有一句:以 domain 为核心,外部资源在边界之外,依赖方向单向朝内。 所以选哪个骨架不重要,重要的是那张"依赖箭头图"是否成立。A-1535 的原话:这些架构"目标都是以领域服务为核心,隔离内部实现与外部资源的耦合"。
3. 分层、模块与依赖方向
3.1 一套可落地的分层(旧文口径 + 本文命名)
| 层 | 职责 | 允许依赖 | 禁止出现 |
|---|---|---|---|
trigger(触发器/适配器) |
HTTP/RPC 接口实现、MQ 监听、定时任务 | app、domain(只读)、types |
直接引 infrastructure |
app(应用/编排,可选) |
用例编排、事务边界、对象转换 | domain、types |
业务规则 |
domain(领域) |
聚合、实体、值对象、领域服务、仓储接口、领域事件 | types、JDK |
任何框架、DAO、Redis、RPC |
infrastructure |
仓储实现、缓存、配置、MQ 发送、外部接口实现 | domain(实现其接口) |
被 domain 反向依赖 |
api / types |
对外 DTO、通用响应、枚举、异常 | 无(或仅 JDK) | 反向依赖业务层 |
"case 编排层可选"是旧文明确写的:小项目可以去掉 case,让 trigger 直连 domain——原文 L67 的原话是"但对于一些小项目来说,完全可以去掉这一层",并补充"少量一层对象转换,代码的维护成本会降低很多"。这也是必须纠正的一条绝对化说法:DDD 不是必须四层 + 编排层。
3.2 两条容易搞错的依赖
trigger ──▶ app ──▶ domain ◀── infrastructure
(实现 domain 定义的接口)
infrastructure → domain:这是依赖倒置,不是"下层依赖上层"的错误。判断标准是接口归谁定义:接口在 domain,实现才可以在 infra。trigger → infrastructure:禁止。一旦允许,领域对象就能被"顺路"从触发器传进基础设施,边界失效。这条正是 §4 要用 ArchUnit 钉住的。
3.3 分包方式:类型分包 vs 领域分包(E-S18-018)
A-1535 给了两种,并明确"没有绝对的好和坏":
| 方式 | 结构 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 类型分包 | domain/model、domain/repository、domain/service 各自一层 |
结构统一、工具链友好 | 一个业务改动要跨 5 个包 | 小工程、单领域 |
| 领域分包 | domain/account/{model,repository,service}、domain/trade/{...} |
一个领域的内聚完整,改动集中 | 包数量翻倍,需要约定 | 多领域、中大型工程 |
判据:一个上下文里有 ≥3 个领域 → 领域分包;只有一个领域 → 类型分包更省事。旧文给了同一判断的比喻:"衣服裤子是所有人放一起,还是每人一个衣柜"——两者都对,看你家有几口人。
3.4 对象流转:vo / po / dto / entity / aggregate
| 对象 | 所在层 | 语义 | 陷阱 |
|---|---|---|---|
entity |
domain | 有身份、有行为 | 与 PO 1:1 时容易退化成 PO |
vo / valobj |
domain | 无身份、不可变 | 加了 ID 就变实体 |
aggregate |
domain | 事务边界 | 做成"全家桶" |
po |
infrastructure | 表映射,无业务语义 | 绝不能出现在 domain 方法签名里 |
dto |
api / infra | 跨进程传输,无逻辑 | 与 entity 混用 → 领域被外部契约绑死 |
req/res / response |
api | 接口出入参、包装结果 | 直接透传 PO 字段 → 泄漏表结构 |
对象转换的三条路线,选错了会让分层白做:
| 路线 | 做法 | 适用 | 代价 |
|---|---|---|---|
| 手写转换 | Converter 类逐字段赋值 |
字段少、语义差异大 | 加字段要改两处 |
| MapStruct | 编译期生成映射代码 | 字段多、结构同构 | 需要理解默认映射规则,隐藏 bug 多 |
| 不转换 | 直接复用同一对象 | 只有内部调用 | 一改就传播,分层失效 |
判据:跨层必转换(api ↔ app ↔ domain ↔ po),同层不转换(domain 内部实体与值对象可以直接组合)。转换器应该放在调用方那一层(触发器转 DTO→Command,基础设施转 PO→Entity),这样每一层只认识自己的对象。
A-1535 还有一句很实用的提醒:MVC 下的 XxxVo/XxxReq/XxxRes 现在被"细分到各模块下的实体、聚合、值对象"——这是重构时最容易出错的地方(见 A03 §2 的映射表)。
4. 依赖方向与分层测试(ArchUnit 实测)
规则写在文档里没人守,写在测试里就有人守。下面是实际跑过的最小工程(Java 17 + Maven 3.8.7 + ArchUnit 1.2.1 + JUnit 5.10.2):
s18-layers/
├── pom.xml
└── src/
├── main/java/com/example/order/
│ ├── domain/ Order.java(聚合根) OrderRepository.java(接口) DomainEvent.java(record)
│ ├── infra/ InMemoryOrderRepository.java(实现 domain 接口)
│ ├── app/ OrderApplication.java(编排)
│ └── trigger/ OrderController.java(入口)
└── test/java/com/example/order/LayerTest.java
关键测试(完整版见 runbook.md):
private static final JavaClasses CLASSES =
new ClassFileImporter().importPackages("com.example.order");
@Test
void domain_must_not_depend_on_outer_layers() {
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("..infra..", "..trigger..", "..app..")
.check(CLASSES);
}
@Test
void trigger_must_not_touch_infra() {
noClasses().that().resideInAPackage("..trigger..")
.should().dependOnClassesThat().resideInAPackage("..infra..")
.check(CLASSES);
}
实测结果(2026-11-06):
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.043 s
[INFO] BUILD SUCCESS
只有"绿"还不能证明规则有效——必须验证它能抓住违规。于是故意在 domain 里加了一个 BadDomain,import com.example.order.infra.InMemoryOrderRepository:
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..domain..'
should depend on classes that reside in any package ['..infra..', '..trigger..', '..app..']
was violated (2 times)
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
删除该文件后重跑,恢复 2/2 passed。这一步("先证明规则会失败")是架构测试的验收标准——不会失败的规则等于没写。
另外两条值得加的规则:
| 规则 | 表达式要点 | 抓什么 |
|---|---|---|
| 领域层不得依赖框架 | .should().notDependOnClassesThat().resideInAnyPackage("org.springframework..", "org.apache.ibatis..") |
有人往实体上贴 @TableName |
| 分层无环 | slices().matching("com.example.(*)..").should().beFreeOfCycles() |
包之间互相 import |
成本说明:ArchUnit 是测试期依赖(scope=test),不进生产包;一次构建多 3–5 秒。这个价格换"边界不会在三个月后被悄悄破掉",非常划算。
再加一层验证:同一套规则放进 Spring Boot 3.3.6 的 @SpringBootTest 里同样成立——两个测试(容器装配 + 依赖方向)Tests run: 2, Failures: 0,用时 14.18 s(实测)。这一步必要,因为 @Repository/@Service 会引入 Spring 类型,很容易顺手把框架注解带进 domain;规则在容器里跑绿,才说明"领域层零框架依赖"是真的。完整工程见 runbook.md §7。
5. 脚手架与 archetype
A-1529/A-1530 讲的是同一件事的两个版本:把分层固化成模板,让新建工程不再靠"复制粘贴上一个项目"。
| 版本 | 做法 | 适用 |
|---|---|---|
| 本地版(A-1529) | scaffold 模块 mvn clean install → 本地仓库出现 archetype → IDEA 用 Maven Archetype 新建 |
团队内网、无仓库权限 |
| 在线版(A-1530) | 把 archetype 发布到 Maven 仓库(Nexus/中央),配 archetype-catalog.xml |
多团队、需要版本管理 |
旧文提供两套模板:轻量版(HTTP 为主,缓存用 Guava、任务用 Quartz、消息用 Spring Event)与标准版(Dubbo + Nacos + Redis + ShardingSphere + XXL-JOB + RocketMQ)。这个划分本身就是一个重要判断:不是所有项目都该上全家桶。
脚手架的三条纪律(从旧文的实践里提炼):
- 模板要能"减"。轻量版必须先可用,否则新人第一天就在配 Nacos。
- 环境安装与工程模板分开。旧文把
docs/dev-ops的docker-compose.yml单独放,一条命令起全栈——这比写 20 页安装文档有效。 - 模板要跟着架构演进走。脚手架一旦落后于主干(比如主干已用 Spring Boot 3.3,模板还是 2.7),新人就会"生成完再手工升级",模板的意义归零。
最低成本的替代方案:如果维护 archetype 太重,用一份 docs/architecture.md + 一个 LayerTest + 一个 CI 模板也能达到 80% 的效果——新人新建模块后,CI 会替他检查依赖方向。
4.4 分层测试的三种形态
边界要被守住,光靠一条 ArchUnit 规则不够,三种测试各管一段:
| 形态 | 测什么 | 工具 | 运行时机 | 失败含义 |
|---|---|---|---|---|
| 单元测试 | 领域对象的不变式 | JUnit 5(不启容器) | 每次保存 | 业务规则被破坏 |
| 架构测试 | 包/层的依赖方向 | ArchUnit / jdeps | 每次构建 | 有人越界(如 domain 引入 MyBatis) |
| 契约测试 | 上下文之间的接口 | Spring Cloud Contract / Pact | 每次接口变更 | 上下游模型漂移(对应 A01 的 ACL) |
三者缺一,边界就会从缺口漏掉:没有单元测试 → 充血对象没人敢改;没有架构测试 → 依赖方向三个月后失效;没有契约测试 → 上游改个字段,下游静默出错。成本上,前两者几乎为零(一次配置),契约测试需要上下游配合,建议只对"跨团队"的接口做。
6. 常见误区
| 误区 | 真相 |
|---|---|
| "DDD 必须四层 + case 编排层" | case 可选;小项目 trigger 直连 domain 更省 |
| "infrastructure 依赖 domain 是分层错误" | 接口归 domain 定义时,这是依赖倒置,正是设计目标 |
| "分层写在文档里就行" | 不写成测试,三个月后必然被绕过 |
| "领域分包一定优于类型分包" | 单领域工程用领域分包只是增加层级 |
| "脚手架越全越好" | 全家桶模板会让小项目第一天就背 6 个中间件 |
| "PO 可以当参数传给 domain" | PO 进 domain 签名 = 表结构泄漏进领域模型 |
7. 自测
- 你的
domain包里import了哪些三方包?逐条说明为什么必须留。 - 为什么
trigger → infrastructure要禁止,而infrastructure → domain允许? - 你的 ArchUnit 规则有没有"反向验证"过?如果没有,怎么证明它不是摆设?
- 一个工程里有账户、交易、结算三个领域,你会选类型分包还是领域分包?
XxxReq与XxxCommand同时存在的项目,转换应该发生在哪一层?- 脚手架落后主干两个大版本时,你选升级模板还是废掉模板?
下一篇
S18-A03 · MVC 到 DDD 的重构与迁移
结构讲清了,下一篇讲"已经在跑的 MVC 工程怎么迁":分层映射表、依赖倒置的最小改造、绞杀者式迁移路线与回滚点、以及用 ArchUnit + 指标做验收。
本篇属于系列《S18 · 架构方法与领域建模》。旧文来源:A-1535/A-1536/A-1529/A-1530/A-1533;扩展点 E-S18-017、E-S18-018。ArchUnit 测试为 Java 17.0.20 + Maven 3.8.7 + ArchUnit 1.2.1 实测(含故意违规的反向验证);COLA 结构按官方仓库 README 口径。