从 MVC 到 DDD:一次真实的渐进式迁移实录

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(有界上下文) :把系统拆成若干个边界清晰的子系统,每个子系统内部自治,对外通过明确定义的接口通信。在我的项目里,achievementauth_usertravel 等 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 迁移时的步骤:

  1. 创建领域模型(聚合根、值对象)
  2. application/port/ 定义所需端口(接口)
  3. application/usecase/ 编写用例(注入端口)
  4. infrastructure/ 实现端口(持久化、外部 API)
  5. interfaces/ 迁移 Controller,引入 Assembler
  6. 删除旧的 service/business/Xxxservice/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)
  • 第三方身份认证 APIIdentityAdapter(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() 方法
  • 纯持久化事务 (单表批量写入等)→ 留在 Infrastructure adapter 的对应方法上

原则是:事务边界跟着数据一致性单元走,而不是跟着旧代码的位置走。

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_progressmap_geospatial 都定义了 CalculateDistanceUseCase,Spring 在 BeanFactory 里注册时报冲突。解决方案是把 telemetry_progress 的改为 CalculatePixelDistanceUseCase------名字更精确地描述了职责(计算像素距离,而不是地理距离),同时消除了歧义。
  • achievementtravel 都有一个 AchievementEventAdaptertravel Context 里的那个负责发布成就事件,改为 TravelAchievementEventAdapter 后语义更清晰。

规律 :类名冲突本质上是"强迫你把类名里缺失的上下文补回去"。一个类叫 CalculateDistanceUseCase,在它自己的 Context 包里是明确的,但进入 Spring 的全局 Bean 注册空间后,Context 这层语境消失了,歧义随之出现。加上 Context 前缀、或更精确的职责词,是避免这类问题的通用做法------它同时也是一种设计改善,而不只是技术妥协。

坑 6:遗留包不止 service/repository/

我的迁移路线图把"第四阶段"定义为"删掉 service/repository/ 两个旧包"。但 file_import 模块还有一个历史遗留的 excel 包(com.example.app.excel),里面放着 BusinessExcelDataBusinessExcelDataListenerBusinessExcelImportService(标记了 @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 个提交去追求的。

相关推荐
程序员威哥1 小时前
C#也能玩转YOLO:工业视觉原生推理方案,零Python依赖
后端
kfaino1 小时前
你好,我叫 Prompt——其实,你一直在给 AI 写程序
后端·openai·ai编程
caibixyy2 小时前
springboot+langchain4j实战Day 16 — 混合检索 + Reranker 重排序
后端
Ai拆代码的曹操2 小时前
揭秘"幽灵 CPU":top 抓不到的短命进程,才是真正的 CPU 杀手
后端
IT_陈寒2 小时前
Python里这个赋值坑,连老司机都能翻车
前端·人工智能·后端
唐青枫2 小时前
推荐一个 Zig Web 工程骨架:wing-app
后端
葫芦和十三12 小时前
图解 MongoDB 13|WiredTiger 存储引擎:B-tree、页和 checkpoint 三件套
后端·mongodb·agent
葫芦和十三12 小时前
图解 MongoDB 14|Cache 与淘汰:WiredTiger 的内存治理
后端·mongodb·面试
IT_陈寒16 小时前
Vue这个坑我跳了两次,原来问题出在这
前端·人工智能·后端