191 个提交、11 个 Bounded Context、5 天------我是如何把一个典型 Spring MVC 项目改造成真正的 DDD 架构的。
一、开篇:从最简单的 MVC 开始
我的项目最初只有三层。
Controller 接 HTTP 请求,Service 写业务逻辑,Mapper 读写数据库------标准 Spring MVC,没有任何过度设计,跑了两年没出大问题。
然后业务开始增长。11 个业务领域、越来越多的跨模块调用、无法独立测试的业务逻辑。某天,我需要修改某个业务规则。打开对应的 Controller,顺着调用链往下追,发现一次数据库操作要穿越三个中间层:
bash
controller/AchievementController
→ service/business/AchievementUnlockService ← 业务服务层
→ service/impl/UserAchievementServiceImpl ← MyBatis-Plus ServiceImpl
→ repository/UserAchievementMapper ← 数据库
这就是项目里的三跳依赖链。改一个业务规则,需要同时理解四个文件的职责,而且这条链在全部 11 个业务领域里都存在。
这篇文章记录的,是我在 5 天内用 191 个提交把它改造成 DDD 架构的完整过程。能在 5 天内完成而没有失控,关键靠两件事:Claude Code Superpowers 技能库提供了工程纪律(brainstorming、TDD、systematic-debugging、verification-before-completion),OpenSpec Comet 工作流提供了过程管控(每个 Bounded Context 独立经历 open → design → build → verify → archive,有设计文档、有验证证据、可回滚)。
下面是完整过程。
二、这条链是怎么长出来的
项目初期:合理的起点
我的项目是一个移动端应用的后端服务,初期采用标准的 Spring MVC 分层:
bash
server-api/
├── controller/ # HTTP 接口层
├── service/ # 业务逻辑层(接口)
├── service/impl/ # 业务逻辑层(实现,继承 MyBatis-Plus ServiceImpl)
├── service/business/ # 复杂业务编排
├── repository/ # Mapper 接口
└── ...
common/
├── entity/ # MyBatis-Plus 实体(17 个)
├── param/ # 请求/响应 DTO
├── mapper/ # MapStruct 转换器
└── enums/ # 枚举
这个结构在项目初期完全够用。Controller 调用 Service,Service 调用 Mapper,逻辑清晰。
业务增长后:边界开始模糊
随着业务增长,问题逐渐浮现:
痛点 1:职责边界不清
service/business/ 里的类越来越复杂。有的 Service 只是简单地传递调用(纯粹的"中间层"),有的则混合了 IO 操作、业务规则、DTO 组装------三种完全不同性质的代码住在同一个类里,没有人能说清楚它的边界在哪里。
痛点 2:横向依赖蔓延
模块间的调用开始随意:A 模块需要感知 B 模块的状态,B 模块需要调用 C 模块,C 模块又依赖用户信息。由于大家都在 service/ 这个扁平的包里,跨领域调用直接 @Autowired,形成了一张没有显式边界的依赖网。新工程师看着依赖图会问:"这里真的可以直接调那里吗?"------没有规则告诉他。
痛点 3:common 模块成了垃圾桶
common 模块里混放着三种完全不同的东西:
- MyBatis-Plus 实体(带
@TableName、@TableField注解) - HTTP 请求/响应 DTO(给前端用的)
- 第三方 API 模型(地图服务、身份认证平台、对象存储的 SDK 模型)
结果就是:Controller 直接把持久化实体返回给前端;业务 Service 直接接收 HTTP 请求 DTO;数据库字段变动会直接影响 API 响应------层与层之间没有任何缓冲。
痛点 4:无法独立测试
想对某个核心业务规则写单元测试?你需要 Mock UserAchievementServiceImpl,它继承了 MyBatis-Plus 的 ServiceImpl,后者依赖 BaseMapper,后者又依赖数据库连接......最终写不了纯业务逻辑的单元测试,只能写集成测试。
三、DDD 能解决什么
我不打算在这里做 DDD 理论课。只说和本次迁移直接相关的三个概念:
Bounded Context(有界上下文) :把系统拆成若干个边界清晰的子系统,每个子系统内部自治,对外通过明确定义的接口通信。在我的项目里,achievement、auth_user、travel 等 11 个领域各自是一个 Bounded Context。
四层结构:每个 Context 内部按职责分层:
interfaces → 处理 HTTP,不含业务逻辑
application → 用例编排,定义端口(抽象)
domain → 纯业务规则,不依赖任何框架
infrastructure → 实现端口(数据库、外部 API)
端口与适配器:Application 层只定义"我需要什么"(接口/端口),Infrastructure 层负责"怎么实现"(适配器)。业务逻辑对数据库、HTTP、消息队列完全无感知。
这三个概念正好对应上面那三个痛点:Context 划分解决横向依赖问题,四层结构解决职责混乱问题,端口与适配器解决可测试性问题。
四、迁移路线图
在动手之前,我制定了四个阶段的迁移路线:
阶段一:建骨架(2天)
引入四层包结构 + ArchUnit 规则,旧代码原地保留
阶段二:分离模型(1天)
建立模型分类规则,明确 API DTO / 持久化实体 / 领域对象的边界
阶段三:逐 Context 迁移(7天)
11 个 Context 逐一迁移,每个独立分支可验证可回滚
阶段四:消灭中间层(1天)
删除 service/ 和 repository/ 旧包,三跳链归零
核心原则:不搞大爆炸式重构。 旧代码在迁移期间继续运行,每个 Context 独立迁移、独立验证,随时可以停下来。
五、工具链:OpenSpec Comet + Superpowers
让 191 个提交没有失控,靠的不只是分阶段策略,还有两个贯穿全程的工具。
OpenSpec Comet:每个 Context 是一个独立 change
我用 OpenSpec 的 Comet 工作流管理每一个 Context 的迁移。每个 Context 对应一个独立的 change record,经历五个阶段:
arduino
open → design → build → verify → archive
- open:明确这个 Context 的迁移范围,列出涉及的 Service、Mapper 和对外依赖
- design:通过 brainstorming 推演领域模型划分,产出 Design Doc,确定端口定义和层次边界
- build:按 writing-plans 生成的实现计划推进,每个 task 完成后立即 commit,不积压
- verify:编译通过 + ArchUnit 规则绿 + 集成测试绿,留下验证证据
- archive:归档变更记录,Context 正式完成
11 个 Context = 11 个独立 change。任何一个出问题,直接回到它的 change record 找设计决策和验证证据,不影响其他 Context 的进度。
Superpowers:给每个阶段配对应的工程纪律
Comet 管流程,Superpowers 管执行质量:
- brainstorming(design 阶段):在写第一行代码前,用结构化的方式探索领域模型划分和端口设计。auth_user 这个最复杂的 Context,DeviceId 是值对象还是普通字段、IdentityPort 归 application 层还是 domain 层,都是 brainstorming 阶段确定的,不是写代码写到一半才临时决定的。
- writing-plans + TDD (build 阶段):先出实现计划,再按 TDD 节奏推进------测试先于实现代码存在,domain 层的单元测试可以直接
new跑,毫秒级完成。 - systematic-debugging(遇到失败时):遇到 Spring Bean 冲突、事务失效这类问题,不绕过,先定根因,确认原因后再动手修。
- verification-before-completion(verify 阶段):每个 Context archive 前跑一遍验收清单------ArchUnit 规则通过、集成测试绿、旧 Service 引用清零。没有"感觉差不多了",只有"清单通过了"。
六、第一阶段:建骨架
引入包结构
在主模块里新增 context 根包,每个业务领域对应一个子包:
com.example.app.context/
├── auth_user/
│ ├── interfaces/
│ ├── application/
│ ├── domain/
│ └── infrastructure/
├── travel/
├── achievement/
└── ...(共 11 个 Context)
关键决策 :旧的 controller/、service/、repository/ 包原地保留 ,不动。新的 DDD 代码只进 context.*,两套代码共存,直到每个 Context 迁移完毕。
用 ArchUnit 建护栏
光有包结构不够,工程师在压力下很容易走回头路。我在测试中加入了 ArchUnit 规则,作为自动化的架构护栏:
rust
规则 1:domain 层不得依赖 Spring MVC、MyBatis、任何 HTTP/持久化框架
规则 2:application 层不得依赖 infrastructure 层
规则 3:context 包不得依赖 service.impl 和 repository 旧包
规则 4:不同 Context 之间不得直接依赖(只能通过 application port)
这些规则在 CI 中自动运行,违规则构建失败。护栏建好,可以放心迁移。
这四条规则不是拍脑袋定的------第一个 Context 开工前,我在 OpenSpec 的 design 阶段用 brainstorming 推演了"各层允许依赖什么",才有了这个清单。规则是结论,brainstorming 是过程。
七、第二阶段:分离模型
在迁移业务逻辑之前,必须先搞清楚"模型"的归属问题。
common 模块里的 17 个 MyBatis-Plus 实体和 API DTO 当时是混在一起用的。我建立了分类规则:
| 模型类型 | 描述 | 迁移去向 |
|---|---|---|
| 持久化实体 | @TableName 注解的 MyBatis-Plus 类 |
留在 common,由各 Context 的 infrastructure 层使用,映射到领域对象 |
| API 契约 | HTTP 请求/响应 DTO | 留在 common,迁移时由 interfaces 层的 Assembler 负责转换 |
| 领域模型 | 承载业务规则的纯 Java 对象 | 在各 Context 的 domain 层新建,不复用旧实体 |
| 外部适配器模型 | 第三方 SDK 的请求/响应对象 | 移入对应 Context 的 infrastructure 层 |
核心原则 :持久化实体和领域模型不是同一个东西。领域模型只有业务含义,不带任何 ORM 注解;持久化实体只负责数据库映射,不含业务规则。两者之间的转换是 Infrastructure 层的职责。
这个规则让所有工程师在迁移时有了明确参照,避免了"我是把旧实体直接改成领域模型,还是新建一个?"的争议。
八、第三阶段:逐 Context 迁移
迁移策略
每个 Context 按固定模式迁移,整体顺序从"依赖少"到"依赖多":
auth_user → travel → achievement → task_reward
→ nearby_social → time_capsule → push_notification
→ telemetry_progress → file_import → map_geospatial → scheduler_ops
每个 Context 迁移时的步骤:
- 创建领域模型(聚合根、值对象)
- 在
application/port/定义所需端口(接口) - 在
application/usecase/编写用例(注入端口) - 在
infrastructure/实现端口(持久化、外部 API) - 在
interfaces/迁移 Controller,引入 Assembler - 删除旧的
service/business/Xxx和service/impl/XxxServiceImpl
以 auth_user 为例
auth_user 是第一个迁移的 Context,也是最复杂的之一:UserLoginServiceImpl 同时承担设备 ID 清洗、第三方身份认证 API 调用、用户查找/创建、token 生成、缓存写入、DTO 组装------典型的事务脚本。
迁移后的结构:
bash
context/auth_user/
├── interfaces/
│ ├── LoginController.java # 接收 HTTP,调用 UseCase
│ ├── UserInfoController.java
│ ├── LoginAssembler.java # Request → Command / Result → DTO
│ └── UserAssembler.java
├── application/
│ ├── port/
│ │ ├── UserRepository.java # 接口:findByOpenId / save
│ │ ├── CurrentUserPort.java # 接口:getCurrentUserId
│ │ └── IdentityPort.java # 接口:resolveIdentity(code)
│ └── usecase/
│ ├── LoginUseCase.java
│ ├── RegisterUserUseCase.java
│ └── ...(9 个用例)
├── domain/
│ ├── model/
│ │ ├── User.java # 聚合根,含 bindDevice() 等业务方法
│ │ └── DeviceId.java # 值对象,设备 ID 清洗逻辑
│ └── service/
│ └── TokenDomainService.java # 纯 token 生成/验证逻辑
└── infrastructure/
├── UserPersistenceAdapter.java # implements UserRepository
├── SessionCacheAdapter.java # implements CurrentUserPort
├── IdentityAdapter.java # implements IdentityPort
├── HttpTokenExtractor.java # 隔离 Servlet API
└── mapper/
└── UserInfoMapper.java # 从 repository/ 移入
原来 UserLoginServiceImpl 的 200+ 行代码被分解到了正确的位置:
- 设备 ID 清洗 →
DeviceId值对象(domain) - 第三方身份认证 API →
IdentityAdapter(infrastructure) - token 生成规则 →
TokenDomainService(domain) - 用例编排 →
LoginUseCase(application) - DTO 组装 →
LoginAssembler(interfaces)
每一块代码现在都有且仅有一个职责。
这个结构不是边写边想出来的。auth_user 的 design 阶段,brainstorming 结束后产出了 Design Doc,明确了 DeviceId 是值对象、IdentityPort 归 application 层、IdentityAdapter 归 infrastructure 层。build 阶段按 Design Doc 执行,writing-plans 把实现步骤拆成了可逐一验收的 task,每个 task 完成后立即 commit。
business service 的三种归宿
迁移过程中,service/business/ 里的代码有三种归宿,这是判断的核心逻辑:
bash
service/business/XxxService
├── 纯业务不变量(无 IO) → context/domain/service/
├── 含 IO 的编排逻辑 → context/application/usecase/(内联或新建)
└── 持久化辅助逻辑 → context/infrastructure/XxxPersistenceAdapter(内联)
这个分类规则帮助我在面对每一个 Service 时,不需要长时间讨论"这段代码应该放哪里"。
九、第四阶段:删掉中间层
当所有 11 个 Context 迁移完毕后,执行最后一步:删掉 service/ 和 repository/ 两个旧包。
这一步出乎意料地顺利------因为 ArchUnit 在阶段一就已经禁止了 context.* 对这两个包的依赖。只要编译和测试通过,旧包就只剩下"死代码",可以安全删除。
删除之后,再来看开篇那条三跳依赖链:
迁移前:
bash
context/achievement/infrastructure/AchievementPersistenceAdapter
→ service/business/AchievementUnlockService
→ service/impl/UserAchievementServiceImpl
→ repository/UserAchievementMapper
迁移后:
bash
context/achievement/infrastructure/AchievementPersistenceAdapter
→ context/achievement/infrastructure/mapper/UserAchievementMapper
三跳变一跳。中间层没了。
十、关键技术决策
D1:Mapper 移包不需要改 @MapperScan,但需要注意类名冲突
我的主应用类没有显式的 @MapperScan 配置,Spring Boot 自动扫描根包下的所有子包。Mapper 接口从 repository/ 移到 context/xxx/infrastructure/mapper/ 后,Spring Boot 自动发现,无需任何配置变更。
但这里有一个隐患:11 个 Context 里有多个 Mapper 会读同一张表(比如某张被多个模块共享的核心表),如果每个 Context 都建一个同名的 Mapper 接口,Spring 在注册 Bean 时会因简单类名相同而产生冲突,应用启动失败。解决方案是给 Mapper 类名加上 Context 前缀:
erlang
achievement/ → AchievementCoreRecordMapper
nearby_social/ → NearbySocialCoreRecordMapper
telemetry/ → TelemetryCoreRecordMapper
...
前缀不只是为了规避冲突------它也让类名在 Spring 的全局 Bean 注册空间里变得自描述,清楚地表明"这是哪个 Context 用来做什么的 Mapper"。
D2:ServiceImpl 用 BaseMapper 方法替代
删除所有 service/impl/ 类后,原来 Adapter 注入 ServiceImpl 的地方改为直接注入 Mapper:
| ServiceImpl 方法 | 替代方案 |
|---|---|
getById(id) |
mapper.selectById(id) |
save(entity) |
mapper.insert(entity) |
updateById(entity) |
mapper.updateById(entity) |
list(wrapper) |
mapper.selectList(wrapper) |
remove(wrapper) |
mapper.delete(wrapper) |
MyBatis-Plus 的 ServiceImpl 本质上只是 BaseMapper 的便利包装,绕过它没有任何损失。
D3:@Transactional 的迁移规则
原来 @Transactional 散落在 8 个 ServiceImpl 方法上,迁移时按照两个原则处理:
- 业务编排事务 (一个用例涉及多个持久化操作)→ 迁移到
UseCase.execute()方法 - 纯持久化事务 (单表批量写入等)→ 留在
Infrastructureadapter 的对应方法上
原则是:事务边界跟着数据一致性单元走,而不是跟着旧代码的位置走。
D4:HTTP 客户端框架的包扫描更新
项目使用了一个声明式 HTTP 客户端框架(通过注解扫描发现接口实现),需要配置扫描路径。外部 API 客户端从 service/api/ 移入各 Context 的 infrastructure/ 后,更新扫描路径:
java
// 迁移前
@HttpClientScan(basePackages = "com.example.app.service.api")
// 迁移后
@HttpClientScan(basePackages = "com.example.app.context")
十一、踩过的坑
这里列出的每个坑,发现方式几乎都一样:build 阶段遇到问题先用 systematic-debugging 定根因,verify 阶段的 verification-before-completion 清单兜底核查。没有一个是上线后才暴出来的。
坑 1:Spring 自调用事务失效
某个 ServiceImpl 里有方法 A 调用方法 B,B 上有 @Transactional。在 Spring 中,自调用不经过 AOP 代理,事务注解失效。把这段逻辑拆分到 UseCase 和 Adapter 后,自调用消失,事务行为才变得符合预期。
坑 2:兼容 Facade 的必要性
SessionService.getCurrentUserId() 被 8 个 Controller 直接依赖。如果迁移 auth_user Context 时同步修改这 8 个 Controller,改动面太大,容易出错。我的做法是:保留 SessionService 接口作为兼容 Facade ,让它委托给新的 CurrentUserPort 实现,等其他 Context 逐步迁移后再移除 Facade。
坑 3:深层继承链的拆解
某个 Controller 调用了多个抽象 Service 的子类,子类之间通过继承共享了大量状态。把它拆成 DDD 结构时,继承关系变成了组合关系------几个 UseCase 共享同一个聚合根查询逻辑,提取成 private 方法或 domain service。这是整个迁移里最费时间的一块,光设计就讨论了半天。
坑 4:null 防御缺失
迁移某个 Context 时,发现原来 userService.listByIds() 在某些边界条件下返回 null,旧代码里没有做防御,靠上层 catch 住了异常。新的 Adapter 需要显式加 null guard,否则会在集成测试中暴露出来。这类隐藏问题在迁移时集中暴露,反而是好事。
坑 5:跨 Context 的类名冲突
把 11 个 Context 的代码同时放进 Spring 容器后,我遇到了另一类命名冲突:不同 Context 里存在简单类名完全相同的 Bean。典型的两个例子:
telemetry_progress和map_geospatial都定义了CalculateDistanceUseCase,Spring 在 BeanFactory 里注册时报冲突。解决方案是把telemetry_progress的改为CalculatePixelDistanceUseCase------名字更精确地描述了职责(计算像素距离,而不是地理距离),同时消除了歧义。achievement和travel都有一个AchievementEventAdapter,travelContext 里的那个负责发布成就事件,改为TravelAchievementEventAdapter后语义更清晰。
规律 :类名冲突本质上是"强迫你把类名里缺失的上下文补回去"。一个类叫 CalculateDistanceUseCase,在它自己的 Context 包里是明确的,但进入 Spring 的全局 Bean 注册空间后,Context 这层语境消失了,歧义随之出现。加上 Context 前缀、或更精确的职责词,是避免这类问题的通用做法------它同时也是一种设计改善,而不只是技术妥协。
坑 6:遗留包不止 service/ 和 repository/
我的迁移路线图把"第四阶段"定义为"删掉 service/ 和 repository/ 两个旧包"。但 file_import 模块还有一个历史遗留的 excel 包(com.example.app.excel),里面放着 BusinessExcelData、BusinessExcelDataListener、BusinessExcelImportService(标记了 @Deprecated)、BusinessExcelService(标记了 @Deprecated)四个类------这套代码游离在标准分层之外,没有被阶段一建立的 ArchUnit 规则覆盖,因此没有在"第四阶段"被一并清理掉,而是额外多出了一次专项清理。
清理方式:将 EasyExcel 行模型移入 file_import/infrastructure,把 RowListener 和导入逻辑内联进 BusinessExcelImportAdapter,删除整个 excel 包。调用链从三跳变成了一跳:
java
// 迁移前(三跳)
FileImportController
→ BusinessExcelImportService(@Deprecated)
→ BusinessExcelService(@Deprecated)
→ BusinessExcelDataListener / Mapper
// 迁移后(一跳)
FileImportController
→ BusinessExcelImportAdapter(内联 EasyExcel 逻辑)
教训 :在制定迁移路线时,"删掉 service/ 和 repository/" 只是清理了命名最典型的遗留层,实际项目里往往还有其他特殊目的的包(Excel 处理、消息推送、定时任务的早期实现等)。建议在阶段一建 ArchUnit 护栏时,同步扫描一遍所有顶级包,把所有遗留路径都纳入禁止名单,而不是只写 service.* 和 repository.*。
十二、回头看:191 个提交值得吗?
迁移完成后,我对比了前后的几个维度:
包结构清晰度
迁移前,想知道"某个业务规则在哪里",需要在 service/business/、service/impl/、repository/ 三个包里搜索,还要理解它们的调用关系。
迁移后,答案是:context/achievement/domain/。找到了,就是这里,全部在这里。
可测试性
Domain 层是纯 Java,没有 Spring、没有 MyBatis、没有任何 IO 依赖。AchievementDomainService 的单元测试可以直接 new AchievementDomainService() 跑,毫秒级完成。
跨 Context 依赖可见性
ArchUnit 规则让跨 Context 的非法依赖在 CI 就暴出来,不会等到上线后才发现。
什么样的项目适合做这件事?
不是所有项目都需要迁移到 DDD。我的经验是,当你遇到以下情况时,这件事开始变得值得:
- 修改一个业务规则需要同时理解 3 个以上的类
- 无法对核心业务逻辑写纯单元测试
- 新工程师无法从包结构判断"我该把这段代码放哪里"
- 跨领域调用越来越随意,没有人知道边界在哪里
如果你只是一个 CRUD 小项目,标准 MVC 就够了。但当复杂度开始让人疲惫,DDD 的结构化收益就会开始显现。
附:迁移前后包结构对比
迁移前(扁平 MVC):
bash
server-api/
├── controller/ # 所有 Controller
├── service/ # 所有 Service 接口
├── service/impl/ # 所有 ServiceImpl(继承 MyBatis-Plus)
├── service/business/ # 复杂业务编排
├── service/api/ # 外部 HTTP 客户端
├── service/push/ # 推送相关
├── service/schedule/ # 定时任务
└── repository/ # 所有 Mapper
迁移后(11 个 Bounded Context):
bash
trip-server-api/context/
├── auth_user/
│ ├── interfaces/ # Controller + Assembler
│ ├── application/ # UseCase + Port(接口)
│ ├── domain/ # 聚合根 + 值对象 + 领域服务
│ └── infrastructure/ # Adapter + Mapper(实现)
├── travel/
├── achievement/
├── task_reward/
├── nearby_social/
├── time_capsule/
├── push_notification/
├── telemetry_progress/
├── file_import/
├── map_geospatial/
└── scheduler_ops/
DDD 不是银弹,迁移也急不来。但当三跳依赖链变成一跳,当每一行代码都知道自己属于哪个边界,当新工程师第一天就能定位业务规则------这种清晰感,是值得花 191 个提交去追求的。