☰
架构不是套娃:简单代码胜过无脑的层次
2026/10/6 9:44:23 网站建设 项目流程

架构不是套娃:为什么简单的代码胜过无脑的层次

上周我参加了一次代码评审,项目组的同学把Controller、Service、Manager、DAO、Converter、DTO、VO全给安排上了。一个查询订单列表的接口,调用链是这样的:Controller → OrderService → OrderManager → OrderMapper → 数据库,中间还夹着一层 OrderConverter,负责把 Entity 转成 DTO,再把 DTO 转成 VO。我随手点开 OrderManager,发现整个类只有三个方法,其中一个方法就一行:return orderMapper.selectById(id);。

我问:为什么要这层 Manager?回答是:架构规范要求这样,以后业务复杂了用得上。

这个场景我见得太多了——它几乎成了很多团队的默认姿势:把架构理解为套娃,层数越多越专业。但实际体验却是灾难级的:读代码要跨五个文件、改一个字段要过五道门、新来的同事看着这叠抽象直接懵。我想借这篇文章把一个问题彻底说透:为什么简单的代码能赢过无脑的层次,架构到底应该解决什么问题,以及我们怎么把已经套上的娃一层层拆下来。

这篇文章没有立场之争,只聊实践。它基于我个人这些年做业务系统、带团队、做架构治理的真实体感,适合被各种架构规范绑架的开发者,也适合刚入行一心想搞大设计的同学。

1. 一次让我失眠的评审:Service 层只是“跑腿的”

先说那次评审的完整细节吧,它几乎能概括套娃式架构的全部病征。

那是一个很普通的订单查询功能,需求也就是“查列表,展示状态和金额”。代码层面涉及 8 个类:

  • OrderController:接收请求,调用 OrderService;
  • OrderService:定义接口,做事务标记,调 OrderManager;
  • OrderServiceImpl:实现类,方法体就是把入参转给 Manager,返回结果转给 Converter;
  • OrderManager:我原本以为这里会有业务规则,结果大部分是透传;
  • OrderMapper:MyBatis 的 Mapper 接口,真正执行 SQL;
  • OrderDO:数据库实体;
  • OrderDTO:业务层的数据对象;
  • OrderVO:给前端展示的数据对象。

新增一个“订单来源渠道”字段,我算了一下要动多少地方:数据库加列,OrderDO 加字段,OrderDTO 加字段,OrderVO 加字段,Converter 里加三行转换逻辑,Service 接口不用动但实现类可能要加参数,Controller 的入参和出参也要对应调整。也就是说,一个最简单的字段扩展,要触碰 6 到 7 个文件。

更让我不理解的是,OrderService 的很多方法根本没有业务逻辑,就是帮 Manager 传话:

@Override public OrderVO queryOrderById(Long id) { OrderDO orderDO = orderManager.queryById(id); return orderConverter.toVO(orderDO); }

而 OrderManager 自己呢:

public OrderDO queryById(Long id) { return orderMapper.selectById(id); }

两层加起来四行有效逻辑,却要拆在两个类里,还配了一个接口、一个实现。我问写代码的同学中间为什么不直接调 Mapper,他说 Manager 以后可以扩展。我再问:那现在这个 Manager 除了传话,有没有任何哪怕一条独有的规则?没有。那它凭什么叫 Manager?

这种代码我看过太多,它有一个共同特征:当你准备删掉某一层,把所有调用直接接到下一层时,发现系统完全不会受到任何影响。这话听着像玩笑,但它是判断“装饰层次”的黄金标准。你删掉 OrderManager,Controller 和 Service 直接调 Mapper,功能照跑,测试照过,唯一的损失是“架构图少了一层”。那这一层从一开始就不该存在。

那次评审之后我失眠了一小会儿,不是为这个项目担心,而是想起自己早年也这么干过。当时刚学完“三层架构”,觉得不写 Manager 就不是好系统。直到后来在一个老项目里,我第一次体验到什么叫“代码很干净但根本没法干活”——每个类都长得很规矩,但人和人的协作效率低得惊人。从那以后我养成了一个习惯:任何新增的架构层次,都必须回答“它承担了什么不可替代的职责”,回答不上来就砍掉。

