☰
从1200行OrderService到单一职责:破解发散式变化与霰弹式修改
2026/10/1 11:58:30 网站建设 项目流程

我印象最深的一次加班,是因为一个看起来半小时就能搞定的需求:给订单加一个"是否含税"的展示字段。结果我从下单服务改到订单实体,再从订单实体改到数据导出模块,最后连三个月没碰过的对账报表都得动。改完在工位上坐了很久,想不明白为什么加一个字段能牵动这么多地方。

这类体验,写业务代码超过两年的人基本都撞过。它背后站着的,就是软件设计里绕不开的三件事:发散式变化、霰弹式修改,以及被反复念叨却常年被误读的单一职责。教科书把它们讲成了名词解释,但工位现场从来不缺案例——缺的是把这三个词从纸面拽回到你正在改的那段代码上的方法。这篇我就按自己踩坑的顺序,把识别信号、判断标准、拆分步骤和粒度权衡摊开讲一遍,适合正在维护一坨"万能 Service"、或准备重构但不敢下手的人看。

1. 先把这三个词从纸面拽回到代码里

1.1 发散式变化:同一个类,被好几拨人用不同理由改

发散式变化的英文原词是 Divergent Change,指的是一个类或模块,因为多种互不相干的原因被反复修改。注意,重点不是"改得频繁",而是"改的理由来自不同方向"。一个类天天被改,如果每次都只为一个原因改,那它没病;反过来,一个类一个月只被改两次,但一次是因为税务规则调整,一次是因为数据库表加了列,那它就是发散式变化。

举个我真实见过的例子。一个叫OrderService的类,包含了这些逻辑:参数校验、优惠券计算、库存扣减、订单落库、消息推送、埋点上报。于是它会被至少四拨人盯上:

  • 财务团队调整税率和发票规则,动它;
  • 运营团队上线新的满减玩法,动它;
  • DBA 给订单表加字段或者改字段类型,动它;
  • 数据团队要求补一个埋点字段,还是动它。

这四拨人互相不认识,改的频率、时间点、验收标准全都不一样,但只要碰订单,落脚点都是同一个文件。这就是典型的发散式变化——一个类被多种变化原因同时撬动。

怎么识别?我常用两个土办法。第一个是翻 Git 历史:把某个文件的提交记录拉出来,如果提交信息的动词五花八门——"调整税率""新增促销""修复导出""补齐埋点"——基本可以确诊。第二个是看提交人:如果同一个文件长期被前端、后端、数据三个小组的人轮流改,那也是信号。这两个办法不需要任何工具,git log --format="%an %s" -- path/to/File.java一条命令就能看个大概。比起静态扫描,它更贴近"人"的视角,而职责这件事本来就是围绕人转的。

1.2 霰弹式修改:改一个需求,撒出去十几个文件

霰弹式修改的英文是 Shotgun Surgery,字面意思是"霰弹枪式的修改"。它描述的是另一种病:你只想做一个改动,却发现它碎成了十几处,分散在一堆文件里。你改完 A 忘了 B,测试环境正常,上线之后 C 没跟上,线上数据对不上。

典型的场景是新增一个字段。比如给用户加"昵称",你要动的地方可能是:User实体、UserDTO、UserVO、UserMapper.xml、缓存的序列化类、导出 Excel 的模板类、同步到检索系统的转换类、对应的单元测试……漏掉任何一个,都是一条线上缺陷。

这里有个和发散式变化容易混淆的点:发散式变化是"一个入口,多个原因进";霰弹式修改是"一个原因,多个入口出"。前者是收口太紧,后者是散得太开。它们看起来是相反的病,但根子上是同一个问题——职责边界跟变化轴没对齐。所以处理手法也往往是同一套:找到那条"变化的原因",把它收拢到一个地方去。

识别霰弹式修改,我建议直接看 PR 的 diff 分布。如果一个需求对应的 PR 里,改动的文件超过 5 个,且都是"各加两行"的小改动,那大概率是霰弹式修改。不用等到线上出事才反应过来,做 Code Review 的时候就能看出来。

1.3 单一职责:不是"一个类只做一件事"

