返回现场笔记
15 分钟阅读

分层架构上的探索实践

把整洁架构 / 六边形架构的分层思路落进真实 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)**的一次落地实践。底层稳定、高层多变,让变化停留在高层,不让它污染到核心的业务逻辑。

六边形分层与依赖方向
presentation用户展示层infrastructure基础设施层application应用服务层domain领域层×
允许 外层 → 内层阻断 内层 ↛ 外层

三、项目分包是怎么一步步演进出来的

理论说完了,真正难的是怎么把它落到自己项目的代码里。我把这个过程拆成几步演进,你可以照着这个思路走。

第一步:先按四层切出骨架

先按照四层架构,把系统切成几个一级 package:

.
├── view            // 视图
├── usecase         // 用例
├── domain          // 领域层
└── infrastructure  // 基础设施

这是教科书式的结构。但一放到真实项目里,就会撞上几个问题:

  1. 系统里会有一些配置信息,需要单独存放;
  2. 我们现在是前后端分离的,前端页面作为展示层并不在后端项目里,所以 view 层要拿掉;
  3. usecase 往往和具体业务强绑,我把它和 domain 对象聚合到一个 package 下更顺手,所以 usecase 也移除掉。

调整之后,骨架变成这样:

.
├── config              // 配置类
├── domain              // 领域层
└── infrastructure      // 基础设施

第二步:把基础设施拆成两个维度

再看 infrastructure,它在现实项目里其实是两个维度混在一起的:

  • 一个是对外暴露的服务接口(可以叫 gateway,表示对外的接入层);
  • 一个是对外部中间件、数据库的依赖框架(这才是我们习惯意义上的"基础设施")。

把这两个维度分开,结构就清爽了:

.
├── config                  // 配置类
├── domain                  // 领域层
├── gateway                 // 对外接入层
└── infrastructure          // 基础设施

第三步:单独拎出一个"通用工具包"

项目里还会有一类非常基础、横跨整个项目的通用代码——有点像这个项目自己的 java.lang 包。我把它叫 tool

.
├── config                  // 配置类
├── domain                  // 领域层
├── gateway                 // 对外接入层
├── infrastructure          // 基础设施
└── tool                    // 基础工具包

到这里,第一级的宏观分层就完成了。但要特别提醒:tool 这个包一定要控制规模,别让它变成"什么 Utils 都往里塞"的垃圾场。

分包结构的四步演进
01

按四层切骨架

4 pkgs

教科书结构先落地

.
├── view
├── usecase
├── domain
└── infrastructure
02

适配真实项目

3 pkgs

去掉 view / usecase,加 config

.
├── config
├── domain
├── infrastructure
├── view
└── usecase
03

拆开基础设施

4 pkgs

gateway 与 infrastructure 分离

.
├── config
├── domain
├── gateway
└── infrastructure
04

补齐工具包

5 pkgs

加上项目级 tool 包

.
├── config
├── domain
├── gateway
├── infrastructure
└── tool
终点5 个一级 package · 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 屏蔽掉外部对接的实现细节;
  • 业务只关心"做什么",不关心"怎么做"
  • 命名上一定要用有业务含义的名字,不要出现 RedisClientMySQLClient 这种技术味儿十足的名字,而应该出现 IMessageClientIUserClient 这种按业务命名的接口。

model——领域对象的呈现

  • 每个业务尽量建自己的 model,复用对象要谨慎,避免出现一个什么都装的大类;
  • 当你发现用 get 找属性都要停顿思考时,就说明这个类已经太大了;
  • model 可以有自己的行为方法(method),不只是个数据袋子;
  • 业务对象其实是有分类的:
    • entity(实体):有完整生命周期、有业务含义的对象,比如 ReceiverLabelDocument
    • value object(值对象):只是承载一些数据,离开 entity 没意义,比如 LabelPosition
    • aggregate(聚合):多个 entity 协作时内聚成一个聚合,是核心的业务操作对象,比如 AutoSignContract

repo——仓储层,用 Java interface 表示

  • 只给聚合 model 提供 repo,不必给每个小对象都配一个;
  • 数据的组装放在实现里完成,接口只描述"要什么"。

service——领域服务

  • 它是业务对象(model)的延伸;
  • 一个领域服务只处理它对应 model 相关的业务,比如 ReceiverDomainService 只处理 Receiver 相关方法,不要在它里面写以处理 Document 为主的方法;
  • 每个方法只完成一个动作(粒度要小),靠 application 来编排这些方法。
一个 business 的内部结构:业务核心居中,两个接口层朝外
业务核心 · 纯 JVM对外接口 · 可 mock
acl/防腐层interface
面向外部系统,实现留在包外
application/应用服务

讲清业务流程、编排 service,不塞数据组装