2. 层层套娃是怎么长出来的:过度分层的三条产生路径

过度分层很少是一夜之间拍脑袋拍出来的,它通常沿着三条非常具体的路径慢慢长出来。理解了这三条路径,你就能在自己的代码里及时踩刹车。

2.1 把“设计模式”当成必须执行的部门规章

第一类套娃来源于对模式的崇拜。很多开发者在学设计模式时会形成一种错觉:一个类不实现个接口、不套一层抽象,就“不够面向对象”。我见过有人为了一个只有一种实现的支付服务,强行定义了 IPaymentService 接口、PaymentServiceImpl 实现、AbstractPaymentService 基类,还嫌不够,又加了一个 PaymentStrategy 策略接口来包住实现类。

我问他哪里能看出多态的必要性——他回答“以后会有多个支付渠道”。但现实是,这个系统从上线到退役,始终只有微信支付。那这些抽象层花了多少成本?每次改支付参数,你都要同时改基类、接口、实现类、策略类,改完还要保证四处同步。这已经不是设计模式了,这是在为想象中的需求写代码。

模式是用来解决已经出现的扩展点,不是用来给未来开空头支票的。策略模式真正的价值在于:支付宝、微信、银行卡三种支付方式摆在眼前,且它们的算法骨架相同、变化点不同,抽象能帮你减少重复。如果只有一种支付渠道,直接写一个类就是最好的设计。单实现接口、单实现抽象类、没有任何支路的抽象,基本都属于给代码“上刑”。

2.2 面试八股与团队规范的惯性

第二类套娃来源于行业惯性。“Controller-Service-Mapper 三段式”几乎成了 Java 后端的默认出厂设置。这个结构本身没有错,它对应的是早期 Web 应用的典型场景:接口层、业务层、持久层确实有各自独立的关注点。但它被滥用是因为很多团队把它当成“无论业务大小都必须遵守”的模板,而不是“根据你实际复杂度来调整”的参考。

结果就是:一个只有两张表、一个增删改查接口的小功能,也要造出 Controller、Service 接口、ServiceImpl、Mapper、Entity、DTO 六件套。写起来特别慢,读起来更慢。我经常和团队说:三层架构是主流,但主流不等于任何场景下都是最优。你做的是内部管理台的一个小模块,业务就几十行,你怎么设计都不需要套五层壳。架构规范应该是一把尺子,不是一根锁链。

2.3 防御式抽象:赌未来一定复杂

第三类套娃最隐蔽,叫“防御式抽象”。它看起来特别理性:现在虽然简单,但以后业务会复杂,现在把层次留好,以后就能平滑扩展。

问题在于,这种“以后”从来没有准确的时间点,也从来没有被验证过。等到三年后业务真的复杂了,留下的那些空壳层次要么已经被忘干净,要么因为当初猜错了方向而不适用。你为了一个永远不会来的复杂度,先支付了三年的维护税。

《极简主义》里有一句我特别认同的话:YAGNI,你不是真的需要它。代码里的每一个抽象都是有成本的,它要有人写、有人看、有人维护、有人在改 bug 时多跳一层。你留的“扩展点”越多,系统越早进入动什么都怕的状态。等新同事看到那一堆接口和 Manager 层时,他根本不敢判断哪个能用哪个是摆设。

防御式抽象还有一个后遗症——它会自己长大。因为空壳层次放在那儿很碍眼,后来的人为了圆这个结构,会继续往里填包、填类、填转换器。就像一个塞了太多隔板的柜子,为了不浪费隔板,你开始往每个格里塞本来不需要的东西。最后,复杂度的确变得“很有组织”,但也彻底失去控制。

3. 套娃的真实代价:四个被多数人忽略的成本

讨论架构时,大家喜欢聊“设计合理性”“扩展性”“整洁性”这些玄的词,很少聊具体的账。我这次把它摊开来算一算,套娃式架构到底贵在哪。

3.1 阅读成本是一笔算术题

读代码的成本 = 你需要在多少个文件之间跳转。跳转次数跟复杂度成正比,而且不成线性,是超线性。

一个没有套娃的查询逻辑,从 Controller 到 Mapper 就两层,读代码的人脑子里的上下文大概是这样:参数进来 → 验证一下 → 查数据库 → 返回数据。四步,一个文件或两个文件。整个过程是线性的,读到哪理解到哪。