单一职责原则(SRP,Single Responsibility Principle)被引用最多的表述是"一个类应该只有一个引起它变化的原因"(A class should have only one reason to change)。很多人把它简化成"一个类只做一件事",然后开始灾难式地拆类:UserQueryService、UserCreateService、UserUpdateService、UserDeleteService,一口气拆出四个。

我拆过,然后后悔了。因为拆完之后,业务上凡是涉及用户的改动——比如给用户加一个状态校验——四个 Service 都得改,霰弹式修改反而被我自己造了出来。把"一件事"理解成"一个操作",是 SRP 最常见的误读。

关键词其实不是 "one thing",而是 "one reason to change",也就是一个变化轴。而"变化轴"在企业系统里,很多时候对应的是一个角色或者一个业务方。谁会在什么情况下要求改这段代码,这个"谁"其实就代表了变化的原因。财务要求改是对应一个原因,运营要求改是另一个原因。判断一个类有没有违反 SRP,我喜欢问一句话:我能不能用一句不带"和"的话,说清楚这个类是干什么的?

"这个类负责订单的创建和优惠计算"——带了"和",两个原因,大概率有问题。

"这个类负责把订单对象转换成给前端展示的视图"——一个原因,可以。

当然这句话只是个粗糙的过滤器。真正的判断还要看它是否朝同一个方向演化。两个职能如果总是同步变化,强行拆开反而增加成本;如果它们各自因为独立的原因变化,就必须拆。这就是后面要展开的核心。

2. 为什么这两种坏味道总是成对出现

2.1 从"变化原因"倒推代码的边界

讲清楚逻辑之前,先把一个容易混的概念理一理。发散式变化和霰弹式修改,看起来一个"聚得太狠"、一个"散得太开",好像是两个极端,但它们指向的是同一个缺失——代码的切分维度和业务的变化维度不重合。

我用一组正交坐标打个比方。把系统想象成一张表:横轴是业务功能(下单、支付、发货、对账),纵轴是技术切面(校验、持久化、计算、通知、埋点)。理想情况下,每个模块应该是一小块"功能 × 切面"的正交矩形。但现实里大多数系统是按功能切好,然后在每个功能的实现里横向堆技术细节——OrderService里又有校验又有持久化又有通知。这种切法,对"业务按功能演化"是友好的,但对"技术按切面演化"是灾难:税务规则一变,所有功能模块都要动(霰弹式修改);一个模块里的技术细节变多了,它就变成一个什么都管的巨无霸(发散式变化)。

所以切边界的原则不是"功能"或"技术"二选一,而是先找到那些独立变化的方向。独立变化的方向,就是可以独立演进、独立部署、独立测试的维度。财务规则和数据库表结构,各自变化的触发条件完全不同,那它们就应该在代码里被物理隔开,一个改了不牵动另一个。这就是 SRP 真正想要的东西。

判断代码有没有切歪,我习惯用一个"改动收敛测试":假设明天来了一个需求——"把满减门槛从 100 调到 200",我应该改几个文件?理想答案是 1 个。如果答案是 5 个,说明这个变化轴被切碎了,是霰弹式修改。同一套逻辑反向用:假设明天财务说"发票规则调整",我又要改几个文件?如果每次财务调整我都得进OrderService里翻半天,说明这个变化原因没有被独立出来,是发散式变化。这个测试可以纯靠脑补,不需要真的去改,适合在 Code Review 或设计评审时快速过一遍。

2.2 判断职责边界的四个实用抓手

光讲原则容易虚,落到代码上,我总结过四个比较粗糙但好用的抓手,配合使用。

抓手一:看"谁"会来要求改。如果某个类的修改需求来自三个不同的业务方或角色,那它就承担了至少三个职责。这个抓手在需求文档里就能判断,不需要看代码。

抓手二:看"节奏"。有些代码变动很频繁——促销规则、运营配置;有些代码几乎不变——订单表结构、用户基础字段。变化节奏差一个数量级的东西,就不该住在同一个类里。改变慢的被改变快的拖着一起发布,本身就是风险。

抓手三:看"修改需要知道什么知识"。改税率需要懂税法,改 SQL 需要懂表结构,改消息格式需要懂下游系统的契约。如果改一个类里的不同方法,需要调用三种完全不同的知识库,那这个类里其实住着三个人。

抓手四:一句话描述,不能带"和"。前面提过的过滤器,作为快速筛查很方便。

