一次评审会上,年轻同事指着一份设计文档问我:“这个Manager层,是不是有点多余了?我数了一下,一个查询从Controller进来,要经过Service、Manager、Handler,最后才到Mapper,每一层代码长得几乎一样。”会议室安静了几秒,然后有人接话:“架构嘛,分层清晰一点,以后好扩展。”
我特别理解这种反应。过去十多年,我见过太多类似的代码库——每一层都像俄罗斯套娃一样,拆开一个,里面还是一个一样的娃娃。但“架构”这两个字,不该等同于“层次多”。架构的职责是控制复杂,不是制造复杂。今天这篇东西,我就想聊聊为什么简单的代码胜过无脑的层次,以及我自己在项目里用来判断“该不该加这一层”的几条实用标准。
1. 先把话说清楚:架构的职责是控制复杂,不是制造复杂
1.1 为什么我们默认“层多就等于专业”
先说个现象:很多程序员,尤其是刚工作三五年、正好开始参与系统设计的阶段,都有一种“层数越多越正规”的错觉。这种错觉不是凭空来的,它受几个因素影响。
首先是历史遗留的教育路径。很多经典教材讲企业级开发,一上来就是表现层、业务层、数据访问层,层层分明。教材为了把职责讲清楚,自然要划分边界,但读者容易把“教学示例”当成“生产标准”。其次是公司内部的历史代码本身就是这么写的——老项目的三层结构被当成“祖传规范”,新人来了照着加,不问为什么。最后是面试文化推波助澜,八股文里常问“你项目的架构是什么样的”,候选人也倾向于把分层讲得越细越好,似乎层数多就能显得有设计感。
结果就是,在很多代码库里,分层不再是控制复杂度的工具,反而成了复杂度的来源。我见过一个中等规模的订单系统,一个查询用户订单的接口,从Controller进去,先过Service,再过OrderManager,再过OrderHandler,然后才到OrderMapper查询数据库。任何一个中间层都只是转发一下,各层之间甚至没有不同的逻辑职责。这种“套娃”架构,本质上就是把一个本来两步能走完的路,硬拆成五个检查站。
这里要澄清一个概念:架构的价值不在于“有多少层”,而在于“每一层是否承担了独特且必要的职责”。如果两个连续层做的事情没有本质区别,那它们就不该作为两个独立层存在。
1.2 间接层次的真实成本
很多人以为多包一层只是多点代码量,无伤大雅。实际上,间接层次有很具体的成本,而且这些成本会在项目演进过程中被放大。
第一是心智负担。人脑的工作记忆是有限的,一个调用链从起点到终点,每多一个间接层,阅读代码的人就要多记一个跳转。十层的调用链和五层的调用链,阅读难度不是线性增长,是指数增长。因为每跳一层,你都要在脑子里保存“上一层的意图”和“当前层的职责”两个上下文。
第二是调试和排查问题的成本。线上出了Bug,一个查询结果不对,你得沿着调用链一层一层往下跟。每层都可能有一点点数据处理,到底哪一层把字段改错了?断点打在哪一层?如果所有层都是直接转发,排查成本和调用链长度成正比,而收益为零。
第三是性能损耗。这个得分情况说,一般业务系统里,多几层Java方法调用对性能影响微乎其微,几乎可以忽略。但如果是高频路径,或是有代理、有反射、有动态增强的层次,每多一层都是实打实的开销。更重要的是,一旦你为了“可扩展性”先铺了很多层,后来要在层与层之间塞逻辑,比如加个缓存、加个审计,你会在错误的层之间反复试探,改错地方的情况非常多。
第四是改动的连带成本。一个简单需求的改动,本应只动一个地方,套娃架构下往往要动三处以上:接口加一个参数,所有实现类要改,中间转发层也要改。这不是夸张,我见过给一个查询加个“是否包含已删除订单”的布尔参数,结果改了九个文件的案例。
所以,不要轻易引入一个层。每一个间接层都要问一句:它到底屏蔽了什么变化,还是仅仅在传递调用?
2. 无脑层次的三种典型症状
2.1 为“可扩展性”预支的结构
无脑层次最常见的理由是“万一以后要扩展呢”。我特别理解这种未雨绸缪的愿望,但它经常演变成“为不存在的变化预先设计结构”。
举个例子。某系统现在只有一种订单来源——小程序下单。有人设计了一个OrderSource接口,下面只有一个WechatOrderSource实现类,然后所有业务代码都通过接口调用。理由是:“以后肯定会接入App、接入第三方平台,到时候只要加实现类就行,业务代码不用改。”
这就是典型的预支抽象。问题在于:你现在根本不知道未来接入的新来源会带来什么差异。也许新的来源根本没有“优惠券抵扣”这个概念,也许新的来源有“门店自提”流程,这些差异会深刻影响业务代码的结构。你现在抽象的OrderSource接口,大概率在真正的第二来源到来时被推翻重来,因为变革不在你预想的方向上。
YAGNI原则(You Aren‘t Gonna Need It,你不会需要它)很多时候被误读为“不要写多余代码”,其实它更深层的含义是:不要在信息不足的情况下做结构性决策。等真正的变化来临时,你掌握的信息足够多,做出的抽象往往比现在的空想准确得多,而且重构成本未必高——因为当时的代码简单,改动范围清晰。
我现在的基本立场是:只有一份实现时,不建接口。等待第二个实现出现时,再抽取接口。这不是懒,而是让抽象来自真实需求,而不是想象。
2.2 只为“对称美”设计的同构层
另一种常见的套娃,是纯粹为了形式上的对称。团队定了“Controller-Service-Mapper”三层结构,于是所有功能都套这个模板,不管这个功能本身多么简单。
比如一个“根据ID查询用户姓名”的接口。Controller调Service的getUserNameById,Service里没有任何业务逻辑,直接调UserMapper的getUserNameById,Mapper查数据库返回。这三层代码,从方法名到参数到返回值,几乎完全一样。为什么要分成三层?因为“规范”要求这么分层。这就是对称美驱动的设计——每一层都必须存在,否则看起来“不完整”。
这种同构层的危害,除了前面说的心智负担之外,还有一个不易察觉的点:它会掩盖真正的职责边界。当一个Service方法只做转发时,程序员会把一些本不该属于Service的逻辑也塞进Service,因为“反正这里有个方法,往里面加点东西方便”。于是Service越来越臃肿,而Controller和Mapper层又太薄,职责边界变得混淆不清。
更好的做法是:让每一层的存在都由职责驱动。没有业务规则需要统一收口时,Controller直接调Mapper也不是不行。等到真的出现“多个Controller都要用同一个业务规则”时,再把这条规则提升到Service层。层次应该是“长”出来的,而不是“套”上去的。
2.3 被“禁词化”的设计原则
还有一类套娃,是设计原则被背成了教条。单一职责原则变成了“一个类只干一件事”,于是有人把原本一个类里自然的几个方法,拆成五个类,每个类一个方法;开闭原则变成了“对扩展开放”,于是做任何修改都想着加一层,而不敢动已有的类。
我曾经接过一个老系统的维护,里面有个订单状态的流转逻辑,本来在一个状态机类里还算清晰。后来维护的人觉得“不能让状态机类太庞大”,每加一种状态流转,就新建一个类、注册一个扩展点。半年之后,这个扩展点机制本身比它要解决的业务问题还复杂,新来的同事光理解这套扩展机制就要花一周。
设计原则本来是为了让代码更贴近业务变化,但当它们被“禁词化”——字面上照搬、不考虑上下文——就会走向反面。真实世界里的设计原则,每条都有它的适用边界和代价。单一职责强调的是“改变的原因”要单一,不是“类的数量”要单薄。开闭原则的精髓是多态应对变化,但前提是变化真的会发生,而且方向可预判。没有这些前提的教条应用,就是在制造套娃。
所以每当你准备用某个原则来指导加层时,先问:这个设计遵循原则之后,是让代码更容易改了,还是让代码更难懂了?原则是工具,不是目的。
3. 什么时候真的需要层次:分界线的判断方法
3.1 抽象的理由必须来自变化点
说了这么多“不要乱分层”,那到底什么时候该分层?我自己的核心判断依据是:变化点。
意思是,只有当你能指出一个具体的变化点,而且这个变化点在未来一到两个迭代内真的有概率触发时,才值得为它建立抽象层。变化点通常有两类。
一类是“多个真实实现”。现在就要接入两个以上的数据源,比如订单既来自自营系统,也来自第三方同步系统,二者的数据结构和处理逻辑有明显差异。这时候建立一个OrderSource接口,让各实现自己处理差异,业务侧统一调用,这是合理的抽象。
另一类是“重要的外部边界”。比如系统要接入一个不稳定或不规范的外部系统,你需要在边界处做防腐处理,把外部结构转成内部模型,阻止外部变化影响核心逻辑。这种边界层的价值是立刻能看到的:外部接口变了,你只需要改边界层一处。
判断的时候可以参考下面这几个问题:
- 这个层是否屏蔽了一个真实的、具体的差异?
- 如果不建这个层,这个差异会渗透到多少处代码?
- 这个层自身是否稳定?会不会因为变化而频繁修改?
- 去掉这个层,调用方是否仍然清晰易懂?
如果答案偏向“没有具体差异、纯粹是规范驱动”,那这层就不该存在。
3.2 领域边界与技术边界要用在不同位置
分层还有一个常见的混淆:把业务分层和技术分层混为一谈。实际上,这两种边界应该放在不同的位置,解决的问题也不同。
业务分层解决的是“业务规则的表达和复用”。比如一个订单提交的校验逻辑,涉及库存、优惠、风控多处业务联动,你把它收敛到领域层,让Controller或者入口只做流程编排,这是有意义的。因为业务规则本身上下凝聚、内部复杂,统一收口后,后期改规则时只有一个地方需要改。
技术分层解决的是“基础设施与业务解耦”。比如你希望业务代码不直接依赖数据库、消息队列、文件存储的具体实现。你在业务和基础设施之间架一层仓储(Repository)接口,业务只依赖接口,底层实现可以替换。这种分层的价值在于:基础设施变化时,业务代码不感知。
关键区别在于:业务分层的变化点来自业务领域内部,技术分层的变化点来自外部基础设施。两种变化点都真实存在,所以两种分层都有合理依据。但如果一个层既没有承载领域逻辑,又没有屏蔽基础设施变化,它就纯粹是在“搭架子”,没有实际意义。
我见过很多代码库,Service层声称承载业务逻辑,但真正往下看,里面的业务逻辑非常稀薄,大量方法就是查一个表、返回一个VO。这种层既没有成为业务规则的收口点,也没有成为技术边界的隔离点,它只是一个物理存在、逻辑空转的“层次占位符”。
3.3 三层视角:按“变化的频率”和“变化的范围”分层
最后分享一个我个人比较常用的分层视角,不复杂,就是按两个维度来观察代码:变化的频率和变化的范围。
变化的频率,指的是某段代码隔多久会改一次。经常改的业务校验,和几乎不变的数据库表结构映射,它们的变化频率完全不同。变化的范围,指的是一个改动会影响多少调用方。影响面大的层,要谨慎设计边界;影响面小的层,可以适当粗糙。
如果把代码按这两个维度看,真正需要分离的是那些“变化频率不同”的部分。比如订单金额计算逻辑经常变(优惠规则、定价策略),而订单记录的存储方式很少变。你把常变的计算逻辑和少变的存储逻辑绑在一个类里,每次金额规则变了都要动存储相关代码,这种耦合才真正需要拆层。
反之,如果两部分的代码变动频率和范围都差不多,那拆不拆层意义不大。拆了反而增加阅读和调用成本。所以每次有人问我“这两块代码要不要分开放”,我的回答总是:先看看它们各自的改动节奏。如果总是同进退,放一起是简化;如果各自改变,才考虑隔离。
这个视角的好处是,它不依赖任何教条,只依赖你对自己业务代码的观察,而且非常直观。
4. 动手简化:一个重构实录
4.1 重构前:三层接口六步跳转的订单查询
为了看得更清楚,我拿一个真实的简化案例来走一遍。这是以前接手的一个订单查询接口,功能很简单:根据订单ID查到订单主记录,连同明细一起返回给前端。重构之前的调用链是:
OrderController.getOrderDetail -> OrderService.getOrderDetail -> OrderManager.getOrderDetail -> OrderHandler.queryOrder -> OrderRepository.findOrderById -> OrderMapper.selectOrder每条链路里还有对应的接口和实现类:OrderService接口加OrderServiceImpl实现,OrderManager接口加OrderManagerImpl实现,OrderHandler接口加OrderHandlerImpl实现。每个类里的代码大同小异,大部分方法就是调下一层的方法,返回结果,有些方法顺手加了个空注释“// 待补充业务逻辑”。
问题在哪里?非常明显。
第一,调用链六跳,其中四跳没有任何业务逻辑,纯粹是透传。第二,任何字段的增删,要改动从Controller到Mapper的结构体定义,传参对象在很多层之间被转换、复制,光看字段名对不上要花半天。第三,新手接手,第一周时间全花在搞清楚“我该往哪一层加代码”上,而不是理解业务本身。第四,没有一处提供额外价值——没有缓存,没有监控埋点,没有权限校验,没有业务规则,什么都没有。
4.2 重构后:保留哪些、合并哪些、删除哪些
重构的目标不是“把层次减少到没有”,而是“让每一层都回答一个问题”。我最后确定的方案是三层:
OrderController(入口参数校验 + HTTP语义处理) -> OrderQueryService(业务编排) -> OrderRepository(数据访问,含基础缓存)注意这里发生了什么。
删掉了OrderManager和OrderHandler这两层透传,因为它们不承担任何独特职责。保留了OrderRepository,因为它确实承担了数据访问的封装,而且未来如果要把订单存储从MySQL迁移到分库分表或者加缓存,只动这一层。保留了OrderQueryService,因为查询订单涉及到“订单主记录、明细记录、可能还有优惠记录”的组装,这些数据的获取与拼装逻辑确实有业务味道,值得单独收口。至于Controller,它不需要接口抽象,只有一个实现,直接类引用即可。等未来真出现多个Controller入口需要复用这套查询逻辑时,再考虑是否提升方法。
重构后的调用链只有三层,每一层的职责都清楚,任何人接手这个模块,读一遍三个类就知道整个查询流程。
重构带来的实际效果:单次需求改动涉及的文件从九处降到了两到三处;新同事上手时间从一周缩短到两天;代码量减少了接近五成。最关键的是,当业务真正要加新的东西时——后来确实加了“订单附带发票信息”——我们知道该改哪里,不需要在六层结构里猜来猜去。
4.3 简化之后如何防止回潮
重构完不等于一劳永逸,代码架构有一种“熵增”倾向,过几个月又会有人为了某种“规范”往上加层。我的经验是三管齐下。
第一,把简化原则写进团队约定。我们团队现在的代码评审Checklist里有三条硬性问题:你新增的这个层/接口,当前有几个实现?它是否屏蔽了一个具体的差异?删掉它,调用方代码会怎样?如果三个问题都没有令人满意的答案,评审原则上不通过。
第二,给关键链路画一张“架构说明图”。就一张白底黑字的调用链图,钉在WIKI首页,任何人要加层,先看图:你打算插入在哪两个节点之间?理由是什么?如果理由说不通,就往回走。
第三,定期做“复杂度巡检”。我在团队里每个月会对几个核心模块做一次调用链长度统计,超过一定阈值的(比如查询链路超过四跳),就约上负责人聊一聊,看看能不能合并。把这件事常态化之后,代码库的层次膨胀速度明显降下来了。
还有一个小技巧:每当代码评审里有人喊“万一以后要扩展”,我们就要求他把“以后”具体化——什么时候?什么场景?扩展什么?如果说不出来,那这个问题就暂时不存在,不值得现在花费结构成本。
5. 与团队站在一起:让“简化”成为可沟通的共识
5.1 评审会上怎么顶住“万一以后要扩展”的质疑
提简化方案,十有八九会遇到阻力,最大的阻力就来自那句“万一以后要扩展”。怎么有理有据地沟通,我有几句比较实用的回法。
第一句:“请列出你预见的那个扩展点。”这不是刁难,是逼对方把模糊的担忧具体化。如果对方说以后要多接几个数据源,那好,讨论这个多数据源的差异具体在哪,现在就为这个差异建层合理。如果对方说“我也说不清,但多一层保险”,那这一层就不该加——说不清的保险,代价却清清楚楚:现在每一个改动都要穿透这层。
第二句:“当前这层没有任何独特职责,你写它本身也是有成本的。”绝大多数人只看到多一层“以后会省事”,没看到多一层“现在就不方便”。你用一个具体的当前改动来演示:比如给字段加个返回值,现在要改几个文件?如果由于这层存在导致改动翻倍,就是最好证据。
第三句:“如果真到了那一天,我们重构的成本分析过吗?”很多人默认“现在不建接口,以后要改代码成本很高”,但实际上,一个简单的系统里,等到第二个实现来临时抽取接口,往往只需要半天。而为了一个不存在的将来,现在维护额外的层,每个月都在付费。
5.2 复杂度的可视化:把“改动一个需求要动几处代码”量化
推进简化,光靠理念争论容易陷入各说各话,我更推荐用数据说话。这年头大家的共识基础是“可测量的才算可靠”,那我们就去量化复杂度。
我个人在团队里用的核心指标有四个:
- 单需求改动文件数:统计最近二十个需求,平均每个需求改了哪些文件。套娃架构下这个数字通常很难看。
- 调用链平均长度:随便抽核心模块十个接口,数一下从入口到数据库访问之间的方法调用跳数。超过五跳的,基本都能找出透传层。
- 圈复杂度与认知复杂度:用静态分析工具跑一下,关注那些“看起来每层都有方法,但每个方法都极简单”的模块,这种模块常常就是结构膨胀的重灾区。
- 注释与代码的背离程度:如果一个层里的注释比代码还多,而且注释在描述“为什么要这层”,那大概率说明这层存在本身就是需要解释的补偿行为。
把这些指标贴到周会或者架构评审上,比单纯说“我觉得这层没意义”要有说服力得多。很多反对者在看到“一个查询改动要动九个文件,而其中七处只是转发”的统计数据后,态度都会松动。
5.3 小步重构与代码所有者的接受度
有了数据和共识,动手重构时也有一点要特别注意:节奏。
一次性大爆破重构,风险高,代码所有者也容易抗拒。我习惯的做法是“试点先行、小步快走”。挑一个业务相对独立、调用方少的模块,先做一轮“去套娃”重构,把改动前后的调用链、代码量、改动文件数对比表发出来,让大家看到效果。有了一个成功样本,后面再推其他模块的简化,阻力会小很多。
另外,重构尽量不要侵入正在活跃迭代的业务模块。选那些“最近三个月都没怎么变更需求”的模块动手,风险最小,成果又容易让人一眼看懂。而且这类模块往往是最能反映纯结构问题的——没有业务需求在掩盖结构问题,一层层透传就摆在眼前。
如果代码所有者担心改动影响线上,那就先用对比测试兜底:重构前记录所有接口的输入输出样例,重构后跑一遍对照,逐字段比对。这一步做完,绝大多数技术风险就已经消除了。剩下是人的接受度问题,靠数据和耐心聊,靠成功案例带动,不靠强行push。
最后聊两句心里话
写了这么多,其实核心就一句话:架构该做的是“拦截复杂度”,而不是“囤积层级”。判断一个架构好不好,永远要看它能不能让你改代码更轻松,而不是看它的结构图画得有多宏伟。
我自己在项目里吃过太多套娃架构的亏。那种看着规范、实则处处透传的代码库,表面上层次分明,实际上每一次需求变更都变成穿越层层关卡。反而是那些看起来不那么“正规”、但每层都能回答“我为什么在这”的代码,长期维护下来最省心。
最后分享一个小技巧:每次提交代码前,翻翻自己这次加的类或接口,问一句——这个新层次,是让我这段代码更好懂,还是更像一份“未来才用得上”的保险?如果答案是后者,那它大概率不该存在。这个自问的动作,帮我避免了很多不必要的套娃。希望你也能试试。