但套娃版的查询逻辑呢?从 Controller 开始,你要先看 Service 接口,再看 ServiceImpl,然后看 Manager 接口、ManagerImpl,再看 Mapper 的 SQL,最后还要看 DTO/VO 转换。每跨一个类,你的短期记忆里就要多写一行“这个类是干嘛的”,多一次方法签名的换算。如果八个类之间的调用关系不是完全直线,中间还有分支和循环,那复杂度直接爆炸。

我做过一次简单的测试:拿同一个功能的两版实现,一版无分层透传,一版五层套娃。让团队里两个水平相近的同事分别读,无分层版大概 5 分钟就能讲明白数据流;套娃版要 15 分钟左右,而且中间会反复翻回去看“这个 Converter 到底转了什么”。这就是硬成本——15 分钟一个人,十个人就是 150 分钟。而代码每天都在被读,这笔账滚起来非常吓人。

3.2 修改链条变长:改每个字段都像过安检

有人会说,读代码慢点没关系,改起来扩展性好就行。但套娃架构的修改成本同样被低估了。

举一个我实际遇到过的例子:某系统的用户信息页需要展示用户的会员等级名称。原本的调用链是 Controller → Facade → Service → Manager → Mapper → BO → DTO → VO。当时数据库里存的是会员等级 ID,前端要的是等级名称,于是代码里的处理方式是多查一张字典表。

改这个需求,需要动:VO 加字段,DTO 加字段,BO 加字段,Manager 加查询逻辑,Service 加转换逻辑,Facade 加一次透传,Controller 的响应结构加字段。整整七个地方。新人接到这个任务,光是把数据从数据库一路“运”到前端,就要看五个类的关系,其中任何一个类漏加字段,编译不报错,但线上显示 null。

说得重一点,套娃层次的每多一层,就相当于给数据流加了一道“安检闸口”。数据每走一层,都要被检查一遍、转换一遍、复制一遍。至于这些转换到底有没有意义,往往是没人能回答的。真正的业务改动,很多就被淹没在这种“过安检”的过程中,团队把一天的工作时间花在了挪数据上,而不是思考业务上。

3.3 抽象泄漏:分层没有藏住复杂度,只是把它分配到更多类上

架构界有一个公认的定律:任何非平凡的抽象,都会有泄漏。意思是,你无论怎么封装、怎么分层,底层的复杂性迟早会从某个角落渗出来。

套娃架构最大的悖论就在这里:你以为多包几层能藏住复杂度,实际上只是把复杂度拆散了撒在更多的类里。数据库加了个字段,DO 要改,DTO 要改,VO 要改,转换器要改,这一串本身就是泄漏的结果——你没有通过抽象减少变化点,只是把同一份变化复制到了很多地方。

真实世界里,复杂性是守恒的。订单要有状态字段,这个状态要么放在数据库实体里,要么放在业务模型里,要么放在前端 VO 里,但它总归要存在。好的设计不是让这个状态“消失”,而是让“改状态的实现”只牵动最少的地方。套娃架构恰恰相反,它让状态字段遍布五六个类,每一个都是改动时的泄露点。而且层越多,你越难说清这个状态到底应该由谁负责——这就是所谓的“局部合理、全局失控”。

3.4 测试、调试与“把套娃分布式化”的连锁反应

这一层很多人体会不深,我多说两句。套娃架构对调试的惩罚非常直接:当线上出问题,报错栈里从 Controller 到底层 SQL 可能横跨五六个类,你要逐个排除。如果中间某一层是那种“空转”的透传方法,你查半天才发现它没做任何事,那种感觉真的很想摔键盘。

更麻烦的是测试。有些人喜欢给这种套娃结构写单元测试,结果发现 mock 链比自己写的代码还长:mock Controller,mock Service 接口,mock ServiceImpl 内部调用的 Manager,mock Manager 里调用的 Mapper。真正被测的逻辑只有一行,测试代码却写了一百行。这不是测试的问题,是设计的问题——因为你把不可测的复杂性分布在太多层次上了。