编排
service/领域服务

model 的延伸,一个方法只完成一个动作

作用于
model/领域对象

entity / value object / aggregate,可带行为方法

这一列不碰 Spring / Dubbo —— 业务逻辑因此能在纯 JVM 里独立验证。

repo/仓储层interface
面向数据库,实现留在包外

第五步:两个基础设施层去适配业务分层

骨架和业务分层都成型之后,gatewayinfrastructure 这两个基础设施层,要去适配我们的业务分层。

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-package,老结构逐步退缩直至可整体删除。
外部调用Facade · 拦截层按业务把请求路由到 new extant
extant-package

合同业务

✓ 已接管
extant-package

签署业务

✓ 已接管
extant-package

回调业务

✓ 已接管
迁移进度
0 / 3

新结构在老系统旁并行生长,逐个业务接管对外协议;出问题时把整个 new-package 删掉也无副作用——「小步快跑、可回退」。

2. 新旧需求怎么用这套架构

新需求通常包含比较完整的业务逻辑,可以直接在分层架构下从零构建,然后把协议暴露给外部或老代码使用。

老需求也不必硬翻,可以走演进式适配:把老需求这次要改的那部分,用分层架构重写,再对外暴露一个 interface,让老代码去调用。

但不管新旧,有两件事是底线:

  • 每个 business package 要能独立自治,形成高度内聚的逻辑——这能很大程度上缓解"未知的未知"(Unknown unknowns);
  • 每个 business package 的规模要足够小——只有足够小,后来维护的人认知负担才低,这个模块才能做到快速交付。

3. 分层架构是怎么帮到单元测试的

单元测试一直是个被反复强调、却一直推不动的质量手段。在我们这之前很长一段时间,单测的执行效果是不理想的

为什么?因为对大部分开发同事来说,单测最痛的不是"写",而是""。一段充满依赖、满是坏味道的代码,根本不好跑起来,跑起来也特别慢。而单测的本意,只是想测那一小块业务逻辑——不应该为了测业务,把一堆无关的代码都启动起来

分层架构恰好解决了这个问题:它把基础设施和业务代码分开了

回头看一个 business 的结构:

.
├── application         // 应用服务
│    └── param          // 与用例相关的入参和出参
├── acl                 // 防腐层,只有 interface
├── model               // 领域对象
├── repo                // 仓储层,只有 interface
└── service             // 领域服务

我们只测 business 这部分代码,从 application 入口测起。因为 aclrepo 都是 interface,我们很容易就能 mock 它们——给它们预设好输入和返回值,然后验证我们在 application 里的编排和业务逻辑是不是对的。

关键是:这套测试完全不需要启动任何运行时框架(比如 Spring、Dubbo),纯粹就是在验证业务逻辑,能极快地告诉我们"我写的业务对不对"。

单元测试执行流程:测试入口进入应用服务,经过领域服务与领域对象; 防腐层、仓储层被替换为 mock,整套测试在纯 JVM 中运行。
test scope · 纯 JVM
@Test测试入口
application编排
service业务动作
model聚合根
outside test scope · 替换为 mock
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 下的目录膨胀到一定规模还理不顺时,就该认真思考——这个系统是不是该拆分了?

扁平 package 列表与按业务分组的目录树对比:同样的业务,组织方式决定认知负担。
flat · 平铺膨胀
  • business1
  • business2
  • business3
  • business4
  • business5
  • business6
  • business7
  • business8
  • business9
  • business10
  • business11
  • business12
  • … business100

平铺上百个,改一处先找半天

tree · 按业务分组
  • corebiz/
  • ├─ contract/
  • │ ├─ template
  • │ ├─ draft
  • │ └─ archive
  • ├─ sign/
  • │ ├─ e-sign
  • │ └─ batch
  • └─ callback
  • support/
  • ├─ notify
  • ├─ audit
  • └─ storage

结构即文档,一眼知道去哪改


五、一些实践中的心得

最后分享几条我踩完坑总结出来的心得,给准备动手的同学提个醒。

  • 不要一上来就全盘改造。 先挑一个边界清晰、体量适中的业务试点,跑顺了再扩散。绞杀者模式的核心就是"小步快跑、可回退"。
  • interface 是分层的灵魂。 aclrepo 都用 interface 表达,这是业务代码能脱离运行时框架、能独立测试的根本。没有 interface 的"分层"只是物理上的分目录,不是真正的解耦。
  • 命名要见业务。 RedisClient 这种名字一出现,分层的好处就打折了——它把技术细节漏给了业务层。要用 IMessageClientIUserClient 这种有业务含义的名字。
  • 领域服务要小、要专一。 一个方法只做一件事,靠 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《领域驱动设计精粹》(六边形架构)