Appearance
一个留学缴费遗留系统的治理:边界、验证与工程判断
本文并不是一次性回忆后整理出来的总结,而是基于我自 2025 年中开始维护这个系统以来,过去约 16 个月的 Git 提交记录,由 AI 辅助回溯代码变化、提交动机和治理过程后重新整理而成。
文中保留了较多具体案例,因为这些案例本身就是方法论成立的依据,因此篇幅也会相对长一些。
这是一个面向留学缴费场景的 Java 系统,于 2021 年上线。
从 C 端用户视角看,流程其实并不复杂:用户创建缴费订单、完成付款,最终关心的核心结果也很简单——钱有没有付出去,学校有没有收到。
但为了得到这两个看起来简单的结果,系统内部实际上要经过一条很长的业务链路。
一个典型流程可能包括:
- C 端用户创建订单并付款; 业务人员审核学生、学校和缴费信息;内部审核完成后,上游支付或资金渠道继续审核;
- 上游支付或资金渠道继续审核;
- 换汇;
- 境内外资金流转;
- 学校收款;
- callback 和状态同步;
- 失败、退款和异常处理。
很多步骤都需要人工确认以后才能继续,并不是一个从下单开始就自动执行到底的纯线上支付流程。
同时,这个业务天然不是实时完成型业务。
一笔缴费从用户付款到最终资金支付出去,本来就可能持续一周甚至更长时间。中间涉及人工审核、渠道审核、换汇、跨行转账、境内外资金流转等环节,因此真正重要的不是毫秒级响应,而是:
- 当前走到了哪一步;
- 为什么停在这里;
- 下一步由谁处理;
- 钱现在处于什么状态;
- 系统内部状态和渠道状态是否一致;
- 最终资金归属是否正确。
这个系统也不是高并发系统。实际观察到的峰值 QPS 只有个位数,但系统承载真实资金,且单笔交易金额通常较高。
因此:低并发并不等于低风险。
相比吞吐能力,这个系统更在意数据正确性、状态一致性和稳定性。
业务本身还有明显的季节性。留学缴费存在旺季和淡季,晚上七八点以后通常进入明显低峰。系统没有严格固定的发布窗口,但正常使用期间会尽量避免发布,真正需要发布时通常选择低峰。
业务人员也高度依赖系统。系统一旦不可用,不只是“页面打不开”,很多业务数据也无法继续查询和处理。
所以这个系统真正的特点更接近:
低并发、高金额、长周期、多人工节点、外部渠道参与、内部状态复杂、业务具有季节性。
为了方便描述,本文将系统中的两个主要应用称为:
- portal:面向 C 端用户,负责缴费、订单、付款等用户侧功能;
- console:面向业务人员,负责订单管理、审核、渠道操作、查询和日常处理。
自我接手后,这个系统最多同时由 2 个人维护,而且维护者之间存在明显的经验差异。
期间也曾短暂有测试人员参与,但大部分时间并没有稳定的专职测试,系统最初也缺少足够的自动化回归测试。
这两个背景——维护人数少和验证能力弱——后来深刻影响了治理方式。
如果一个规则只能依赖某个人记住,它就很容易变成风险。
如果一次修改涉及几十个类,但没有足够的自动化测试能够证明主流程没有变化,那么即使代码看起来没问题,也很难放心一次性上线。
因此早期很多治理采用的都是很小的步子:
一次只改少量类,或者只迁移几个文件;本地编译通过以后,再经过构建、测试环境人工点验,最后选择低峰发布。
系统本身并不是高并发场景,所以这种小批量、低峰发布是可行的。
代价也很明显:
每一小步都要重复经历构建、部署、人工验证和上线观察,治理速度自然会很慢。
这也是后来逐步补 UT、IT 和 SmokeIT 的重要原因。
自动化测试对这个系统的价值,不只是提高“测试覆盖率”,更重要的是:它扩大了能够安全修改的范围。
原来一次只能放心改几个类;当解析逻辑有 UT、数据库访问有 IT、关键线上能力有 SmokeIT 以后,系统才逐渐具备处理更大范围结构问题的条件。
这篇文章不是一次“重构完成总结”。
直到现在,仍然有不少地方没有处理完;还有一些地方虽然知道存在问题,但因为涉及前端、其他系统、历史数据或者核心支付语义,目前仍然不会轻易修改。
它更像是一份持续一年多的遗留系统治理记录:
哪些问题真实增加了维护成本,这样会有什么风险,我又是如何判断哪些问题值得现在处理、哪些应该暂时不动。
一、背景:接触的是一个怎样的遗留系统
1. 一个 Java 包里已经有 700 多个文件
当时 ordermanagement 这个 Java 包体系下已经有 700 多个 Java 文件,而整个仓库有 2000 多个 Java 文件。
问题并不只是“订单代码比较多”。
这个包里同时存在:
- 订单;
- 支付;
- 付款单;
- callback;
- KYC;
- 换汇;
- 资金出境;
- Mapper;
- Service;
- Controller;
- DTO;
- VO;
- Param;
- 定时任务;
- Utils;
- 多个具体渠道实现。
ordermanagement 已经不再真正表达这个包的职责,而逐渐变成了一个不断承接新业务的容器。
这样会有什么风险
最大的风险不是文件数量本身,而是很难仅根据代码所在位置判断它属于哪个业务边界。
修改订单时可能直接碰到渠道协议,修改换汇时可能进入一个以订单命名的包,查 callback 时又要穿过多个技术层目录。
随着代码继续增加,包名本身逐渐失去了导航作用。
后来的处理
我没有先重写这些代码,而是先逐步重新确定归属,例如:
order.coreorder.flow.*channel.*userpayermerchantcommon.*
先让代码进入正确边界,再判断内部实现是否值得继续优化。
2. portal 和 console 存在大量重复实现
两个主要应用中长期存在相同或相似的:
- Entity;
- Mapper;
- Mapper XML;
- Security;
- Properties;
- Config;
- Utils;
- 业务对象。
部分 Java 文件甚至内容完全一致。
同一张业务表,可能在 portal 和 console 中各维护一套 Entity、Mapper 和 XML。
这样会有什么风险
真正危险的不是多了几份代码,而是:同一个概念出现多个事实来源。
今天两份代码完全一致,并不代表半年以后仍然一致。
例如数据库增加一个字段,portal 修改了,console 忘记修改。两个应用仍然可能正常编译、正常启动,但已经形成了两套对同一张表的不同理解。
后来的处理
我逐步建立:
common-mappercommon-pojocommon-bizcommon-infra
将真正属于同一个事实源的代码收拢。
我的原则很直接:系统中不应该长期存在相同的文件。
3. 多代技术方案叠加在一起
这个系统最初基于 el-admin 演化而来。
el-admin 原本使用 JPA,但后续业务代码大量改成了 MyBatis,因此系统中长期保留了不同技术阶段留下来的痕迹。
曾经同时存在:
- JPA;
- MyBatis;
- PageHelper;
- MyBatis-Plus;
- Dubbo;
- Feign;
- RabbitMQ;
- XXL-Job;
- 多种渠道 SDK。
很多技术并不是经过一次完整替换后进入系统的,而是新方案逐渐叠在旧方案上。
这样会有什么风险
一个新的需求进来时,首先需要判断:这个模块到底使用哪一套方式?
同一种功能可能存在不同实现习惯,不同方案还可能通过 Maven 依赖、DTO 或 API 契约互相污染。
后来的处理
能够收口的逐渐收口:
- JPA / MyBatis XML 的部分能力 → MyBatis-Plus;
- PageHelper → MyBatis-Plus 分页;
- BeanUtils → MapStruct;
- 重复 Mapper →
common-mapper; - 渠道实现 → channel 边界。
这些变化并不是为了追求更新的技术栈,而是为了减少真实维护风险。
4. 一些技术债并不是偶然形成的
系统早期更偏向快速完成功能,工程约束相对弱。
历史上还存在过偏向代码产出数量的评价方式。
这种激励天然更容易鼓励‘复制已有实现’,而不是‘抽取公共能力、减少重复、建立单一事实来源’。
这也是系统中大量重复代码长期存在的一个背景原因。
所以今天回头看这些重复实现,并不能简单归结为“谁写得不好”。
技术结构往往会真实反映当时的目标和约束。
5. 外部流程简单,不代表内部模型简单
从 C 端用户视角看,流程很简单。
用户主要关心:
- 是否已经付款;
- 款项最终是否已经支付到学校。
内部审核、渠道审核、失败回退、换汇、资金出境等状态,用户大多并不知道,也不需要知道。
但系统内部却需要承载这些复杂状态。实际维护中,外部渠道本身出现的问题并不算多,更多棘手情况反而来自内部模型无法完整表达真实业务,以及这些例外场景后续需要怎样落到订单、入账和状态上。
风险判断也不能只从代码出发。例如当前主要付款方式包括用户主动扫码支付和银行转账。即使系统因为某种原因生成了两个订单或者两个付款入口,也不天然等于用户会实际付款两次,真实付款动作本身形成了一层业务约束。相比高并发下的自动重复扣款,这个系统更常面对的是内部状态、金额归属和人工补偿怎样保持一致。
更麻烦的是,真实业务并不总能落入最初设计好的订单模型。
案例:付款以后又出现额外费用
例如学校最终需要收到 2,000 USD。
学生已经按照 2,000 USD 完成付款,但后续发现某种付款方式还需要额外承担 30 USD 转账费用。
现实业务于是变成:
- 学校应收金额仍然是 2,000 USD;
- 学生还需要额外补 30 USD;
- 原订单金额已经不能完整表达最终业务事实。
当前系统没有独立的附加费用产品能力,这类场景只能由内部人员直接修改订单相关字段,让订单金额重新符合实际业务。
案例:一笔付款后来需要重新拆分
另一种情况是:
学生原计划把学费和生活费一起支付。
后续发现当前流程无法这样处理,于是需要:
- 退款一部分;
- 调整某个订单金额;
- 修改入账金额;
- 把原来的资金重新归属到另一个订单。
这些操作都属于内部处理,并不一定要求外部渠道改变。目前没有完整的产品化入口时,也会通过内部数据修正直接调整订单金额、入账金额或资金与订单之间的归属关系。
这样会有什么风险
这里暴露出来的问题,不一定是“系统会直接造成资金事故”。
很多时候人工流程本身可以把业务继续走下去。
真正的问题是:产品模型没有完整表达现实业务,于是部分业务规则只能通过人工数据修正完成。
如果这种规则长期只存在于维护人员和业务人员的经验里,那么未来看到某个字段时,很难知道:
为什么这个字段曾经被人工修改?
为什么这里不能简单删除?
为什么订单金额和实际入账金额会出现某些历史差异?
所以:数据修正本身不一定是在制造错误,有时是在弥补系统模型无法完整表达现实业务。
真正应该治理的,是这些规则长期没有被显式表达、记录、校验和产品化。
6. 问题不只存在于业务代码里
接触这个系统以后,需要处理的不只是 Java 类和业务模型,构建、部署、容器和日志这些工程基础本身也留下了不少历史问题。
案例:Docker 大量使用 --net=host
早期不少服务的容器启动直接使用 --net=host。
这种方式确实简单,容器可以直接使用宿主机网络,也绕开了一部分容器网络配置问题。但它同时弱化了容器自身的网络边界,使服务更加依赖具体宿主机环境。
这类问题后来也让我越来越关注:部署方式本身是不是把环境差异和隐式依赖带进了系统。
扩展阅读:
案例:logback.xml 中出现多个 root 节点
日志配置也不是天然可靠的。历史配置中曾经出现过一个 logback.xml 存在多个 root 节点的情况。
这不是代码风格问题,而是最基础的运行时行为都会让日志级别和 Appender 的实际行为更难判断。
后面 Docker 基础镜像切换影响接口签名的事故又进一步说明了一件事:工程基础设施不是业务代码之外一个可以忽略的层次,它同样可能直接改变系统运行结果。
扩展阅读:
二、治理前先判断:这个问题现在值不值得处理
随着治理逐渐深入,我发现真正重要的并不是“能不能看出问题”。
而是:这个问题现在应不应该处理。
我后来越来越倾向于从五个维度判断。
1. 影响范围
首先看修改会影响到哪里:
- 一个类;
- 一个模块;
- portal / console;
- 多个 Maven module;
- 前端;
- 其他系统;
- MQ;
- callback;
- 历史数据。
影响范围越大,就越不能只靠“代码看起来更合理”做决定。
2. 可逆性
第二个维度是:如果判断错了,能不能低成本退回。
例如:
- Mapper / Converter 重命名;
- BeanUtils → MapStruct;
- PageHelper → MyBatis-Plus;
- package 迁移;
- Parser / Processor 拆分;
通常可以小步修改、小步验证,必要时局部回滚。
而下面这些变化就完全不同:
- Order / Payment Intent / Charge 的历史关系;
- 对外 API;
- MQ 契约;
- callback 语义;
- 已经存在多年的历史数据结构。
一旦修改,可能很难完整退回。
所以我后来越来越在意:对于能够局部迁移、局部验证、低成本回退的内部改造,可以积极推进;对于会改变历史语义或者跨系统契约的修改,即使设计上明显不理想,也应该更加保守。
3. 可验证性
修改之前还要问:我能不能证明它是安全的?
验证手段可能包括:
- UT;
- Mapper IT;
- SmokeIT;
- 真实请求 / 响应样本;
- 对照实验;
- 历史数据;
- 日志;
- 测试环境人工验证。
在自动化测试不足的时候,可验证性直接决定了能改多大的范围。
早期一次只敢改四五个类,本质上不是编码能力问题,而是:一次修改超过人工能够有效验证的范围以后,风险就迅速上升。
所以自动化测试真正改变的是:安全修改半径。
4. 维护成本增速
还有一类问题当前可能并不严重,但会持续恶化。
例如:
- 同一张表维护两份 Mapper;
ordermanagement不断继续塞新业务;- 渠道 DTO 不断扩散;
- PageHelper 继续进入新接口;
- Map 和 BeanUtils 继续增加隐式依赖。
这类问题真正危险的地方在于:现在不处理,后面的每一个新需求都会继续扩大它。
相比之下,一个很丑但多年稳定、很少修改的 Controller,虽然代码质量差,但维护成本未必还在快速增长。
5. 实际收益
最后才是收益。
处理以后到底减少了什么?
例如:
- 少改几个地方;
- 少维护一份重复代码;
- IDEA 能重新找到真实引用;
- 查询行为变得显式;
- 渠道变化被限制在边界内部;
- 测试可以独立执行;
- 后来的人不需要先了解大量历史才能做判断。
所以我现在判断一个技术债是否值得立即处理,通常不是只看它丑不丑。
而是综合判断:影响范围、可逆性、可验证性、维护成本增速和实际收益。
真正需要优先处理的,往往不是最难看的代码,而是:维护成本还在持续增长,同时又缺乏明确边界和验证手段的问题。
三、治理过程中逐渐形成的五类原则
第一类:让变化尽量局部
核心目标是:一个地方发生变化时,尽量不要要求很多无关位置同步修改。
案例一:JPA / MyBatis / XML → MyBatis-Plus
旧代码中,一个数据库字段增加或删除,经常需要同步调整:
- Domain / Entity;
- Mapper;
- XML resultMap;
- SELECT 字段;
- INSERT / UPDATE。
其中 XML 特别容易遗漏。
Java 字段删除以后,IDE 至少还能提供一定帮助;XML 少写一个字段,很多情况下编译完全正常。
这样会有什么风险
真正的问题不是“多写几行代码”,而是修改点过多以后,维护开始依赖人的记忆。
一次数据模型变化可能需要同时检查多个文件,只要漏掉其中一个,就可能在运行时才暴露问题。
后来的处理
我逐步向 MyBatis-Plus 收口。
目的不是因为 MyBatis-Plus 更新,而是为了减少:一次表结构变化需要人工同步维护的位置。
扩展阅读:
案例二:重复 Entity / Mapper → common-mapper
同一张表如果 portal 和 console 各维护一套 Entity、Mapper、XML,相当于把同一份持久化模型复制了两次。
这样会有什么风险
数据库字段变化以后,两边必须同步修改。
一旦漏掉其中一边,系统中就出现两个不同版本的“真实模型”。
后来的处理
我把真正共享的数据访问逐步收拢到 common-mapper。
目标是:同一个持久化事实尽量只有一个来源。
案例三:重复枚举
相同枚举如果存在两份,新增一个状态时漏掉其中一个,两边仍然可能正常编译。
这样会有什么风险
这类问题非常隐蔽,因为代码不一定报错,但业务语义已经开始漂移。
所以完全同义、同职责的枚举也应该逐步收拢。
案例四:渠道协议限制在 channel 内
系统会接入多个外部渠道。
如果渠道 DTO、枚举、错误码、签名和 SDK 散落在订单、退款等核心业务代码中,那么一个渠道协议变化就可能扩散到大量模块。
后来的处理
我逐步建立独立 channel 边界,把:
- DTO;
- Enum;
- HTTP;
- 签名;
- 加解密;
- 错误码转换;
尽量收紧在渠道内部。
一个好的渠道边界,不只是目录清晰,而是:某个渠道发生变化时,变化应该尽量停在这个 channel 内。
扩展阅读:
第二类:让依赖和行为尽量显式
这类问题的共同特点是:代码真实做了什么,但从代码表面上不容易看出来。
案例一:BeanUtils → MapStruct
例如:
java
BeanUtils.copyProperties(a, b);假设 a 中有字段:
java
private String payerName;这个字段实际上仍然可能通过 copyProperties 被使用。
但 IDEA 查看引用时,可能显示 0 usages。
这样会有什么风险
维护者很容易认为:
“这个字段已经没人用了。”
然后执行 Safe Delete。
真正的问题不是 BeanUtils 的性能,而是:代码存在真实依赖,但静态分析工具看不到。
对于一个需要不断清理历史字段和死代码的系统,这很危险。
后来的处理
我逐步使用 MapStruct,让映射关系尽量进入编译期模型。
这样 Rename、Find Usages、编译检查都能参与维护。
案例二:Map<String, Object>
类似的问题也存在于 Map:
java
map.get("userName");
map.get("userNmae");两个都可以正常编译。
同时:
- Ctrl+单击不能导航;
- Rename 不可靠;
- Find Usages 无法建立真实字段关系;
- key/value 很容易不断字符串化。
后来的处理
对于结构相对稳定的数据,我更倾向于强类型 DTO。
目标仍然是:让编译器和 IDE 参与维护。
案例三:Redis 类型
早期 Redis 使用中,读取以后需要强制类型转换,甚至还会把 Java 类名写进 Redis。
这样会有什么风险
缓存数据和具体 Java 实现类形成了隐式运行时耦合。
类移动、改名或者模型变化,都可能影响已有数据。
后来的处理
逐步处理:
- 类型安全读取;
- 数据模型统一;
- key / prefix;
- TTL;
- 数据隔离;
- Integration Test。
目的还是减少:只有运行时才知道这里到底是什么。
案例四:PageHelper
PageHelper 的典型调用方式:
java
PageHelper.startPage(pageNum, pageSize);
List<User> users = userMapper.select(...);分页行为并不属于真正的查询调用,而依赖前面已经设置过线程上下文。
实际见过类似结构:
text
Controller:
startPage()
Service:
if (...)
selectA()
else
selectB()这样会有什么风险
仅看 selectA(),完全看不出来它实际上处于分页状态。
startPage() 和真正执行的 SQL 之间还可能隔着很多业务逻辑。
PageHelper 分页查询返回的结果,表面类型通常是 List<T>,但运行时实际可能是继承自 ArrayList 的 Page<T>;再用 PageInfo 包装后,分页信息和结果列表又被放进同一个分页对象中。这会让“普通列表”“分页结果”和“分页元数据”几种概念混在一起。
后来的处理
我更倾向于:
java
mapper.selectPage(page, query);因为分页参数直接出现在真正执行查询的位置。
原则是:重要行为应该出现在调用本身,而不是藏在调用上下文里。
扩展阅读:
案例五:业务 Excel 导入
假设业务 Excel 中有一些记录,系统按规则最后只保存其中一部分。
如果最终只显示:“导入成功”
那么用户并不知道哪些数据被过滤、为什么被过滤。
这样会有什么风险
系统实际行为和用户认知不一致。
后来的处理
我更倾向于:
上传 → Parse → 展示解析结果 → 展示过滤数据及原因 → 用户确认 → Save。
原则是:系统做了哪些取舍,应尽量让用户明确知道。
第三类:让代码更容易理解和验证
案例一:UT / IT / SmokeIT
这是我沿用了很多年的习惯。
明确区分:
*UT*IT*SmokeIT
并通过 Surefire / Failsafe 控制执行边界。
默认不自动执行 UT / IT,需要时显式开启。
这样会有什么风险
如果所有测试都叫 XxxTest,仅看名字并不知道:
- 是否连接数据库;
- 是否请求真实 HTTP;
- 是否修改数据。
一次普通 mvn test 到底会做什么,也变得不确定。
我的目标
不是追求测试分类形式,而是:我必须明确知道一条命令究竟会执行什么。
扩展阅读:
案例二:Parser / Processor
业务 Excel 导入如果直接写成:
java
parseAndSave();往往会同时承担:
- Excel 解析;
- 业务过滤;
- 规则判断;
- 数据库存储。
这样会有什么风险
想测试 Excel 格式,也被迫一起启动业务环境和数据库。
实际上,JSON / Excel 解析本身通常没有业务含义。
后来的处理
我更倾向于:
- Parser:只负责纯解析;
- Processor:负责业务过滤和处理;
- Save:负责持久化。
这样 Parser 可以完全离线做 UT。
原则是: 可测试性应该反过来影响代码结构。
案例三:真实请求 / 响应进入 src/test/resources
第三方接口如果只依赖外部文档,维护时经常要来回核对:
文档 → DTO → 真实行为。
我更倾向于把真实、脱敏后的请求和响应保存到 src/test/resources,再由 UT 真正读取和解析。
样本只保留协议结构和必要字段形态。姓名、账号、卡号、证件信息、订单号、请求 ID 等可识别或敏感数据都会替换为虚构值,不保存可以还原的真实业务数据。
这样做的价值
一份样本同时承担:
- 示例;
- 文档;
- 测试输入;
- 回归数据。
而且它保存的是:这个系统真实遇到过什么。
例如某第三方文档声明 card 是 String,但真实返回中曾经出现过 String 和 Object 两种形态。
如果只相信文档,未来很容易在“清理代码”时把真实兼容逻辑删掉。
案例四:Mapper / Converter
这里有一个很典型的历史过程。
系统最初使用 JPA 时,持久层使用 UserRepository ,MapStruct 对象转换器可以叫 UserMapper ,两者命名天然不冲突。
后来持久层大量改成 MyBatis,又出现了 UserMapper 。于是系统里同时存在:
- MyBatis
UserMapper - MapStruct
UserMapper
两个不同职责的 Spring Bean 最终发生命名冲突。
为了先解决启动问题,其中一套后来被改成了 SysUserMapper。
启动问题解决了,但职责歧义仍然存在。
后来的处理
我开始明确区分:
Mapper:持久层访问;Converter:对象转换。
我也考虑过类似 UserMapStruct 的名字,但最终没有采用。
因为:MapStruct 是技术实现,Converter 才是职责。
即使未来转换框架发生变化,Converter 仍然成立。
案例五:Persist
数据库对象我采用 UserPersist 这样的命名。
实际项目中经常能见到:
UserEntityUserDomain
但我认为这两个名字都没有 Persist 准确。
Entity 的概念太宽,领域模型中的实体同样可以叫 Entity;Domain 又容易被理解为领域模型。
而 UserPersist 直接表达:这是一个持久化对象。
最终形成:
UserControllerUserServiceUserMapperUserPersist
Persist 并不是我见过的主流行业约定,但在这个系统中,它能让职责更加明确。
我的原则是:类名最后一个单词应该尽量告诉人,这个类是什么。
案例六:Utils / Component / Helper
同样的判断也被用于一些容易混用的后缀。
我现在更倾向于这样理解:
Utils:纯工具类,通常是静态方法,只被调用,不需要实例化;不做外部调用,也不依赖 Spring 等框架,如FreemarkerUtils;Component:更像“需要 Bean 能力的工具”,本身依赖其他 Bean 或框架能力,如专门为渠道编写的XxxPayoutHttpComponent;Helper:需要结合一组自定义上下文或配置构造后再使用,例如带自定义配置创建的HttpHelper;Converter:在当前系统中专门保留给对象转换,尤其是 MapStruct。
这样会有什么风险
如果所有“方便调用的代码”都随意叫 Utils、Helper 或 Component,名称就无法再提供职责预期。
看到一个 XxxUtils 时,还需要打开源码确认它会不会访问网络、会不会拿 Spring Bean、会不会产生外部副作用,那么 Utils 这个名字就没有真正提供信息。
所以这类命名规则并不是为了统一后缀本身,而是希望:看到名字时,就能对它的依赖方式和副作用范围形成一个基本预期。
案例七:无用代码不应该长期存在
遗留系统里很容易留下已经没有入口的 Controller、Service、Repository、DTO、Converter,或者一整条已经退役的调用链。
这样会有什么风险
无用代码留在那里,后来的人并不知道它是真的还在使用,还是“以前用过但现在已经没用了”。
它会继续干扰 Find Usages、全文搜索、依赖分析和业务理解,也会让维护者在修改公共模型时额外判断这些旧代码是否仍然需要兼容。
后来的处理
对于已经能够确认没有入口、没有外部契约、也没有历史运行依赖的代码,我更倾向于把完整死链一起删除,而不是长期保留“也许以后还有用”的实现。
这里的前提仍然是能够确认安全。无法确认的历史代码不会只因为看起来没用就直接删除。
原则是:确定无用的代码就不应该继续存在,但删除本身也需要证据。
扩展阅读:
第四类:建立稳定边界
案例一:系统应该拥有自己的业务语言
系统会对接多个外部渠道。历史系统中存在一个来自某跨境支付渠道的具体审核术语。
但从系统自身视角看,它更接近:“渠道审核”。其他渠道未必存在完全相同的流程和术语。
这样会有什么风险
如果第一个接入渠道的术语直接变成系统领域模型,那么后续所有渠道都会被迫使用第一个渠道的语言来描述自己。
后来的判断
更合理的关系应该是:
系统自己的业务语言 → 渠道适配层 → 外部系统自己的业务语言。
原则是:系统与外部系统应该拥有各自对等的一套业务术语,再在边界处做映射。
案例二:common 不能只有一个
最早很多共享代码都在 commons 中。
后来逐步出现:
common-corecommon-bizcommon-infracommon-mappercommon-pojo
这是迁移过程中逐渐形成的,并不是一开始就设计好的。
common-core
主要包括:
- Exception;
- Annotation;
- Base;
部分真正纯粹的 Utils 以后也可以按需要继续迁入。
common-biz
共享业务能力,例如订单、payer、merchant、user 等公共业务逻辑。
common-infra
无具体业务语义的基础设施,例如:
- 企业微信推送;
- 邮件;
- 短信;
- HTTP;
- 限流;
- Logger;
- Interceptor。
这样会有什么风险
如果唯一判断标准是:
“portal 和 console 都需要,所以放 common。”
那么几年以后 common 自己就会变成新的垃圾桶。
所以:共享只是第一层判断,还要继续判断共享的是什么。
案例三:package 应该反映 Maven module
这是很早就存在的一个目标。
早期已经出现类似:
biz.module.userbiz.module.orderbiz.module.payerbiz.module.merchant
这样的业务包。
后来进一步演化为:
common.bizcommon.infracommon.mappercommon.pojochannel.*
这样会有什么风险
如果看到一个 import 后,还必须 Ctrl+单击进去才能确认它来自哪个 Maven module,那么 package 没有承担足够的信息。
理想状态是:看到一个类的 package,大体就应该知道它在哪个 Maven module。
案例四:边界问题优先于代码风格
遗留代码中仍然存在:
- 巨型 Controller;
- 巨型 Service;
- 很长的方法;
- 大量 if / else;
- 历史命名。
这些都可以继续优化。
但相比代码风格,我现在更关注:它属于哪里。
ordermanagement 包从 700 多个 Java 文件逐步缩到个位数,并不是先重写了所有代码,而是先把它们迁回:
- order;
- channel;
- user;
- payer;
- merchant;
- common;
- 其他真正对应职责的位置。
这样会有什么风险
一个代码写得很漂亮的类,如果放错模块、依赖方向错误、渠道协议进入核心业务,它仍然会持续制造结构债。相反,一个内部风格很差但已经进入正确边界的类,至少以后可以继续局部治理。所以现在更倾向于:先归位,再优化。
案例五:不要为了消灭重复制造错误抽象
“相同文件不应该存在”并不意味着所有相似代码都必须合并。
例如 portal 和 console 的 Service,经常存在:
- 参数不同;
- 分页方式不同;
- 返回值不同;
- 业务入口不同。
如果为了只保留一个 Service,最后在内部写满:
java
if (portal) {
...
}else{
...
}反而创造了一个更差的抽象。
未来也许可以拆出公共 CRUD 的 UserServiceBiz,再由 PortalUserService 和 ConsoleUserService 承担边界差异。
但这件事当前优先级很低。
原则是:完全同义的重复应该消除,但相似不等于同一个抽象。
第五类:控制治理风险
案例一:分布式流程不能假设严格顺序
长期运行以后,真实遇到过:
- MQ 重复;
- callback 重复;
- callback 早于本地数据落库;
- 用户重复点击;
- 主动查询和 callback 路径不同;
- 上游主动重试。
例如曾经出现:
换汇成功 callback 已经到达,但本地记录还没有完成保存。
这样会有什么风险
代码中的理想顺序:
请求 → 保存 → callback
并不是分布式环境中的可靠保证。
如果系统把重复、乱序、延迟、重试全部当成“不应该发生的异常”,很多低概率问题最终都会在真实环境出现。
所以后来越来越把这些情况当成正常分布式语义处理:接受重复、允许乱序、考虑最终一致。
扩展阅读:
案例二:Docker 基础镜像改变了签名结果
曾经出现:
- Java 代码没变;
- JAR 没变;
- 请求数据没变;
- 只改变 Docker 基础镜像;
第三方接口签名却发生异常。
后来通过最小工程、同一份 JAR 和多组 Dockerfile 做对照实验,最终定位到 String#getBytes() 使用了平台默认字符集。
完整链路是:基础镜像默认字符集不同 → getBytes() 产生不同字节序列 → 签名原文变化 → 签名结果不同。
这样会有什么风险
这说明:
“只是基础设施变化”
并不等于:
“不会影响业务行为”。
后来也更加警惕:
- Charset;
- Locale;
- Timezone;
- TLS;
- BouncyCastle Provider。
原则是:只要默认值会影响业务结果,就不应该把它当成稳定契约。
扩展阅读:
案例三:事故时先恢复,稳定以后再处理根因
某次 Redis 故障期间,历史提交里能看到多次尝试、回退以及临时切换方案。
这并不是一次漂亮的架构调整。
当时第一目标只能是:先恢复。
服务稳定以后,再继续处理:
- 独立 Redis;
- RedisUtils;
- 类型安全;
- 用户模型;
- key / prefix;
- TTL;
- 数据隔离;
- Integration Test。
原则
事故期间可以接受临时方案。但服务恢复以后,还要继续判断:是不是还有系统性问题没有解决。
临时方案不能因为“现在能跑”就自动变成永久架构。
案例四:只治理自己能够验证的边界
目前持续处理的大部分都是本系统内部:
- package;
- Maven module;
- Mapper;
- Converter;
- common;
- channel;
- Redis;
- 测试;
- 内部持久层;
- 基础设施封装。
但一旦涉及:
- C 端前端;
- 其他系统;
- API 契约;
- MQ;
- callback;
- 历史数据;
- 核心支付语义;
就会更加谨慎。
例如目前一个 Order 可以对应多个 Payment Intent。
如果从零开始设计,也许不会采用同样的模型。
但现在这个关系已经可能影响:
- portal;
- console;
- 其他系统;
- callback;
- Charge;
- Refund;
- 历史订单。
而且正如前面提到的,订单和入账数据还可能承担一些没有完全产品化的人工补偿语义。
这样会有什么风险
即使本系统内部测试全部通过,也不能证明外部依赖和历史数据语义仍然正确。
而且这类修改的可逆性很差。
所以:“设计上应该怎么做”和“现在线上能不能安全修改”必须分开判断。
涉及外部系统和历史业务语义的契约,目前一律保守。
案例五:稳定但低收益的问题进入 TODO
系统现在仍然存在:
- 巨型 Controller;
- 巨型 Service;
- 很差的历史代码风格;
- 还可以继续细化的抽象;
- portal / console 尚未进一步抽取的公共逻辑。
这些问题不是没有看到。
例如一个 800 行 Controller,我当然会想处理,但真正开始之前还需要分析:
- 影响范围有多大;
- 有没有外部契约;
- 能不能验证;
- 出问题以后能不能回退;
- 不处理的话维护成本是否还在持续增加;
- 当前处理能带来多少收益。
如果它长期稳定、修改频率低、分析成本高、收益有限,就可以先进入 TODO。
原则是:技术债存在,不代表现在就是偿还它的最佳时机。
四、回头看这一年多的治理
1. 不是代码变少了,而是边界发生了变化
2025 年中期,整个仓库大约有 2000 多个 Java 文件,ordermanagement 包中有 700 多个。
现在,整个仓库已经超过 2500 个 Java 文件,但 ordermanagement 包只剩个位数文件。
这个对比很重要。
Java 文件总量实际上仍然在增加。
说明不是:“删除了大量业务,所以超级包自然消失了。”
而是:业务仍然持续增长,同时原来集中在一个超级包中的职责逐步找到了新的边界。
2. 很多架构不是一次设计出来的
common 的演化就是一个很典型的例子。
最开始只是意识到:portal 和 console 共同使用的业务代码应该共享。
后来逐渐发现:
- 共享业务;
- 共享基础设施;
- 共享持久层;
- 共享基础对象;
仍然不是一回事。
于是才逐步形成:
common-corecommon-bizcommon-infracommon-mappercommon-pojo
这并不是一次完整设计以后照图实施,而是在迁移过程中不断发现:原来的边界还不够准确。
很多治理其实都是这样:先解决一层问题,再看到下一层问题。
3. 架构不是目录结构,而是变化应该停在哪里
后来我越来越倾向于用这样一个问题判断边界是否合理:如果这里发生变化,它应该影响到哪里为止?
例如:
- 数据库字段变化,最好停在统一持久层;
- 某个渠道协议变化,最好停在对应 channel;
- 对象转换变化,最好停在 Converter;
- 基础设施变化,不应该无故扩散到业务层;
- 某个业务领域变化,也不应该要求修改大量无关模块。
所以模块化的目标不是让目录看起来更整齐。
真正重要的是:让变化尽量被限制在合理范围内。
4. 验证能力决定了能够承担多大的改动
早期自动化测试不足时,我经常一次只修改很少的类。
改完以后:
- 本地构建;
- 测试环境验证;
- 人工点击主要流程;
- 低峰发布;
- 再观察。
这套方式能控制风险,但成本很高。
如果一次提交修改五六十个类,人工验证能力几乎不可能覆盖所有变化。
所以治理速度的真正上限,很多时候并不是:写代码有多快。
而是:系统能够多快证明这次修改没有破坏已有行为。
这也是为什么后来我越来越重视 UT、IT、SmokeIT 和真实样本。
自动化测试真正提供的不是一个漂亮的覆盖率数字,而是:把一部分“我得亲手再点一遍才放心”,变成系统可以重复执行的验证。
当验证能力增加以后,能够安全承担的改动范围也会随之扩大。
5. 维护者越少,隐式知识的风险越高
自我接手后,这个系统最多同时由 2 个人维护,而且维护者之间存在明显经验差异。
在这种情况下,如果很多规则只能依靠某个人记住,它们就会变成实际风险。
例如:
- 这个字段虽然 IDEA 显示 0 usages,但不能删;
- 这个查询前面其实执行过
startPage(); - 这个 Redis key 实际存的是某种特殊类型;
- 这个包虽然叫
ordermanagement,但这个类实际属于渠道; - 这个第三方字段文档说是 String,但真实环境还出现过 Object;
- 这个 Test 虽然名字普通,但运行以后可能访问数据库;
- 这个订单字段看起来奇怪,但过去可能承担过人工补偿场景。
如果每一个问题都需要先问:
“以前为什么这样写?”
系统就会越来越依赖某个人的记忆。
所以很多看似细小的规则,例如:
- Mapper / Converter;
- Persist;
- package → module;
- UT / IT / SmokeIT;
- 真实请求响应;
- Parser / Processor;
实际上还有一个共同作用:让代码本身承担一部分知识传递。
好的命名、边界、测试和真实样本,不只是为了代码整齐,也是一种低成本的维护和带教机制。
6. 真正需要治理的,不只是代码复杂度,而是不可恢复的复杂度
这个系统本身就不可能简单。
支付、审核、换汇、境内外资金流转、callback、退款、多人工节点,这些都是真实业务复杂度。
这些复杂度不可能通过重构消失。
真正危险的是另一类复杂度:
- 只有某个人知道为什么这个字段不能删;
- 只有某个人知道这个渠道实际上会返回两种格式;
- 只有某个人知道某个订单金额为什么可能被人工调整;
- 只有某个人知道这个测试不能随便跑;
- 只有某个人知道某个类虽然在这个包里,其实属于另一个业务;
- 只有某个人知道某个回调可能比本地保存更早发生。
这些知识一旦随着维护人员变化而丢失,就很难重新恢复。
所以我后来越来越倾向于把治理目标理解成:不是消灭复杂度,而是减少不可恢复的复杂度。
把原本存在于个人经验里的知识,逐渐放进:
- 类型系统;
- 类名;
- package;
- Maven module;
- UT;
- IT;
- SmokeIT;
- 真实请求 / 响应样本;
- 明确的渠道边界;
- 可以追溯的代码和文档。
五、这仍然没有结束
如果从零开始设计一个系统,很多现在需要治理的问题,本来就不会存在。
但遗留系统不是这样。
真正困难的并不是知道理想代码应该怎样写,而是不断判断:
- 哪些问题真的在制造维护成本;
- 哪些问题的成本还在持续增长;
- 修改会影响到哪里;
- 修改是否可逆;
- 有没有足够的验证手段;
- 当前收益是否值得承担风险;
- 哪些应该立即处理;
- 哪些应该留在 TODO;
- 哪些虽然设计上并不理想,但现在最好不要动。
所以这并不是一次“把代码改成自己喜欢的样子”的重构。
更接近于:逐渐把一个需要大量依赖历史经验、隐式约定和个人记忆才能维护的系统,变成一个更多依赖类型、代码结构、模块边界、真实样本和可执行测试就能够理解和维护的系统。
架构也不是目录是否整齐。更重要的是:当一个地方发生变化时,这个变化能不能停在一个合理的边界内。
工程判断也不是“看到坏代码就修改”。
真正要判断的是:这个问题现在是否值得处理,我是否有能力验证,出了问题能不能退回,不处理的话风险会不会继续增长。
最终真正想降低的,也不是代码量。而是:每一次修改带来的不确定性,以及那些只能依赖个人记忆才能恢复的系统知识。
理想状态并不是让后来的人一接触系统,就立刻拥有相同的经验。
而是:即使有一天维护人员发生变化,后来的人仍然能够依靠代码结构、命名、测试、真实样本、Git 历史和明确边界,逐步重建对系统的判断,而不是必须依赖某个人口述整个系统为什么会变成今天这样。
如果能做到这一点,那么治理留下来的就不只是更整齐的代码。
而是一套:可以被继续理解、验证、判断和演进的工程基础。