这套逻辑再往上蔓延,就催生了另一个现象:很多团队觉得系统复杂了,第一反应是上微服务、上分布式架构,恨不得把一个类拆成十个服务。结果套娃还在,只是从方法级套娃升级成了服务级套娃。原本一个查询要跨 5 个类,现在要跨 5 个进程,接口联调、网络超时、数据一致性全部变成问题。分布式架构是解决并发规模和团队协作边界的工具,不是解决“代码分层太多”的工具。你没把层次理顺就上微服务,等于把一栋结构混乱的大楼硬拆成十栋大楼,裂缝只会更多。

4. 简单代码为什么能赢:复杂度守恒与变化边界的账

说了这么多套娃的坏话,得说说好的架构长什么样了。我的观点很简单:好的架构不是为了好看,而是为了管理复杂度的变化边界。而大多数简单代码之所以能赢过套娃,是因为它在复杂度守恒定律下,把该承担的复杂度放对了位置。

4.1 复杂度守恒:抽象不会消灭复杂度,只会转移它

先记住一句话:任何系统的业务复杂度都是守恒的,你不可能通过加一层抽象让它消失。订单有状态机,支付有回调,库存有并发扣减,这些复杂度从你接到需求那一刻就存在了。你写不写 Manager 层,它都在那里。

那抽象的价值是什么?是隔离变化,让某种复杂度在变化时只影响一小块区域。数据库字段变化只影响 ORM 实体,前端展示变化只影响 VO,这是合理的隔离。但如果你的隔离层本身也是同一块区域的一部分(比如业务逻辑和数据库访问根本没有分离,你却硬拆了两层),那这个抽象就没有隔离任何东西,它只是在给代码搬家。

这就是为什么简单的代码能赢:它不假装复杂度不存在,而是尽量让一个类、一个方法承担完整而清晰的职责。同样一个订单状态流转,在一个类里用状态机写得清清楚楚,肯定比拆成“OrderStateService”“OrderStateManager”“OrderStateTransition”再加接口实现让人省心得多。

4.2 变化的边界:按变化率切分,不按职责名切分

那什么才算真正的边界?我这些年总结了一条很实用的原则:看变化率。变化率相近的代码放在一起,变化率差异大的地方才是需要做隔离的边界。

数据库表结构、ORM 实体和 SQL 的变化率是一致的——表加字段,实体跟着加。把实体和 Mapper 放在一起没啥问题。

前端展示逻辑(VO、DTO)的变化率也很高——前端三天两头改字段。让 VO 跟上层的 Controller 直接在一起,比让它跟 DO 反复转换更合理,因为“给前端什么数据”这个问题本来就属于接口层。

业务规则的变化率介于两者之间,但它只跟业务本身相关,跟数据库结构并不完全同步。比如“订单超过 30 分钟未支付自动关闭”,这条规则是业务的核心,它被放在 Service 里还是 Domain 里,取决于你的架构风格,但它的变化频率和数据库表结构的变化频率明显不同,所以它不应该和 DO 混在一起。

真正的边界就画在这些“变化率不同”的临界处。你画出的每一条边界,都是为了挡住一边的变化波及另一边。而套娃架构里的很多层次,两侧的变化率根本没差别——Service、Manager、Repository 可能一年都不变一次,那它们之间的边界就是摆设,白白增加沟通成本。

4.3 简单代码的设计质感:少即是多

有人会误以为“简单代码”等于“没有结构”“一把梭”。真不是。简单代码和草率代码之间有一条明确的分界线:简单代码依然有清晰的职责划分、准确的命名、明确的依赖方向,只是它不搞没有信息量的转发层。

看一个方法,理想状态是你能在 30 秒内说出:它接收什么,输出什么,中间做了哪几件事。比如:

public OrderDetailVO getOrderDetail(Long orderId, Long userId) { Order order = orderRepository.findByOrderIdAndUserId(orderId, userId); checkOrderVisible(order, userId); List<ItemVO> items = itemConverter.toItemVOList(order.getItems()); return OrderDetailVO.of(order, items); }

三件事,三个依赖,一眼到底。你不会去猜“orderRepository 是不是还有一层 orderRepositoryImpl 包着”。如果将来业务复杂了,需要加缓存,你可以在这个方法内部加一行cache.get(orderId);需要加权限判断,你已经在做了;需要换成读写分离,那是 repository 内部的事。它不是没有结构,它的结构就是和当下最匹配的结构。

