3308 字
约 11 分钟
7
S18-A03 · MVC 到 DDD 的重构与迁移

适用读者:手上有一个跑了 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,出问题也无法二分定位。这是迁移里最容易被忽略的纪律。

四条硬纪律:

  1. 一次只迁一个领域,且该领域有对应的回归用例集(没有就先补用例,补用例本身也是收益)。
  2. 保留双写/双读开关(feature flag),开关关闭时走旧路径——这是真正的回滚能力,不是"git revert"。
  3. 禁止"顺手重构"。迁移 PR 里出现无关的格式化/改名,一律拆出去。
  4. 每阶段结束跑一次全量回归 + 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. 自测

  1. 你的 service 里有多少行是"领域逻辑"?怎么快速统计(提示:找 if + 状态判断)?
  2. 为什么 P2(拆包)必须"只移动不改逻辑"?
  3. 一个 PO 被 7 个 Service 引用,你会先拆包还是先拆字段?为什么?
  4. 迁移的四个回滚点分别在哪一阶段?哪个最容易被跳过?
  5. 双跑(新旧路径比对)应该比什么?只比返回值够吗?
  6. 迁移完成后,@Transactional 应该出现在哪一层?为什么不在 domain?

下一篇

S18-A04 · 高可用分布式架构设计 + 从需求到架构的评审清单

结构性工作做完,最后一篇回到"系统级取舍":把"要高可用"拆成容量、故障域、一致性、幂等四件事,给出可算的容量公式、熔断参数、ADR 模板与五步评审清单。


本篇属于系列《S18 · 架构方法与领域建模》。旧文来源:A-1538/A-1531/A-1536(问题碰撞段);扩展点 E-S18-019、E-S18-020。分层映射与迁移阶段为对原文的整理,工期与成本为推断值(按领域包估算,未见原文实测);绞杀者路线引自 Fowler bliki。

S18-A03 · MVC 到 DDD 的重构与迁移
https://www.clxhxhhr.top/posts/5443/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。