我做了不少系统的架构设计和重构,坦白讲,最常被人低估、却也最容易被讨论出火气的,就是那个听起来毫无新意的“三层架构”。要是你只在教科书和项目骨架里见过它,可能会觉得三层架构无非就是界面、业务逻辑、数据访问各自分层,Controller调用Service,Service调用Mapper,完事。可真上了战场,让业务在手里跑上一年半载,你就会发现这个所谓的“三层”,根本不是一个静态的目录结构,而是一套在实战压力下不断演化、不断妥协、也不断清晰化的活系统。
这篇算是我的架构经验小结第四篇,想认真聊一聊三层架构在真实项目中是怎么一步步演化过来的。没有高深理论,全是实际踩坑、拆解、重构图出来的教训。适合那些已经在写业务代码、开始感受到分层带来的困扰,或者正准备接手一套中年系统的开发者。如果你还停留在“三层架构就是Controller-Service-DAO”的刻板印象里,看完这篇,应该会有一些新的判断依据。
- 先搞清楚一件事:我们说的三层架构,到底在分什么
很多人聊三层架构,上来就分Controller、Service、Repository,然后开始争论某某方法该放哪一层。这其实把问题搞偏了。三层架构的本意并不是规定你必须有三个类文件夹,而是要把“变化原因不同”的代码分开,让每一种变化可以独立发生、独立测试、独立演进。
我在早期项目里也犯过同样的错误。拿到需求就照着模板建包,controller包、service包、dao包,然后写用户管理的时候,把校验、组装、调DAO、返回结果全塞进Service。表面上看确实是三层,但问题在于,Service层变成了一个无所不包的大杂烩,所有业务规则都堆在里面。后来系统加了权限、加了消息推送、加了外部接口对接,每次改动都要在Service里翻半天,改动的影响范围越来越大,回归测试的工程量也越来越大。
真正让我重新理解三层架构的契机,是一次订单系统的重构。老的订单模块已经膨胀到两个类加起来六千多行,每次上线都提心吊胆。当时我重新梳理了一遍需求,发现里面其实混杂着好几类完全不同的逻辑:有纯展示相关的字段拼装,有订单状态流转的领域规则,有对数据库表的增删改查,还有对第三方支付接口的适配。这些逻辑的变化频率、变化原因、测试方式都不相同,硬塞在一个Service里,就导致任何一处小改,都得把整条链路重新过一遍。
所以我的第一个核心体会是:三层架构分的是“职责变化维度”,而不是“代码物理位置”。Controller关心的是协议转换和参数适配,Service关心的是业务流程的编排与事务边界,数据访问层关心的是持久化细节。当你能把“这段代码为什么会变化”想清楚,分层自然就清楚了。
1.1 经典三层的职责边界,怎么划才不容易吵架
我见过太多团队在评审的时候吵“这个方法该放Controller还是Service”,本质是职责定义太粗。我把自己的判断标准分享出来,不一定通用,但至少能让讨论聚焦:
Controller层,只做外部世界的翻译官。它接收HTTP请求、解析参数、做基础格式校验、调用业务入口、把结果转换成响应结构。Controller不应该知道订单状态怎么流转,不应该知道库存扣减的规则,更不应该自己去拼SQL。只要发现Controller里出现了if else判断业务规则,或者出现了对数据访问对象的直接调用,那就是分层腐化的信号。
Service层,做业务流程的导演。它负责编排:先查什么、再校验什么、然后调用什么、失败怎么回滚。Service是事务的边界,是业务规则的主要栖息地。但这里要特别小心,业务规则也有不同类型。有些是纯粹的计算和判断,比如折扣怎么算、状态能不能流转;有些是流程性的事务编排,比如下单要同时扣库存、发消息、记录流水。把这两类混在一起,后面一定会痛苦。
数据访问层,做持久化的封装。它只关心怎么把数据存进去、取出来、更新掉。业务判断不应该出现在这里。我在一些项目里见过在Mapper里写复杂业务逻辑的,理由是“这样查出来正好是我要的结构”。短期看确实省事,但一旦业务调整,你要么改SQL,要么改调用方,两边都被牵制,非常被动。
这三条线划清楚之后,再看代码就有一种豁然开朗的感觉。新功能进来,你先判断它是展示协议变化、业务流程变化,还是存储结构变化,然后只改对应层,其他层完全不用动。这个“只改该改的地方”的能力,才是三层架构真正的价值。
- 实战中,三层架构是怎么一步步“变丑”的
教科书里的三层架构很美,实战里的三层架构通常很丑。如果没人干预,它一般会经历这么几个阶段。
第一阶段是蜜月期。项目刚启动,分层清晰,代码整洁,每个类都很瘦。大家写起来也很有成就感,觉得架构不过如此。第二阶段是膨胀期。业务需求越加越多,每个人都在赶进度,没人愿意为了“架构整洁”去拆分已经有几千行的类。新来的同事看到Controller里有人直接调Mapper,问了一句“这样好吗”,得到的回答往往是“先跑通再说,后面再重构”。然后这个“后面”就再也没有到来。
第三阶段是腐化期。系统开始出现各种症状:改一个字段要动三层,加一个列表页要复制粘贴一大段类似的代码,Service之间的相互调用纠缠成一张网。最典型的表现是,你以为自己在分层,实际上层与层之间已经不设防。比如Service直接new了一个其他系统的客户端,Controller里塞了数据组装逻辑,甚至在一个事务方法里调了外部接口导致长事务。
我印象最深的一个案例是,一个报表模块的Service里有段代码,先查主表,再循环查子表,然后循环里又查配置表,最后还要调一个远程接口补数据。整个方法跑了五秒多,数据库连接被长时间占用,远程接口超时还会拖垮整个请求线程。后来我们想办法优化,第一步就是把这段“大泥球”逻辑按职责拆开:数据查询交给数据访问层做批量获取,远程接口调用单独抽出去做异步补偿,业务编排留在Service层只负责流程控制。改完之后接口从五秒降到了八百毫秒,这就是分层的实战红利。
2.1 三种典型的“变丑”路径,你大概率见过
第一种叫Controller胖死。Controller里不仅有参数接收,还有登录用户获取、权限判断、数据拼装、异常处理、日志记录。代码膨胀之后,每个接口都是一个独立的小世界。这种路径的根源,是团队把“不要写业务逻辑到Controller”理解成了“所有逻辑都别放Service以外的地方”,结果业务逻辑没进Service,反而都被顺手堆在Controller里。
第二种叫Service万能化。Service包揽一切,包括参数校验、缓存处理、数据转换、领域规则、甚至SQL拼接。这种路径看似符合分层,实际上Service已经退化成“什么都能干的杂工”。最直接的后果是Service之间互相依赖,你调我我调你,最后形成环。有一次我排查一个问题,发现A服务方法里调了B服务方法,B服务方法里又反向调用了A服务方法,绕了一个大圈才拿到数据。这种代码跑起来没报错,但责任完全混乱,想改一个逻辑,牵一发动全身。
第三种叫数据访问层失守。Repository或Mapper里开始写业务判断,比如根据状态查不同类型的数据,或者在SQL里做复杂的计算。这种做法的隐蔽性强,因为从调用方看,接口很好用,一查就出结果。但问题在于,业务规则沉到了数据层,后续想复用、想调整、想测试,都会被SQL绑架。
这三种路径并不互斥,通常一个老项目里三种问题同时存在。做架构治理的时候,不可能一天改完,要有优先级,先把最影响变化效率的地方收拾干净。
- 一次实战演化实录:从大泥球到清爽三层的重构过程
我觉得光讲道理不够,还是拿一个具体的例子来讲。这个项目是一个后台管理系统中的核心订单模块,接手的时候Service类四千多行,Controller层也有一千多行,改动一次需求,测试要回归三四条主链路。我带着团队做了一轮重构,目标是让这个模块重新变得“可理解、可修改、可测试”。
第一步,先不写代码,画现状图。我们把订单模块里所有的方法调用关系列出来,标注哪些是Controller直接调Service,哪些是Service互相调用,哪些是Service调外部接口,哪些是数据访问层的查询被谁用了。画完之后所有人都愣住了,因为我们发现Controller里居然有一个方法直接查了三张表拼了一个复杂报表,这种逻辑本该在Service层通过组合完成。
第二步,按“变化原因”把代码分组。我们把订单相关的代码分成了几个桶:订单状态与金额计算相关的领域逻辑、订单流程编排相关的事务逻辑、订单查询与展示适配逻辑、订单持久化访问逻辑。分完桶,再看原来的代码,很多混放的位置就一目了然了。
第三步,逐层迁移。我坚持一个原则:重构过程中,不改变任何外部接口的行为,只做内部搬移。先把数据访问相关代码下沉到Repository层,确保Service里不再出现SQL和QueryWrapper拼装。然后把Controller里的业务判断往上提,把展示拼装留在Controller里,但把真正的计算逻辑下沉到Service。最后把Service里互相调用的环打断,通过引入领域服务或领域对象来承载公共逻辑。
第四步,补测试。重构最怕的就是没人敢动。我们给核心的订单状态流转、金额计算逻辑补上了单元测试,给几个主要接口补上了集成测试。这样后面再有人想“偷偷把某个逻辑塞到奇怪的地方”,测试会第一时间报警。
这次重构花了两周时间,没有新增一个功能,但对后续的开发效率提升非常明显。新需求进来,改动范围从“动整条链路”变成了“只动一个类一个方法”,这个体验上的差别,只有经历过的人才会懂。
3.1 重构中的几个关键决策:事务边界和分层边界怎么取舍
重构过程中最容易纠结的事情是,有些逻辑放在哪一层都说得通。比如订单取消时,要不要把库存释放写在订单服务里?要不要把消息通知放在事务内?
我的处理原则是:事务边界优先于分层整洁。如果一段逻辑必须和主流程保持强一致,那它就放在同一个事务边界内,哪怕它“看起来”更属于其他关注点。比如释放库存,如果下单和释放库存必须同时成功或同时失败,那库存释放逻辑就不能简单地“扔给库存服务自己去处理”,要么走本地事务,要么走可靠消息最终一致性。这不是三层架构能解决的问题,但分层会倒逼你想清楚这件事。
另一个关键决策是,Service层要不要拆成多个类。我认为要看“状态”和“行为”的聚集程度。如果一个订单Service里既有订单创建、订单取消、订单查询、订单导出,而且这些操作共享大量订单状态逻辑,那可以先不拆类,但可以把方法内部逻辑拆成多个私有方法。如果不同业务模块之间的共享逻辑越来越多,那就该考虑把公共逻辑抽到独立的领域服务里。
还有一个容易被忽略的点是,DTO和实体到底怎么放。我见过不少项目,Entity直接传给前端,序列化之后暴露了所有字段,既不安全也不稳定。后来我们引入DTO做隔离,Controller只和DTO打交道,Service内部可以自由使用领域对象或实体,数据访问层返回实体,由Service负责转换为DTO。这个约束刚引入的时候稍微增加了代码量,但后期接口稳定性、字段兼容性都好了很多。
- 实战中的几个高频坑位与排查技巧
同样的三层架构,在不同团队手里长出来的样子完全不同。有些坑几乎是必然会踩的,我挑几个高频的分享。
第一个坑是“事务内调用远程接口”。这在微服务化的系统里尤其常见。Service方法标注了Transaction,中间去调远程服务,远程服务慢或者超时,数据库事务一直被撑着,连接池被耗尽,整个应用雪崩。排查的时候可以用数据库连接池监控、慢查询日志、线程堆栈快照,多管齐下定位。解决办法也简单,要么把远程调用移出事务,改成事务提交后的异步事件,要么把不需要强一致的远程操作从主链路剥离开。
第二个坑是“查询接口把整个表都查出来”。数据访问层的方法设计得过于通用,比如一个list方法不传条件就返回全量数据,Service层再在内存里做过滤。小数据量没问题,数据量一上来,内存和GC立刻告警。排查时可以看数据库慢日志、接口耗时时长,同时结合代码走查。解决方向是让数据访问层提供更精确的查询方法,把过滤下推到数据库,而不是把全量数据捞上来过滤。
第三个坑是“层与层之间直接越层调用”。Controller直连Repository,或者Service里直接操作第三方HTTP客户端,导致分层形同虚设。这种问题靠Code Review是最难的,最好用架构校验工具把依赖规则固化成检查项,比如禁止Controller层直接引用数据访问层、禁止Service层直接解析HTTP请求参数。我实测下来,把这些规则接入CI流水线,比在评审会上反复强调一百遍都有效。
第四个坑是“实体类一张表对应一个,业务对象却没人建”。很多团队的数据模型和业务模型是同一个东西,导致业务规则没地方放。比如订单有状态字段,谁来判断状态能不能流转?如果判断逻辑散落在各个Service方法里,就等于没有规则。我建议对复杂业务,在Service层建立自己的领域对象或状态机,不要直接拿表的实体到处传。
说到排查技巧,我个人的习惯是,接到一个线上问题,先不看代码,先看调用链。通过日志或者链路追踪工具,把一次请求经过的所有层标出来,你能很快看到时间都耗在哪里,是哪一层逻辑拖慢了整体。然后回到代码,只盯那一段。这种定位方式比从头到尾读代码高效得多。
- 工具和规范:怎么让三层架构在团队里真正立住
很多架构方案最后失败,不是方案不好,是缺少工具和规范的支撑。三层架构如果只靠人的自觉,一定会在某个加班的深夜被打破。所以要靠工具来兜底。
我目前在实际项目中比较有效的一套组合是这样的。代码结构上用Maven或Gradle的多模块划分,把接口层、应用层、领域层、基础设施层放在不同模块里,模块之间的依赖方向用ArchUnit或者类似工具做单测校验。每次构建的时候会跑一遍依赖规则测试,一旦有人越层引用,构建直接失败。
模块规范上,我倾向于让每一层只依赖它下面的层,不允许跨层调用。比如应用服务层可以依赖领域层和基础设施层,但接口层不能直接依赖基础设施层。这个规则一开始很严格,团队适应之后会发现,代码的可读性上来了,新人接手也更容易找到地方。
在接口设计上,我会有意识地控制Service方法的粒度。一个Service方法应该对应一个完整的业务用例,参数用请求对象而不是一堆散参数,返回值用响应对象而不是裸集合。这样的接口设计有几个好处:第一,调用方清晰知道这个方法是干什么的;第二,后续加参数可以扩展对象而不破坏已有调用;第三,方便做统一日志、统一异常处理。
再来是编码规范。我要求团队在写新代码之前先想清楚这个逻辑属于哪一层,如果在Controller里想写业务判断,先问自己这判断是展示相关的还是业务相关的。如果是展示相关的,留在Controller没问题;如果是业务相关,那就下沉。这个看似简单的自问,能挡住大部分分层腐化的苗头。
- 那些教科书里没写,但我踩过坑之后才懂的经验
最后分享几个可能不那么“正统”,但实战价值很高的个人体会。
第一,三层架构不是银弹,它解决的是“组织代码”的问题,不解决“设计业务模型”的问题。如果你的业务本身就很复杂,状态多、规则多、交互多,单纯靠Controller-Service-DAO的经典三层顶不住,需要在Service内部再细分领域层。别怕分层多了,分层多不是罪,分层混乱才是。
第二,架构是演化的,不是设计出来的。最开始可以简单一点,但一定要留出“拆分”的余地。比如方法参数用对象而不是散列表,类职责尽量单一,每个方法尽量只做一件事。当业务复杂度上来的时候,你会发现这些日常的小习惯,比什么高深架构都管用。
第三,做重构的时候,不要把代码和业务一起改。先保证业务行为不变,把代码结构调好,再考虑改业务。一旦同时改,出了问题你根本没法判断是行为变了还是结构出了问题。我们那次订单模块重构,之所以两周能顺利落地,就是死死守住了“行为不变”这条线。
第四,团队的技术氛围决定了架构能撑多久。如果每个人都觉得“先上线再说”“重构以后再做”,那再好的架构设计也会慢慢腐化。反之,如果有人愿意在每一次Code Review的时候多问一句“这个方法为什么放在这里”,三层架构就能一直保持健康。架构不是画在文档里的,是长在每一次代码评审里的。
我个人最近带的项目里,已经不怎么提“三层架构”这个词了,因为大家已经形成了一种肌肉记忆:新需求来了,自动判断变化类型,自动选择放置位置,自动决定要不要抽新类。但这种肌肉记忆,就是从一次一次踩坑、重构、复盘里练出来的。如果你也在维护一个日渐膨胀的系统,不妨找一个边界清晰的模块,先试着把Controller和Service里的逻辑重新分一分,把数据访问彻底关进Repository,再跑一遍测试。等这个模块清爽了,你自然就能体会到,三层架构真正的力量不在结构本身,而在它逼着你想清楚“每一段代码为什么活在这里”。