把代码拆得多细很容易,把一个方法演化到刚刚好很难。刚刚好意味着:你增加的每一个类、每一层抽象,都能直接回答“删掉它我会失去什么”。回答不上来的,就是债务。

5. 什么时候分层是必要的:边界不是用来凑数的

文章写到这儿,如果给你留下“所有分层都该砍掉”的印象,那是我表达得不够准确。分层是软件工程一个极其重要的武器,问题是很多人体会到了它的威名,却没体会到它的适用边界。这一节说说我心目中真正“值得分层”的几个场景。

5.1 接口层与业务层:GUI 时代就存在的合理隔离

最简单也最经典的一处分层,是接口层(Controller/Handler)和业务层(Service/Domain)的分离。它的历史可以追溯到 GUI 时代,那时候叫“三层结构”(表示层、业务层、数据层),目的很明确:界面可以随便改,底层业务不被牵着走。

直到今天这个边界依然成立。Controller 负责处理 HTTP 协议细节——参数校验、状态码、响应包装,Service 负责业务规则。你把这段边界拆了,让 Service 直接抛一堆 HTTP 状态码给框架,系统会乱套。这类分层的价值显而易见:变化源不同,一个是网络协议,一个是业务规则,频率不同,方向不同,必须隔离。

5.2 外部依赖隔离:数据库和第三方服务是真正的火山口

第二类值得做的隔离,是围绕“外部世界”的边界。数据库、Redis、第三方接口,这些都属于你控制不了、变化也由不得你的外部系统。业务代码直接写 SQL、直接调第三方 HTTP 接口,短期能跑,但一旦对方接口调整,或者数据库分库分表,你的业务代码会被牵连得血肉模糊。

所以 Repository 模式、防腐层(Anti-Corruption Layer)在这个语境下是真有用的。它把“外部怎么变”挡在门口,让业务代码只说“我要什么数据”,不问“数据从哪来”。这个抽象不是装饰,它是真正的防弹衣。注意,它和那种“业务内部套 Manager”有本质区别:它隔离的是不同的变化源,而不是在同一个变化源里玩接力。

5.3 多团队、多服务边界:分布式的边界反而要少而清晰

进入微服务架构、分布式架构之后,很多人产生一个误解:服务拆得越细,边界越多,架构越好。实际恰恰相反,服务间每多一条边界,你就要多一份网络通讯、多一份数据一致性负担、多一份部署协调成本。所以分布式架构的边界应该是“少而清晰”,不是“多而密集”。

一个领域内的强内聚业务,哪怕很大,也值得放在一个服务里,通过模块来划分内部结构;只有当某个子域变化频率明显不同、需要独立扩展、需要独立团队维护时,才值得把它拆成独立服务。你要是按数据库表来拆服务——用户服务、订单服务、订单项服务,那会死得很惨,因为一次查询要跨三个服务,你等于把套娃从代码层搬到了运维层。

从这个角度看,微服务的正确用法不是“让层次更多”,反而是“让跨进程的调用更少”。真正的高手做微服务,都在努力减少服务间调用,把高频业务放进同一服务内部完成,而不是为了“边界清晰”把一家人硬分成三个户口本。

5.4 判断标准:删掉这层,我会失去什么?

聊了这么多,很多同学可能还是不知道具体怎么判断。我给你一个傻瓜但好用的判断标准,拿去套任何一层都行:删掉这层,把下层的实现直接暴露给上层,我会失去什么?

  • 如果答案是“失去网络隔离、失去安全性、失去外部依赖封装”——这层该留。
  • 如果答案是“失去一份整洁感、失去一个以后可能用到的扩展点”——这层就是套娃,删。

我之前带团队重构一个老项目时,就用这个标准逐层审查。结果发现,有 70% 左右的名字带 Manager、Facade、CommonHandler 的层次,删掉之后系统完全不受影响,甚至测试跑了更顺。剩下 30% 是真正负责协议转换、事务控制、外部资源访问的,它们留下来,项目整体反而清爽了不少。