这四个抓手不是非此即彼,更像是交叉验证。当一个类的四个抓手全都指向"多重职责"时,重构优先级排到最前面,一点不用犹豫。当一个类只有一个抓手亮红灯时,我通常先放着,记录下来,等下一个变化来验证。急于求成地拆,往往是把发散式变化"治"成了霰弹式修改。

2.3 一个经常被忽略的补充视角:变化方向比变化频率更重要

最后补一点很多文章不讲的东西。不要只按"变化频率"拆代码。变化频率高的东西拆出来独立演进,这没错,但如果两个高频变化的原因是耦合的——比如税率和发票抬头永远一起改——那你把它们拆开,等于人为制造霰弹式修改。

真正该比的是变化的原因是否独立。频率只是它的一个副产品。我在一个金融项目里见过把"利率计算"和"还款计划生成"拆成两个模块的做法,理由是"利率经常变"。结果每次利率调整,还款计划模块也必须同步改。因为利率变了,还款计划必然变,这两个东西共享同一个变化原因,拆开纯属自找麻烦。后来还是合回去,作为"还款策略"这一个职责统一管理,改动反而收敛了。

判断标准其实可以浓缩成一句话:如果原因 A 变化时,B 大概率不跟着变,那 A 和 B 就是两个职责;如果 A 变了 B 总要变,那它们是一个职责。这话听着像废话,但在评审会上拿它反问一句"改这个的时候,那个是不是一定也要动",往往能立刻把设计争论掰回正轨。

3. 实战:把一坨"万能订单服务"拆开

3.1 案发现场:一个 1200 行的 OrderService

先看一段简化后的现场代码。这个类来自一个电商系统,去掉细节后大概长这样:

@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private InventoryClient inventoryClient; @Autowired private CouponMapper couponMapper; @Autowired private MqProducer mqProducer; public OrderVO createOrder(CreateOrderCmd cmd) { // 1. 参数校验 if (cmd.getUserId() == null) throw new BizException("用户为空"); if (cmd.getItems() == null || cmd.getItems().isEmpty()) throw new BizException("商品为空"); // 2. 优惠计算 BigDecimal discount = BigDecimal.ZERO; if (cmd.getCouponId() != null) { Coupon coupon = couponMapper.selectById(cmd.getCouponId()); if (coupon.getThreshold().compareTo(cmd.getTotalAmount()) <= 0) { discount = coupon.getAmount(); } } // 3. 库存扣减 inventoryClient.deduct(cmd.getItems()); // 4. 落库 Order order = new Order(); order.setUserId(cmd.getUserId()); order.setAmount(cmd.getTotalAmount().subtract(discount)); order.setStatus(OrderStatus.CREATED); orderMapper.insert(order); // 5. 发送消息 mqProducer.send("order.created", order.getId()); // 6. 埋点 log.info("order_created, userId={}, amount={}", order.getUserId(), order.getAmount()); // 7. 组装 VO 返回 OrderVO vo = new OrderVO(); vo.setOrderId(order.getId()); vo.setPayAmount(order.getAmount()); return vo; } }

这个类有多条变化原因,用上一节列的抓手扫一遍,四个全部亮红灯:

变化原因触发角色修改频率涉及方法变化方向
优惠规则调整运营高优惠计算段业务规则
库存交互协议变更供应链中库存扣减段外部契约
订单表结构变更DBA低落库段持久化
下游消息格式变更数据/中台中消息发送段外部契约
埋点字段调整数据高埋点段观测
返回字段调整前端高VO 组装段接口契约

六个变化原因挤在一个方法里,且分属六个不同角色。这就是标准的发散式变化。更糟糕的是,如果这时候系统里还有另一个OrderQueryService也要读订单、也要组装 VO,那么"返回字段调整"这个需求就得同时改两个地方——霰弹式修改也跟着来了。

3.2 第一步:列出所有变化原因,写成清单

重构最忌讳一上来就动代码。我的习惯是先花半小时把变化原因整理成上面那张表,标清楚每一行对应代码里的哪一段。这张表本身就是重构路线图——它告诉我要拆成几块,哪些块该合并,哪些变化原因可以共用。

整理的时候有三个原则:

  • 按角色分,不按功能分。"运营要改"和"前端要改"对应两个不同的变化原因,即使它们改的是同一段代码,也要分开考虑。
  • 能合并的合并。如果两个变化原因总是同步发生(比如新增订单字段和新增导出字段),先合并成一行,避免拆得过细。
  • 标注优先级。高频变化的先拆,低频且稳定的可以留到最后,甚至不拆。

这一步不需要任何工具,一张纸或者一个 Markdown 表格就够。它的价值在于:拆之前你先想清楚了目标形态,而不是边拆边想。

3.3 第二步:小步抽取,每一步都能编译和测试

有了清单,接下来就是按变化轴抽取。关键原则是小步走,每抽一次都能编译、能跑通测试,而不是一次改到底。下面按顺序讲我实际的操作步骤。

第一步:抽 OrderValidator。参数校验是纯入参逻辑,不依赖任何外部资源,最安全。用 IDE 的 Extract Class,把校验段移到新类,OrderService里改成调用validator.validate(cmd)。编译、跑测试。

第二步:抽 OrderPricingCalculator。优惠计算也是纯计算,只依赖CouponMapper。抽出来之后,OrderService只拿到一个discount结果,不再关心优惠怎么算。

@Component public class OrderPricingCalculator { @Autowired private CouponMapper couponMapper; public BigDecimal calcDiscount(CreateOrderCmd cmd) { if (cmd.getCouponId() == null) return BigDecimal.ZERO; Coupon coupon = couponMapper.selectById(cmd.getCouponId()); return coupon.getThreshold().compareTo(cmd.getTotalAmount()) <= 0 ? coupon.getAmount() : BigDecimal.ZERO; } }

第三步:抽 OrderRepository。把OrderMapper的直连改成通过OrderRepository,这样以后 DBA 改表结构,只需要动 Repository,不会透传到业务层。这一步很多团队会省,觉得"Mapper 已经够了",但实际项目里,把字段映射和业务规则隔开的那层薄薄封装,长期看收益非常明显。

第四步:抽 OrderNotifier。消息发送和埋点合并成一个通知职责,因为它俩的变化原因都是"外部需要知道订单创建了"。如果消息格式和埋点字段各自独立变化,可以再拆成两个,但在这个例子里它们经常同步改,合在一起反而更好。

第五步:抽 OrderAssembler。VO 组装独立成一个类,前端改字段的时候只动它。

抽完之后,OrderService变成这样:

@Service public class OrderService { @Autowired private OrderValidator validator; @Autowired private OrderPricingCalculator calculator; @Autowired private OrderRepository repository; @Autowired private OrderNotifier notifier; @Autowired private OrderAssembler assembler; public OrderVO createOrder(CreateOrderCmd cmd) { validator.validate(cmd); BigDecimal discount = calculator.calcDiscount(cmd); Order order = repository.save(cmd, discount); notifier.onCreated(order); return assembler.toVO(order); } }

主流程从 1200 行变成十几行,每一个变化原因都找到了自己的落点。这时候再去改优惠规则,动的是OrderPricingCalculator;改落库结构,动的是OrderRepository;前端改字段,动的是OrderAssembler。每个变化原因只对应一个文件,这才是"改动收敛"。

3.4 第三步:定义接口,锁定依赖方向

抽完类之后,还有一步不能省:给依赖方抽象出接口,尤其是持久化和外部调用。理由不是因为"面向接口"这个教条,而是因为接口能锁定依赖方向,防止上游的细节泄露到调用方。

具体做法:定义OrderRepository接口,实现在OrderRepositoryImpl里直接调用 Mapper。OrderService只依赖接口。这样以后想把存库换成分库分表,或者加一层缓存,都不用动业务代码。同理,OrderNotifier定义成接口,Mq 实现和埋点实现分开,将来要接入第二个消息通道也不用改主流程。

同时,从整个模块的外部看,可以对外暴露一个粗粒度的OrderFacade,内部各个小类作为实现细节藏起来。对外粗粒度、对内细粒度,是避免霰弹式修改的关键——外部调用者只依赖 Facade 一个入口,内部怎么拆它不关心,也不需要跟着改。

3.5 第四步:三维验证,确认拆分真的有效

拆完之后必须验证,不能凭感觉说"好多了"。我用三个维度验证:

维度一:功能验证。所有已有测试全绿。如果之前没有测试,重构的第一步其实是补测试——这一点再怎么强调都不过分。没有测试护栏的重构,本质是赌博。

维度二:变化收敛验证。模拟三个需求(加字段、改优惠、改消息格式),确认每个需求只改一个文件。这个验证可以走一遍 Diff,看改动的文件数是否符合预期。

维度三:依赖方向验证。检查有没有反向依赖——比如OrderRepository里引用了OrderService的类。有的话说明边界又被打穿。用 IDEA 的 Analyze Dependencies 或者简单的依赖检查工具就能扫出来。

三个维度都过了,这次重构才算站得住。

4. 拆分手法与粒度权衡

4.1 常用重构手法对照表

真正落地的时候,SRP 不是靠喊口号实现的,而是靠一串标准手法。这些手法大多来自 Martin Fowler 的《重构》,我按实操场景整理成下面这张表:

手法适用场景操作要点常见坑
Extract Class一个类里存在多条变化原因按变化原因分组字段和方法,整体搬出去抽得太细,反而增加跳转成本
Extract Method方法太长,内部有清晰的阶段性按语义切段,别按行数机械切抽出的方法名太抽象(如 doPart1)
Move Method方法用了太多别的类的数据移到数据所在类,消除 Feature Envy移完后出现循环依赖
Extract Interface需要锁定依赖方向用例驱动,不是给每个类都建接口接口里塞了实现细节方法
Replace Conditional with Polymorphism大量按类型分支的条件判断每个分支抽成一个策略类分支少且稳定的场景不值得
Split Phase一个方法先处理又决策又执行拆成"决策阶段"和"执行阶段"中间数据结构设计不当,反而传递复杂
Parameter Object一长串参数在同一组里反复出现封装成对象对象名起得太泛(如 Param)

这张表建议打印出来贴在工位上,重构的时候照着挑。不要在同一时间用三种以上手法,否则出了问题很难定位是哪一步引入的。

4.2 拆到什么粒度算够:三个经验阈值

"单一职责"没有标准答案,我用了几年后总结出三个大致阈值,供参考:

  • 一个类改动时,涉及的场景原因不超过 1 个。这是最硬的标准,前面反复说。
  • 一个类代码规模不超过 500 行,方法数不超过 15 个。这不是精确指标,超过之后就值得审视一下是不是又长出了新的职责。
  • 一个方法不超过 50 行,且圈复杂度不超过 10。超过就抽 Extract Method 或者策略。

这三个阈值不追求"越小越好"。我见过有人把每个方法控制在 3 行以内,结果代码像跳棋一样跳来跳去,读起来比一百行方法还累。拆的目的是降低认知负担,不是降低代码行数。一个 400 行但逻辑高度一致的类,比四个 100 行、互相调用成一团乱麻的类,要好维护得多。

一个更实用的判断:当你用 IDE 的"Find Usages"追一个功能的时候,如果追了两个类还没找到底,说明拆过头了;如果在一个 800 行的类里找了半天,说明拆得不够。这个感觉比任何指标都好用,靠的是每天写代码积累的手感。

4.3 别忽视工具:让 IDE 和静态扫描替你盯着

手工判断职责容易受主观影响,善用工具能省一大半力气。

IDEA 的重构功能要练熟。Extract Class(Ctrl+Alt+Shift+T)、Move Method(F6)、Extract Interface这些是重构的主力,用熟了能避免手工搬代码时的各种低级失误。特别推荐Move Method和Pull Up,在抽接口、抽父类的过程中非常好用。

SonarQube 之类的静态扫描关注"类复杂度""方法复杂度""重复代码"。这些指标并不直接检测 SRP,但超标的类往往是职责过多的信号。设置一个合理的阈值告警,长期看是有价值的。

Git 变更热点分析是我最喜欢的工具之一。用几行脚本统计最近半年某个目录下各文件的提交次数,次数特别高的文件基本就是变化集中地。用前面说的"变化原因"表对照一下,往往能立刻定位到需要重构的类。工具只是辅助,但配合前面讲的方法论,效率能提一截。

5. 常见问题与踩坑实录

5.1 常见问题速查表

重构过程中会遇到的问题,我整理成下表,遇到时可以直接对号入座:

现象可能原因排查思路处理方式
拆完之后改动的地方更多了拆得过细,切碎了同一个变化轴检查新类之间是不是总是一起改合并回同一个职责
类越来越多,调用链越来越长只拆了类,没有定义粗粒度入口看外部调用方是否要注入五个以上依赖加 Facade 收口
拆完之后出现循环依赖拆错了方向,职责边界跨了用依赖分析工具扫一遍重新找变化轴,或者引入中间层
新类里全是 getter/setter,业务逻辑还留在原类只抽了数据,没抽行为检查方法是否还在操作别人的数据用 Move Method 把行为搬过去
重构完成后 bug 变多没有测试护栏看重构前后的测试覆盖率先补测试,再重做一遍
时间不够,拆到一半一次性拆太多看还有多少未完成回滚到上一个能跑通的状态,分批次拆

5.2 几个必须讲的坑

坑一:为了"单一职责"把 CRUD 拆成四个 Service。我在一个项目里这么干过。用户模块被拆成UserQueryService、UserCreateService、UserUpdateService、UserDeleteService,外加三个 DTO 转换类。结果加一个"删除用户时校验是否有订单"的需求,要同时改四个类,还得调整它们的依赖关系。CRUD 是操作类型,不是变化原因。按操作拆,等于把同一个变化轴切碎,是典型的把发散式变化治成霰弹式修改。

坑二:接口拆得太细,调用方负担爆炸。有个团队把订单相关的依赖拆成了OrderRepository、OrderCacheRepository、OrderLogRepository三个接口,每个方法都很小。结果业务类要注入七个依赖,每次启动都担心循环。接口应该按"调用方视角"拆分,而不是按"实现方视角"。调用方通常想要一个"能读能写订单"的东西,而不是三个分开的东西。

坑三:领域对象全拆成贫血模型,业务逻辑散落到 Service。这是从发散式变化跑向另一个极端。Order类里只有 getter/setter,所有业务规则都写在OrderService里。表面上看Order类是"单一职责"了,实际上所有变化原因又都挤回了 Service,等于绕了一圈回到原点。领域对象应该承载属于它自己的业务规则,Service 负责编排。判断标准是:一条规则如果永远只作用于订单这个对象,它就应该在 Order 里。

坑四:重构没有测试护栏,越改越乱。这是最致命的。我见过团队为了赶进度,在没有测试的情况下重构一个核心模块,改完之后线上连出三个 bug,最后回滚。重构的前提是"行为不变",而证明行为不变只能靠测试。没有测试的地方,先写特征测试(Characterization Test)把当前行为锁定,再开始拆。

坑五:把重构和业务需求混在一个 PR 里。这是我最想强调的一条。重构 PR 里混了业务改动,Code Review 的时候没人能分清哪些是行为不变的调整,哪些是行为变更。一条 PR 只做一件事,重构就是重构,需求就是需求,分开走,出问题的时候好定位也好回滚。

5.3 一条我用了很多年的自查清单

重构之前,我会过一遍下面这个清单,五条里超过两条回答"否",就不动手,先补功课:

  1. 有测试覆盖这次要改的代码吗?没有就先补。
  2. 能说清楚这次要消除的是哪一条变化原因吗?说不清说明还没想透。
  3. 拆完之后,一个典型需求会在几个文件里改动?预判应该收敛到 1~2 个。
  4. 调用方是否会因此注入更多依赖?如果会,先设计 Facade。
  5. 这次改动会不会跟业务需求混在一个 PR?会就先分拆。

写业务代码久了会发现,真正拖慢项目的往往不是技术难题,而是那些"每次都要改一遍"的地方。发散式变化和霰弹式修改,名字听起来抽象,但它们描述的场景你每周都在经历。识别它们不需要什么高级工具,一张变化原因表、一次 Git 历史翻看、一个"改这个需要动几个文件"的自我提问,就能把问题看得七七八八。至于要不要拆、拆到什么程度,我个人更倾向"小步多次",而不是"一次性大手术"——每次只解决一条变化原因,让它先跑一阵子,看它是否真的收敛了,再决定下一步。这样做的好处是,即使某一步判断错了,回退的成本也就是一次提交,而不是一整个季度的重构计划。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询