适用读者:手上有一个跑了 3–5 年、改一处要回归三天的 MVC 工程,需要在不停止迭代的前提下换骨架的人。
前置:S18-A02(工程结构与依赖校验)、S18-J06(战术建模)。
覆盖旧文:A-1538(243 点)、A-1531(101 点)、A-1536(问题碰撞段 4 点)——合计 354 点。
扩展点:E-S18-019(MVC→DDD 分层映射表)、E-S18-020(绞杀者式迁移与回滚点)。
上一篇:S18-A02 · DDD 工程结构与脚手架
下一篇:S18-A04 · 高可用分布式架构设计 + 从需求到架构的评审清单。
1. 一句话结论
MVC 到 DDD 的迁移不需要动工程模块结构,只需要动三件事:把 service 里的领域逻辑下沉到 domain、把基础组件上移到 infrastructure、把 service 剩余部分降级为应用编排层——因为 MVC 里 service/dao 本来就依赖 domain,依赖方向天然吻合。
A-1538 的原话是这套方案的立论基础:"不需要太高成本,MVC 就可以天然地向 DDD 工程分层的模型结构转变。重点是不改变原有的工程模块的依赖关系,将贫血的 domain 对象层,设计为充血的结构。"
这句话很重要,因为它把"重构"从"重写"里救了出来。很多团队失败的原因不是不会 DDD,是一上来就重写:新工程半年不上线,旧工程继续腐化,最后两套并存。
1.1 腐化的机理(不是"代码写得丑")
A-1538 对腐化的描述非常具体:
- MVC 把"状态"和"行为"分离到不同包:
domain里放 po/vo/enum,service里放逻辑; - 因为没有约束,贫血对象被众多 service 交叉使用,service 之间也相互调用;
- 时间越长,VO 的字段被越撑越大,一条"裤子"被加肥加大,所有人都在穿;
- 结论:"MVC 工程的腐化根本,就在于对象、服务、组件的交叉混乱使用。时间越长,腐化的越严重。"
A-1536 给的是同一个病灶的另一种描述:DAO、PO、VO 在 Service 层相互调用,长期开发后"各个 VO 里的属性字段数量都被撑的特别大","状态"与"行为"分离导致"代码的意图渐渐模糊"。
所以迁移的目标不是"变好看",是切断交叉引用。判据很直接:一个 PO 被几个 Service 引用? 被 ≥5 个引用且字段在增长,它就是腐化点。
1.2 一个必须先纠正的语境问题
A-1538 开头特意澄清:MVC 与 DDD 的对比,只是"软件落地的框架结构"的对比,不是设计思想与建模方法的全面对比(DDD 是设计方法,涵盖范式/模型/框架/方法论;MVC 是一种 Web 分层框架)。
这个澄清在评审会上非常有用:当有人说"我们用了 MVC 就不算 DDD"时,正确的回应是"我们讨论的是工程骨架,DDD 的战略/战术设计可以同时存在于 MVC 骨架上"——这正是本文迁移方案的合法性来源。
2. 分层映射表(E-S18-019)
A-1538 给的映射是按"依赖关系不变"推出来的,本文把它整理成可执行的四列表:
| MVC 里的位置 | 迁到 DDD 的哪层 | 迁移动作 | 风险 |
|---|---|---|---|
service 里的领域逻辑(校验、计算、状态迁移) |
domain 下的领域包(每个包含 model/repository/service) |
抽成充血对象 + 领域服务 | 抽错粒度 → 领域包变成"第二个 service" |
service 里的基础组件用法(Redis、配置中心、MQ) |
infrastructure |
定义领域接口,实现在 infra | 直接在 domain 里引 RedisTemplate(越界) |
service 剩余部分(编排、事务、DTO 转换) |
app / case |
保留原类,改名为 XxxApplication/XxxCase |
把业务规则留在 app → 两个地方都有规则 |
dao |
infrastructure |
改为实现 domain 定义的仓储接口 | 继续叫 XxxDao 且暴露 PO |
domain(贫血 PO/VO) |
domain(充血 model) |
原地改造,不新建包 | 直接改 PO 导致 ORM 映射断裂(见 §3.3) |
export(RPC 接口定义) |
api |
保持不变 | 若把 domain 暴露在 export → 必须迁出 |
rpc(调外部接口) |
infrastructure 或独立 gateway |
domain 定义 adapter 接口,rpc 层实现 | 在 domain 里直接引 Feign/Dubbo |
web(Controller) |
trigger |
改名 + 只做协议转换 | Controller 里写业务判断 |
| 通用 Response/枚举/异常 | types |
独立模块,谁都能依赖 | 把实体塞进 types(依赖倒挂) |
这张表的关键在第 5 行与第 6 行:domain 是"原地改造",不是"新建再搬";domain 绝不能出现在对外接口包里(A-1538 明确:"如果你原有工程把 domain 也暴露出去了,则需要把对应的包迁移到 export,因为 domain 包有太多的核心对象和属性,还包括数据库持久化对象")。
3. 依赖倒置改造:三个最小动作
3.1 动作一:仓储接口上移
// 改造前(service 直接依赖 dao)
public class OrderServiceImpl {
@Autowired private OrderDao orderDao; // PO 泄漏到 service
public void pay(String id) { OrderPo po = orderDao.select(id); po.setStatus(1); orderDao.update(po); }
}
// 改造后:domain 定义接口 + 充血对象;infra 实现
// domain
public interface OrderRepository { Order find(String id); void save(Order order); }
public final class Order {
private Status status;
public void pay() { if (status != Status.CREATED) throw new IllegalStateException(); status = Status.PAID; }
}
// app
public class OrderApplication {
private final OrderRepository repo;
public void pay(String id) { Order o = repo.find(id); o.pay(); repo.save(o); }
}
注意 pay() 里的那句 throw:它把"只有 CREATED 能支付"这条业务规则从 5 个 service 的 if 里收进了一个方法。这是迁移真正的收益——规则唯一,而不是"分层好看"。
3.2 动作二:PO 与 Entity 分离(或明确合并)
A-1538 提到实体与 PO "大多数情况下 1v1",也允许 1vn(为了封装属性)。三条策略:
| 策略 | 做法 | 成本 | 适用 |
|---|---|---|---|
| 合并 | Entity 直接带 ORM 注解 | 低 | 中小工程、单表映射 |
| 分离 + 转换器 | Entity ↔ PO 显式转换 | 中 | 领域模型与表结构开始分叉 |
| 分离 + 框架映射 | MapStruct/手写 Mapper | 中高 | 多表聚合、复杂映射 |
判据:当"表结构"和"业务概念"开始分叉时就必须分离。典型信号:一个聚合对应 3 张表、或一个表被 3 个聚合共用。
3.3 动作三:事务边界上移
MVC 里 @Transactional 常贴在 Service 方法上;迁移后应贴在 app/case 层(用例边界),因为一个用例可能调用多个领域服务。domain 内部不再出现 @Transactional——领域对象不该知道事务。
4. 绞杀者式迁移路线与回滚点(E-S18-020)
一次性重写的失败率极高。Fowler 的 StranglerFigApplication 给的是"在外面长一层,逐步替换"的路线。落到 Java 工程上是 6 个阶段:
| 阶段 | 动作 | 可交付物 | 回滚点 | 工期(参考) |
|---|---|---|---|---|
| P0 观测 | 统计 PO 被引用次数、service 间调用图、改动热点 | 腐化热点清单(Top 10) | 无改动,天然可回 | 3–5 天 |
| P1 立规 | 加 types 模块、加 ArchUnit 规则(只对新增代码生效) |
绿色构建 + 规则文档 | 删测试即可 | 2 天 |
| P2 拆包 | 按领域把 service 拆成 xxx 领域包(只移动文件,不改逻辑) |
目录结构 = 目标结构 | git revert 单次提交 | 1–2 周 |
| P3 充血 | 逐个把领域包里的贫血对象改充血(先加方法,再删 service 里的重复逻辑) | 领域对象单测 | 按领域回滚(一个包一个 PR) | 2–6 周 |
| P4 倒置 | 仓储/adapter 接口上移,infra 实现 | 依赖图符合 §3 | 保留旧 dao 双写过渡 | 2–4 周 |
| P5 收口 | 开启全量 ArchUnit 规则、删旧 dao、app 层改名 | 规则全绿 + 指标达标 | 保留旧路径开关 1 个迭代 | 1–2 周 |
为什么 P2 要"只移动不改逻辑":移动文件是纯机械操作,review 成本低、冲突少;一旦同时改逻辑,diff 里既有移动又有语义变更,没人能 review,出问题也无法二分定位。这是迁移里最容易被忽略的纪律。
四条硬纪律:
- 一次只迁一个领域,且该领域有对应的回归用例集(没有就先补用例,补用例本身也是收益)。
- 保留双写/双读开关(feature flag),开关关闭时走旧路径——这是真正的回滚能力,不是"git revert"。
- 禁止"顺手重构"。迁移 PR 里出现无关的格式化/改名,一律拆出去。
- 每阶段结束跑一次全量回归 + ArchUnit,把"绿"作为进入下一阶段的门槛。
成本预估的口径(避免拍脑袋):以"领域包"为单位估算,单包迁移 ≈ 0.5 人日 × (聚合数 + 用例数/2),乘以 1.5 的回归系数。一个 10 聚合、20 用例的领域 ≈ 15 人日。
5. 验收:规则、指标与回归
迁移"完成"不能用"我觉得清爽了"来判定。三层验收:
5.1 规则层(必须 100% 绿)
| 规则 | 表达式 | 现状 |
|---|---|---|
| domain 不依赖 infra/trigger/app | noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().resideInAnyPackage("..infra..","..trigger..","..app..") |
见 A02 §4 实测 |
| domain 不依赖框架 | 同上,包名换 org.springframework..、org.apache.ibatis.. |
新增规则 |
| 分层无环 | slices().matching("com.x.(*)..").should().beFreeOfCycles() |
新增规则 |
| PO 不出现在 domain 签名 | noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().haveSimpleNameEndingWith("PO") |
新增规则 |
5.2 指标层(对比迁移前后)
| 指标 | 采集方式 | 目标 |
|---|---|---|
domain 包的平均 CBO |
依赖分析工具 | 下降或持平(不应该因为充血而暴涨) |
| Service 类平均行数 | cloc/IDE |
下降 ≥ 50% |
| 单次需求改动文件数 | git 统计(取最近 10 个需求) | 下降 |
| 领域对象单测覆盖率 | JaCoCo(仅 domain 包) | ≥ 60% |
| 构建时长 | CI | 增幅 ≤ 30%(ArchUnit 的代价) |
5.3 回归层
- 每个迁移过的领域必须有用例级回归(不是方法级),至少覆盖主流程 + 3 个异常分支;
- 上线用灰度 + 双跑(新旧路径同时执行,比对结果)至少一个迭代;
- 保留旧表/旧字段 ≥ 2 个迭代再删(数据回滚需要)。
判据(迁移可以宣布完成的条件):规则全绿 + 指标达标 + 双跑无差异 + 旧路径下线一个迭代无故障。四条缺一不可。
6. 常见误区
| 误区 | 真相 |
|---|---|
| "MVC 迁 DDD 要重写工程" | 依赖关系本来吻合,改的是"逻辑归属",不是模块结构 |
| "先把 domain 全改成充血再上线" | 按领域分批,每批可回滚 |
| "domain 可以暴露给 RPC 接口" | 会泄漏 PO 与核心对象,必须迁到 export/api |
| "迁移期间可以顺手重构" | diff 混合移动与语义变更 → 无法 review、无法二分 |
| "回滚 = git revert" | 真正的回滚是开关 + 双写;代码回滚救不了已写的数据 |
| "分完包就算 DDD 了" | 分完包只是 P2;充血(P3)与倒置(P4)才是核心 |
7. 自测
- 你的
service里有多少行是"领域逻辑"?怎么快速统计(提示:找if+ 状态判断)? - 为什么 P2(拆包)必须"只移动不改逻辑"?
- 一个 PO 被 7 个 Service 引用,你会先拆包还是先拆字段?为什么?
- 迁移的四个回滚点分别在哪一阶段?哪个最容易被跳过?
- 双跑(新旧路径比对)应该比什么?只比返回值够吗?
- 迁移完成后,
@Transactional应该出现在哪一层?为什么不在 domain?
下一篇
S18-A04 · 高可用分布式架构设计 + 从需求到架构的评审清单
结构性工作做完,最后一篇回到"系统级取舍":把"要高可用"拆成容量、故障域、一致性、幂等四件事,给出可算的容量公式、熔断参数、ADR 模板与五步评审清单。
本篇属于系列《S18 · 架构方法与领域建模》。旧文来源:A-1538/A-1531/A-1536(问题碰撞段);扩展点 E-S18-019、E-S18-020。分层映射与迁移阶段为对原文的整理,工期与成本为推断值(按领域包估算,未见原文实测);绞杀者路线引自 Fowler bliki。