提示:判断要不要保留某层,永远以“当前真实存在的职责”为准,不要以“将来可能出现的职责”为准。未来的职责留给未来去建,现在的职责必须现在起作用。

6. 动手拆套娃:一套可以照做的减法清单

理论说完了,接下来说说具体怎么操作。如果你现在手上正好有一个堆了四五层的项目,别慌,按下面这个顺序慢慢拆。

6.1 第一步:画出调用链,圈出“无转发”的类

打开 IDE,用调用链插件或者手工搜,把核心业务的调用路径画出来。然后把每个类标注成三种角色:有逻辑的、只传话的、只转换的。

“只传话”的类特征很明显:方法体就是一行return xxx.yyy(),参数不做加工,返回值不做处理,事务不在这里开启,异常不在这里处理。这类就是第一个要删的目标。

我一般会先挑一个改动最少的业务线做试点,把中间的传话层删掉,让上层直接调真正干活的类。改完跑一遍全量测试,确认没问题再推广。别想着一天把整个项目拆完,那是重写不是重构,风险太大了。

6.2 第二步:合并“动辄改名”的 DTO / VO / Entity

第二个重点整治区域是数据模型。Entity、DTO、VO 三者之间动不动就来回转换,这种转换代码看着是“规范”,实际上大部分只是字段名复制,没有任何信息增益。

我的建议是:同一业务域内,如果没有明确的权限隔离或跨服务传输需求,尽量复用同一个模型类。你可以在包名上区分(model/request/response),但不要在类层面造三个长相差不多的类。前端的展示字段和服务内部的字段也许有差异,但大多数业务系统里,差异远没有想象中大,用一个小方法做局部映射就够了,没必要搭一套转换器。

如果一定要用 DTO 做隔离(比如有敏感字段不想输出到前端),也只在接口边界做一次转换,不要在中间每一层都转一遍。数据从持久层到接口层之间,能少复制一次就少复制一次。

6.3 第三步:用接口表达意图,而不是表达层次

拆的过程里你会发现,有些接口也不是全没用,它定义的是“这个服务对外承诺的能力”,这对多实现、Mock 测试是有帮助的。但有一种接口纯属层次标志:OrderManager接口里只有queryById一个方法,实现类也只有一个。这种接口的存在意义只是告诉别人“你看我这有个 Manager 层”。

你可以考虑把接口尽量保留在真正需要抽象的边界上,比如外部依赖、多策略实现。内部的纯业务调用,直接用具体类就够了,没必要每个 Service 都配一张接口脸。Java 社区这么做的人很多,换来的是代码跳转变少、阅读变快。如果你担心以后要换实现,真到了那时候再抽接口,也就半天的事。

6.4 第四步:立一条团队约定——新增层次必须过评审

拆完之后,最难的是防止它长回去。我建议在团队里立一条约定:往项目里增加一个新的架构层次(新包、新后缀、新调用层),必须走代码评审,并且要回答“删掉这层会失去什么”。

这条约定看起来严苛,但非常有效。很多人在写代码时只是习惯性地想“这里要不要抽一层”,一旦要过评审,他就会认真想一下这一层到底在解决什么问题。答不上来的,自然就会放弃。答得上的,留下来也不亏。用这种方法,我们团队用了半年时间,把核心工程从二十多个包压缩到十几个包,代码量没减多少,但认知负荷明显下降。新人上手时间从两周缩短到三四天。

6.5 最后的话:架构是动词,不是名词

拆完一套娃之后,我特别想分享的一点是:架构不是一个静态的名词,不是一张很厚的分层图,而是一个动态的动词,是你在每一个需求面前“选择把复杂度放在哪儿”的动作。好的架构师不是在画图,而是在砍结构。

我见过最好的代码库,不是那种层层叠叠、包名一大串的“标准结构”,而是那种改动一个字段只要动两个文件、看一个方法从头到尾不用跳转的朴素系统。它不惊艳,但特别耐操,而且每个接手的人都会感谢前人的克制。

如果你现在正被某个“架构规范”压得喘不过气,我建议你做一次实验:挑一条最常改的业务线,把中间所有只会传话的层全部删掉,然后感受一下改需求的流畅度。我赌你会上瘾。

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

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

立即咨询