3533 字
约 11 分钟
7
S18-A02 · DDD 工程结构与脚手架

适用读者:要把 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)。这个划分本身就是一个重要判断:不是所有项目都该上全家桶。

脚手架的三条纪律(从旧文的实践里提炼):

  1. 模板要能"减"。轻量版必须先可用,否则新人第一天就在配 Nacos。
  2. 环境安装与工程模板分开。旧文把 docs/dev-ops 的 docker-compose.yml 单独放,一条命令起全栈——这比写 20 页安装文档有效。
  3. 模板要跟着架构演进走。脚手架一旦落后于主干(比如主干已用 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. 自测

  1. 你的 domain 包里 import 了哪些三方包?逐条说明为什么必须留。
  2. 为什么 trigger → infrastructure 要禁止,而 infrastructure → domain 允许?
  3. 你的 ArchUnit 规则有没有"反向验证"过?如果没有,怎么证明它不是摆设?
  4. 一个工程里有账户、交易、结算三个领域,你会选类型分包还是领域分包?
  5. XxxReq 与 XxxCommand 同时存在的项目,转换应该发生在哪一层?
  6. 脚手架落后主干两个大版本时,你选升级模板还是废掉模板?

下一篇

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 口径。

S18-A02 · DDD 工程结构与脚手架
https://www.clxhxhhr.top/posts/5442/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。