分层架构上的探索实践
把整洁架构 / 六边形架构的分层思路落进真实 ToB 项目:分包怎么演进、业务怎么自治、单测怎么跑起来,以及踩过的坑。
- #architecture
- #ddd
- #tob
为什么写这篇文章
身处在做 ToB 的 SaaS 行业,我们常年面对一个困扰——需求太复杂了。
复杂的需求一旦落到代码上,就会慢慢把系统搅成一锅粥:改一个功能要在十几个文件之间来回跳,新人进来半个月也理不清脉络,没人敢动的"祖传代码"越堆越多。这种混乱是 ToB 行业绕不开的难题,所以我尝试在一些项目里,用 整洁架构 / 六边形架构 的分层思路,对部分代码做了一些改善。
这篇文章不讲空洞的理论,就把我怎么思考、怎么落地、怎么避开坑的实践过程,完整地分享出来。
一、为什么要分层
我们都熟悉 ToB 行业的一个特点:客户都是大型企业,每家的流程和需求都不一样,规模一旦上来,复杂度就爆炸。
打个比方。你在家里用两个路由器组个网,这是挺简单的事;可一旦你要给一个机房、几百台交换机组网,事情立刻就复杂到要请专门的人来。规模上去了,复杂度就跟着陡增。
再打个比方。把你丢进一个只有四栋楼的小区,不给你任何工具,你绕几圈就能走出来;可要是把你丢进一个陌生的城市,让你去一个指定地点,你大概率会迷路——路线太多了,没有地图,光靠瞎转根本摸不清。
回到我们系统面临的问题上,本质是一样的:随着系统承载的需求越来越多,需求之间交叉的影响会越来越多,对系统的全面理解就越来越困难。
在《A Philosophy of Software Design》(软件设计哲学)这本书里,作者把软件复杂的表现总结成三种症状:
- 变更放大(Change amplification):看似简单的改动,却要在很多不同的地方改代码。
- 认知负荷(Cognitive load):开发人员要掌握很多知识,才能完成一项任务。
- 未知的未知(Unknown unknowns):开发人员不知道自己不知道什么——这才是最可怕的,改完一处才发现别的地方被波及。
Change amplification
变更放大
一处改动,涟漪般扩散到十几处文件
Cognitive load
认知负荷
掌握的知识越来越多,才能完成一件事
Unknown unknowns
未知的未知
不知道自己不知道——改完才发现别处被波及
分层架构很大程度上就是在试图缓解这三种症状。通过把系统一层层拆开、形成一个个自治的稳定空间,来降低理解成本、缩小一次变更的波及范围。 用一句话说就是:让复杂度可控。
二、站在巨人的肩膀上
讲到分层架构,除了经典的三层架构之外,最出名的就是 Uncle Bob 在《架构整洁之道》里提出的整洁架构。它的核心结构是一个同心圆——最里面是业务对象,一圈圈往外是各种适配层和接口。
除此之外,DDD 里的六边形架构也给出了一个思路非常接近的分层,它把代码分成了四层:
- 用户展示层
- 应用服务层
- 领域层
- 基础设施层
其实这两种模式的核心想法是一样的,用一句话就能讲透:
以"要承载的目标业务对象"为最底层,高层代码允许依赖底层,但底层代码不要依赖高层。
这其实就是对**依赖倒置原则(DIP)**的一次落地实践。底层稳定、高层多变,让变化停留在高层,不让它污染到核心的业务逻辑。
三、项目分包是怎么一步步演进出来的
理论说完了,真正难的是怎么把它落到自己项目的代码里。我把这个过程拆成几步演进,你可以照着这个思路走。
第一步:先按四层切出骨架
先按照四层架构,把系统切成几个一级 package:
.
├── view // 视图
├── usecase // 用例
├── domain // 领域层
└── infrastructure // 基础设施
这是教科书式的结构。但一放到真实项目里,就会撞上几个问题:
- 系统里会有一些配置信息,需要单独存放;
- 我们现在是前后端分离的,前端页面作为展示层并不在后端项目里,所以
view层要拿掉; usecase往往和具体业务强绑,我把它和domain对象聚合到一个 package 下更顺手,所以usecase也移除掉。
调整之后,骨架变成这样:
.
├── config // 配置类
├── domain // 领域层
└── infrastructure // 基础设施
第二步:把基础设施拆成两个维度
再看 infrastructure,它在现实项目里其实是两个维度混在一起的:
- 一个是对外暴露的服务接口(可以叫
gateway,表示对外的接入层); - 一个是对外部中间件、数据库的依赖框架(这才是我们习惯意义上的"基础设施")。
把这两个维度分开,结构就清爽了:
.
├── config // 配置类
├── domain // 领域层
├── gateway // 对外接入层
└── infrastructure // 基础设施
第三步:单独拎出一个"通用工具包"
项目里还会有一类非常基础、横跨整个项目的通用代码——有点像这个项目自己的 java.lang 包。我把它叫 tool:
.
├── config // 配置类
├── domain // 领域层
├── gateway // 对外接入层
├── infrastructure // 基础设施
└── tool // 基础工具包
到这里,第一级的宏观分层就完成了。但要特别提醒:tool 这个包一定要控制规模,别让它变成"什么 Utils 都往里塞"的垃圾场。
.├── view├── usecase├── domain└── infrastructure
.├── config├── domain├── infrastructure├── view└── usecase
.├── config├── domain├── gateway└── infrastructure
.├── config├── domain├── gateway├── infrastructure└── tool
第四步:最难的一步——划分 domain 里的业务
一级分层只是骨架,一个业务系统真正的核心还是业务。怎么在 domain 下面划分子 package,才是最考验功夫的地方。
为什么难?因为前面那些层次(应用层、基础设施层……)从技术层面还能总结出一些通用说法,但业务是千变万化的,没有通用模板可以抄。所以业务划分也是最难的环节。
我把业务分成两类:
- 核心业务:系统的核心卖点,有清晰业务目标的业务;
- 支撑业务:支撑核心业务跑起来的那些模块,比如发短信、发开发者回调、文件存储等。
于是 domain 下面可以这样分:
.
├── corebiz // 核心业务
└── support // 支撑业务
对于任何一个核心业务或支撑业务,最好的状态是:它能独立自治,形成一个迷你系统。参考《架构整洁之道》的模型,一个业务 package 可以再拆成这样几个层次:
.
├── application // 应用服务
│ └── param // 与用例相关的入参和出参
├── acl // 防腐层
├── model // 领域对象
├── repo // 仓储层
└── service // 领域服务
这里把每个 package 的职责说清楚,这是整个实践里最关键的部分:
application——业务的主流程
- 它就是前面说的
usecase,表达"业务的动作"; - 只负责讲清楚业务流程、表达业务含义,不要出现复杂的数据组装、也不要出现复杂的判断逻辑;
- 具体的判断逻辑放在
service(领域服务)或model(领域对象)里; - 数据组装逻辑尽量放到基础设施层去做。
acl——防腐层,用 Java interface 表示
- 当业务需要调用外部系统时,用 ACL 屏蔽掉外部对接的实现细节;
- 业务只关心"做什么",不关心"怎么做";
- 命名上一定要用有业务含义的名字,不要出现
RedisClient、MySQLClient这种技术味儿十足的名字,而应该出现IMessageClient、IUserClient这种按业务命名的接口。
model——领域对象的呈现
- 每个业务尽量建自己的 model,复用对象要谨慎,避免出现一个什么都装的大类;
- 当你发现用
get找属性都要停顿思考时,就说明这个类已经太大了; - model 可以有自己的行为方法(method),不只是个数据袋子;
- 业务对象其实是有分类的:
- entity(实体):有完整生命周期、有业务含义的对象,比如
Receiver、Label、Document; - value object(值对象):只是承载一些数据,离开 entity 没意义,比如
Label的Position; - aggregate(聚合):多个 entity 协作时内聚成一个聚合,是核心的业务操作对象,比如
AutoSignContract。
- entity(实体):有完整生命周期、有业务含义的对象,比如
repo——仓储层,用 Java interface 表示
- 只给聚合 model 提供 repo,不必给每个小对象都配一个;
- 数据的组装放在实现里完成,接口只描述"要什么"。
service——领域服务
- 它是业务对象(model)的延伸;
- 一个领域服务只处理它对应 model 相关的业务,比如
ReceiverDomainService只处理Receiver相关方法,不要在它里面写以处理Document为主的方法; - 每个方法只完成一个动作(粒度要小),靠
application来编排这些方法。
acl/防腐层interfaceapplication/应用服务讲清业务流程、编排 service,不塞数据组装
service/领域服务model 的延伸,一个方法只完成一个动作
model/领域对象entity / value object / aggregate,可带行为方法
这一列不碰 Spring / Dubbo —— 业务逻辑因此能在纯 JVM 里独立验证。
repo/仓储层interface第五步:两个基础设施层去适配业务分层
骨架和业务分层都成型之后,gateway 和 infrastructure 这两个基础设施层,要去适配我们的业务分层。
gateway 作为对外暴露的协议接入层,按协议区分集中处理:
.
└── gateway // 协议接入层
├── dubbo // Dubbo 协议层
├── http // HTTP 协议接入层
├── mq // MQ 协议接入层
└── schedule // 定时任务接入层
infrastructure 既要放中间件本身的代码,又要放"中间件和业务代码交互"的适配代码,所以拆成两部分:
.
└── infrastructure // 基础设施层
├── impl // 业务代码和基础设施的适配层
│ ├── corebiz
│ │ ├── business1 // 和 domain 层的 package 分类要适配
│ │ │ ├── acl // 防腐层实现
│ │ │ └── repo // 仓储层实现
│ │ └── business2
│ └── support
│ ├── business3 // 和 domain 层的 package 分类要适配
│ │ ├── acl // 防腐层实现
│ │ └── repo // 仓储层实现
│ └── business4
├── mysql // MySQL 客户端实现,放 DAO、映射代码,方便 impl 统一调用
├── redis // Redis 客户端实现
├── kafka // Kafka 客户端实现
└── thirdparty // 和第三方系统交互的适配代码
最后是 tool,前面说过它是项目级的通用代码包。对我们项目来说比较通用的有这么几类:
.
└── tool // 工具代码
├── livingdocument // 业务文档注解聚集地
├── symbol // 一些标记代码
└── utils // 常用 Utils,比如 StringUtils、PDFUtils 等
最终的完整结构
把上面几步合起来,就形成了下面这套完整的分层架构:
.
├── config // 配置类
├── domain // 领域层
│ ├── corebiz // 核心业务
│ │ ├── business1
│ │ │ ├── application // 应用服务,usecase
│ │ │ │ └── param // 与用例相关的入参和出参
│ │ │ ├── acl // 防腐层
│ │ │ ├── model // 领域对象
│ │ │ ├── repo // 仓储层
│ │ │ └── service // 领域服务
│ │ └── business2
│ └── support // 支撑业务
│ ├── business3
│ │ ├── application // 应用服务,usecase
│ │ │ └── param // 与用例相关的入参和出参
│ │ ├── acl // 防腐层
│ │ ├── model // 领域对象
│ │ ├── repo // 仓储层
│ │ └── service // 领域服务
│ └── business4
├── gateway // 协议接入层
│ ├── dubbo // Dubbo 协议层
│ ├── http // HTTP 协议接入层
│ ├── mq // MQ 协议接入层
│ └── schedule // 定时任务接入层
├── infrastructure // 基础设施
│ ├── impl // 业务代码和基础设施的适配层
│ │ ├── corebiz
│ │ │ ├── business1 // 和 domain 层的 package 分类要适配
│ │ │ │ ├── acl // 防腐层实现
│ │ │ │ └── repo // 仓储层实现
│ │ │ └── business2
│ │ └── support
│ │ ├── business3 // 和 domain 层的 package 分类要适配
│ │ │ ├── acl // 防腐层实现
│ │ │ └── repo // 仓储层实现
│ │ └── business4
│ ├── mysql // MySQL 客户端实现,存放数据库映射代码、XXXDAO 等
│ ├── redis // Redis 客户端实现
│ ├── kafka // Kafka 客户端实现
│ └── thirdparty // 和第三方系统交互的适配代码
└── tool // 基础工具包
├── livingdocument // 业务文档注解聚集地
├── symbol // 一些标记代码
└── utils // 常用 Utils,比如 StringUtils、PDFUtils 等
光看代码目录还是有点抽象,上面第二节的动画图可以更直观地说明这套层次结构。
四、这套架构在实践中的几件事
架构画得再漂亮,落地时还会遇到一堆具体问题。下面是我实践里最值得说的几件事。
1. 怎么把它加进老项目,又不搞出大动静
探索出一套分层架构之后,第一个要面对的问题是:怎么把它用到现有系统里,又不会对系统造成大影响?
敏捷开发里有个重要原则叫精益交付——一次性不要尝试大型交付。不要幻想"推倒重来",也不要一次交付一个要几周才能完成的大改动。要想办法尽快让它跑起来、尽快得到反馈。
我的做法是:在不动老代码的前提下,在项目里新开一个 package,让结构变成这样:
.
├── extant-package // 现有的代码 package
└── new-package // 分层架构的 package
挑一个具体的业务试点,在 new-package 下立刻就能开始实践。就算出问题,在发布分支上把整个 package 删掉也毫无副作用。
背景知识:这种"用新结构逐步包围、替代老结构"的方式,Martin Fowler 称之为 绞杀者模式(Strangler Fig Pattern),名字来自那种会慢慢缠住宿主树的绞杀榕。感兴趣可以 Google 了解一下。
Facade · 拦截层按业务把请求路由到 new 或 extantextant-package合同业务
extant-package签署业务
extant-package回调业务
新结构在老系统旁并行生长,逐个业务接管对外协议;出问题时把整个 new-package 删掉也无副作用——「小步快跑、可回退」。
2. 新旧需求怎么用这套架构
新需求通常包含比较完整的业务逻辑,可以直接在分层架构下从零构建,然后把协议暴露给外部或老代码使用。
老需求也不必硬翻,可以走演进式适配:把老需求这次要改的那部分,用分层架构重写,再对外暴露一个 interface,让老代码去调用。
但不管新旧,有两件事是底线:
- 每个 business package 要能独立自治,形成高度内聚的逻辑——这能很大程度上缓解"未知的未知"(Unknown unknowns);
- 每个 business package 的规模要足够小——只有足够小,后来维护的人认知负担才低,这个模块才能做到快速交付。
3. 分层架构是怎么帮到单元测试的
单元测试一直是个被反复强调、却一直推不动的质量手段。在我们这之前很长一段时间,单测的执行效果是不理想的。
为什么?因为对大部分开发同事来说,单测最痛的不是"写",而是"跑"。一段充满依赖、满是坏味道的代码,根本不好跑起来,跑起来也特别慢。而单测的本意,只是想测那一小块业务逻辑——不应该为了测业务,把一堆无关的代码都启动起来。
分层架构恰好解决了这个问题:它把基础设施和业务代码分开了。
回头看一个 business 的结构:
.
├── application // 应用服务
│ └── param // 与用例相关的入参和出参
├── acl // 防腐层,只有 interface
├── model // 领域对象
├── repo // 仓储层,只有 interface
└── service // 领域服务
我们只测 business 这部分代码,从 application 入口测起。因为 acl 和 repo 都是 interface,我们很容易就能 mock 它们——给它们预设好输入和返回值,然后验证我们在 application 里的编排和业务逻辑是不是对的。
关键是:这套测试完全不需要启动任何运行时框架(比如 Spring、Dubbo),纯粹就是在验证业务逻辑,能极快地告诉我们"我写的业务对不对"。
@Test测试入口application编排service业务动作model聚合根aclmock防腐层
repomock仓储层
一个重要提醒:在使用 Spring 这类 IoC 框架的项目里,注入依赖时要用构造器注入或 setter 注入,这样单元测试才能方便地把依赖替换成 mock。直接用字段注解注入会让测试寸步难行。
4. 怎么管理日益新增的需求——别让 package 又乱回去
架构跑顺之后,会冒出一个新问题:需求一直加,package 又会陷入另一种混乱。给大家感受一下:
.
├── corebiz
│ ├── business1
│ ├── business2
│ ├── business3
│ ├── business4
│ ├── business5
│ ├── business6
│ ├── business7
│ ├── business8
... ...
│ └── business100
└── support
├── business1
├── business2
... ...
└── business50
平铺的 package 一多,又会回到"规模带来的认知负担"那个老问题。
解法是:给 package 建一份像脑图一样的"需求目录"。想象你正在用脑图组织自己负责的产品,现在的需求目录该怎么整理?经过精心整理,它能长成这样:
.
├── corebiz
│ ├── business1
│ │ ├── business1.1
│ │ ├── business1.2
│ │ └── business1.3
│ ├── business2
│ │ ├── business2.1
│ │ ├── business2.2
│ │ └── business2.3
│ ├── business3
│ │ ├── business3.1
│ │ │ ├── business3.1.1
│ │ │ ├── business3.1.2
│ │ └── business3.2
│ └── business4
└── support
├── business1
│ ├── business1.1
│ ├── business1.2
│ └── business1.3
├── business2
│ ├── business2.1
│ ├── business2.2
│ └── business2.3
└── business3
在需求演进的过程里,可以给自己定一条规则:某个目录下的模块数一旦超过 x,就拆分。
最终目的有两个,缺一不可:
- 让这个结构本身成为文档,让 package 的名称具有业务含义;
- 让这个结构认知负担低,方便其他人快速找到要改的地方。
反过来也带来一个启示:当某个 package 下的目录膨胀到一定规模还理不顺时,就该认真思考——这个系统是不是该拆分了?
- business1
- business2
- business3
- business4
- business5
- business6
- business7
- business8
- business9
- business10
- business11
- business12
- … business100
平铺上百个,改一处先找半天
- corebiz/
- ├─ contract/
- │ ├─ template
- │ ├─ draft
- │ └─ archive
- ├─ sign/
- │ ├─ e-sign
- │ └─ batch
- └─ callback
- support/
- ├─ notify
- ├─ audit
- └─ storage
结构即文档,一眼知道去哪改
五、一些实践中的心得
最后分享几条我踩完坑总结出来的心得,给准备动手的同学提个醒。
- 不要一上来就全盘改造。 先挑一个边界清晰、体量适中的业务试点,跑顺了再扩散。绞杀者模式的核心就是"小步快跑、可回退"。
- interface 是分层的灵魂。
acl、repo都用 interface 表达,这是业务代码能脱离运行时框架、能独立测试的根本。没有 interface 的"分层"只是物理上的分目录,不是真正的解耦。 - 命名要见业务。
RedisClient这种名字一出现,分层的好处就打折了——它把技术细节漏给了业务层。要用IMessageClient、IUserClient这种有业务含义的名字。 - 领域服务要小、要专一。 一个方法只做一件事,靠
application来编排。别让领域服务变成新的"大杂烩"。 tool包要严控规模。 它最容易沦陷成"什么 Utils 都往里塞"。一旦发现它开始膨胀,立刻治理。- 当结构理不顺时,那不是结构的问题,是系统该拆了。 分层架构解决的是"一个系统内部的复杂度",当复杂度大到分层都扛不住,下一步就是系统拆分。
六、总结与思考
以上就是我对于分层架构的探索实践分享。
回头看,这套实践给我带来的最大改变,并不是"代码更好看了",而是改变了团队理解系统的方式:目录本身就是文档,新人 clone 下来看一眼树结构,就能大致摸清这个系统在做什么;改一个功能时,影响范围被约束在一个 business package 内,心里有底;单元测试真的能跑起来、跑得快,质量手段不再是摆设。
这些收益,恰好对应着《架构整洁之道》里那三种复杂症状——变更放大、认知负荷、未知的未知——都被实实在在地缓解了。
书里还有一句话,我特别喜欢,也作为这篇文章的收尾:
软件架构的最终目标是:用最小的人力成本来满足构建和维护系统的需求。
分层架构不是银弹,它有学习成本,也有过度设计的风险。但只要用在合适的规模和场景里,它确确实实能帮我们把"理解和维护系统"的成本压下来——而这,就已经达到了我们使用它的目的。
对我自己而言,这次实践还带来一个更深层的思考:复杂度从来不会凭空消失,它只会被转移。分层架构做的,是把"散落在代码各处的复杂度",转移成"层次清晰的复杂度"。看起来复杂度还在,但它的认知负担已经低了一个量级——而这,恰恰就是好的软件设计要做的事。
本文主要参考:
- Robert C. Martin《架构整洁之道》(Clean Architecture)
- John Ousterhout《A Philosophy of Software Design》(软件设计哲学)
- Martin Fowler《绞杀者模式(Strangler Fig Application)》
- Vaughn Vernon《领域驱动设计精粹》(六边形架构)