传统分层架构:程序世界里的三明治结构
很多 Java 项目,尤其是基于 Spring Boot 的 Web 项目,从出生的第一天起,就采用了一种非常经典、非常稳固的三层结构。想象一下你手里的一个美味三明治,层次分明,一口下去,口感丰富。
最上面那一层面包,我们叫它 Presentation 层,也就是展现层。它就是程序的门面,负责接待所有来自外界的请求。各种 @RestController、MVC 的 Controller 都住在这里。它们的工作就是接收 HTTP 请求,然后喊一声:“有人找!谁的业务能力最强,出来接客!”
中间那层最实在的馅料,是 Application 层,也叫应用层。这里存放着整个项目的核心——业务逻辑。各种 UserService、OrderService 在这里辛勤工作,处理各种具体的用例,比如“嘿,帮我创建一个新用户”,“好的,帮我把这个订单的状态更新一下”。它是整个程序的大脑。
最下面那层面包,则是 Infrastructure 层,也就是基础设施层。它负责处理所有跟技术细节相关的脏活累活。比如跟数据库打交道(UserRepositoryImpl)、给消息队列发消息、读写文件等等。它就像后勤部门,给业务逻辑提供各种基础支持。
代码结构大概会长成这个样子,大家感受一下,是不是特别眼熟?
text
presentation ──► application.api ◄── infrastructure.api
│
domain
如果用实际的 Java 包结构来表示,大概就是下面这棵目录树。很多用了四五年的老项目,打开一看,基本都长这样。
text
src/main/java/com/yourcompany/awesomeproject/
├── domain/ # 纯业务对象,比如 User (是一个 Record 或者普通类),还有 UserNotFoundException
├── application/
│ ├── api/ # 存放一些用例接口,比如 CommandUseCase, QueryUseCase, 和各种命令对象(Commands)、视图对象(Views)
│ └── impl/ # 实现类,比如 UserCommandService, UserQueryService, 以及应用配置 ApplicationConfig
├── infrastructure/
│ ├── api/ # 基础设施的接口,比如 ReadRepository, WriteRepository, IdGenerator
│ └── impl/
│ ├── jdbc/ # 基于 JDBC 的实现,比如 JdbcReadRepository, JdbcWriteRepository
│ ├── jooq/ # 基于 jOOQ 的实现,比如 JooqReadRepository, JooqWriteRepository
│ ├── jpa/ # 基于 JPA 的实现,比如 JpaReadRepository, JpaWriteRepository,
│ │ # 还有 JPA 实体 UserJpaEntity 和转换用的 UserJpaMapper
│ └── id/ # ID 生成器实现,比如 UuidV7IdGenerator
└── presentation/
├── common/dto/ # 数据传输对象,DTOs 和各种 Mapper
├── rest/ # REST 风格的控制器,比如 RestQueryController, RestCommandController,
│ # 以及全局异常处理器 GlobalRestExceptionHandler
└── mvc/ # 传统的 MVC 控制器,比如 MvcQueryController, MvcFormController,
# # MvcCommandController, 和对应的异常处理器 GlobalMvcExceptionHandler
你看,非常传统,非常经典,简直就像 Spring Initializr 一键生成的模版项目。很多开发者,包括以前的我,第一次看到 Hexagonal Architecture 的六边形图示时,都会产生一个错觉:哇,这是个什么神仙架构?看起来跟我的三层架构完全是两种不同的物种,一个是外星科技,一个是地球拖拉机。
其实真相往往比想象中平淡。这两种架构,更像是有着同一个曾祖父的表兄弟,血缘关系近得很。如果你的分层架构写得比较随意,层与层之间互相乱依赖,比如 Controller 直接调用了 Repository 的代码,那确实问题很大,需要改造。
但是,如果你的分层架构写得非常规矩,严格遵守了依赖倒置原则,那么恭喜你,它其实已经具备了 Hexagonal Architecture 的很多关键机制,只是你自己没发现而已。
代码:https://github.com/architectural-styles/architecture-layered-sample
一个关键细节:Service 和 Repository 的可见性控制
很多团队在写代码的时候,会忽略一个看似很小,但实则非常关键的细节——类的可见性。大家一般都图省事,直接 public class XXXServiceImpl 一写了之,反正都能访问到。
但在一个结构严谨的分层架构中,有两个非常重要的潜规则,或者说,是一种编码的洁癖。Service 的实现类,我们用 package-private(也就是默认的,不加任何访问修饰符)。同样,Repository 的具体实现类,我们也用 package-private。这个小小的改变,意味着什么?意味着这些干活的具体实现类,只对自己所在的包内部可见。外面的世界,比如 Presentation 层,你想直接 new 一个 UserServiceImpl?没门儿,你连这个类都看不见!
外部代码能看到什么呢?它只能看到我们精心暴露出去的接口。比如 Application 层只对外暴露 CommandUseCase 和 QueryUseCase 这些接口。Repository 层只暴露 ReadRepository 和 WriteRepository 这些接口。Presentation 层的 Controller 想要干点活,它只能依赖这些接口,比如 @Autowired private CommandUseCase createUserUseCase;。
Layered Architecture |
这样一来,世界突然变得无比清爽。业务逻辑(也就是那些 Service)只依赖抽象出来的接口,而对底层的具体实现一无所知。数据库怎么连的,用的是 JDBC 还是 JPA,业务逻辑完全不在乎。
这时候,一个非常神奇的事情就发生了:依赖关系开始发生反转。以前是 Presentation 依赖 Application,Application 依赖 Infrastructure。现在是 Application 只依赖自己定义的接口(Port),而 Infrastructure 反过来去实现这些接口(Adapter)。业务逻辑变成了核心,数据库实现变成了一个随时可以拔插的插件。
Spring Profiles:配置系统突然变成 Adapter 切换器
很多人以为 Spring Profiles 只是一个环境配置的小工具,用来区分开发环境、测试环境和生产环境,比如 dev、test、prod。其实,在这个架构里,它完全可以扮演另一个更有趣的角色:Adapter 的智能切换器。
比如说,我们定义了两个 Profile:一个叫 jdbc,一个叫 jooq。当应用启动时,根据当前激活的是哪个 Profile,Spring 容器就会自动装配对应的实现类。如果激活的是 jdbc Profile,那么所有 @Repository 并且配合 @Profile("jdbc") 的 JDBC 实现类就会被实例化并注入到容器里。如果激活的是 jooq Profile,那么基于 jOOQ 的那一套实现就会启动。
而 Application 层的业务逻辑,它从头到尾只依赖 ReadRepository 和 WriteRepository 这两个接口。至于背后到底是哪个小兄弟在干活,是 JDBC 还是 jOOQ,业务逻辑完全不关心,它只知道对着接口喊一声“给我把数据存了”,然后就有人替它把事情办了。
你仔细品一品,这个过程里发生的事情,其实和 Hexagonal Architecture 的核心思想完全一致。
Hexagonal Architecture 里有一个核心概念叫 Adapter(适配器)。你的数据库访问方式,不就是一种 Adapter 吗?JDBC 是一种 Adapter,jOOQ 是另一种 Adapter,JPA 同样也是一种 Adapter。你的业务核心就像一台电视,它只关心有没有信号输入(Port),而不关心信号是通过有线电视线来的,还是通过天线来的(Adapter)。@Profile 就像是那个信号源切换按钮,按一下,换个 Adapter,电视内容还是照常播放。
DAO 模式:早就存在的 Port 思想
如果我们再把历史往前翻一翻,回到那个 SSH(Struts+Spring+Hibernate)还大行其道的年代,会发现一个更有趣的事实。那时候有一个很经典的设计模式叫 DAO(Data Access Object,数据访问对象)。
DAO 模式其实已经在做同样的事情了。我们会定义一个 UserDao 接口,然后在 XML 里或者实现类里去配置具体的 Hibernate 实现或者 JDBC 实现。这个 UserDao 接口,就是 Port。那些不同的数据库实现,就是 Adapter。
在写单元测试的时候,为了不连数据库,我们会非常自然地创建一个 MockUserDao 或者一个基于内存的 FakeUserRepository,然后注入到 Service 里进行测试。测试业务逻辑的时候,数据库连都不需要启动,测试跑得飞快。
这件事在 Hexagonal Architecture 的术语里,有一个很高大上的名字,叫做 Driven Port(驱动端端口)。你看,我们十几年前就已经在写 Hexagonal Architecture 了,只是我们自己不知道而已。我们当时只是觉得这样写代码,测试起来方便,解耦解得好,从来没想过给它起一个这么 fancy 的名字。
测试体系:架构设计带来的连锁反应
当你的代码里接口边界设计得非常清晰合理时,会产生一个非常美妙的连锁反应:你的测试体系会自动分层,而且每一层都职责分明,运行速度也刚刚好。
第一层是 Unit Test(单元测试),这是跑得最快的。测试某个 UserCommandService 的时候,我只需要 new 一个 FakeReadRepository 和一个 FakeWriteRepository(这两个假仓库把数据存在内存的 List 里),然后注入进去。Spring 不需要启动,数据库更不需要启动,测试几百个用例可能几秒钟就跑完了,爽得飞起。
第二层是 Slice Test(切片测试),这是 Spring Boot 提供的神器。比如我想单独测试 Controller 的逻辑,就用 @WebMvcTest。这个注解只会启动 Web 层的那些 Bean,Controller 依赖的 UseCase 我们可以用 @MockBean 来模拟。这样一来,Controller 的逻辑就可以完全独立于下层进行验证,速度也比启动整个应用快得多。
第三层是 Integration Test(集成测试),也叫整合测试。这时候我们用 @SpringBootTest 配合 MockMvc。整个应用上下文会启动,数据库也会真实运行(可能是 H2 内存数据库),但 HTTP Server 不会真的监听端口。请求通过 MockMvc 模拟发送,一路穿过整个技术栈,从 Controller 到 Service 再到 Repository,最后落到数据库里。这一层测试用来验证各个模块之间能否配合工作。
第四层是 E2E Test(端到端测试),这是最接近真实环境的。我们用 @SpringBootTest(webEnvironment = RANDOM_PORT) 配合一个 RestTestClient。这时候,一个真正的 HTTP 服务器会启动,监听一个随机端口。测试代码会真的发送 HTTP 请求进去,请求一路经过 Controller → Application → Repository → Database,整个系统从头到尾跑一遍,就像一个真正的用户在操作一样。
看到这里,很多人会突然一拍大腿:卧槽,这些测试策略怎么跟 Hexagonal Architecture 教科书里写的一模一样!原因其实非常简单:当你的架构边界正确时,你的测试结构是自然形成的,根本不需要刻意设计。 你不需要去记什么 Unit Test 该测什么,Integration Test 该测什么,架构会告诉你答案。
架构守卫:ArchUnit 自动维护边界
理想很丰满,现实很骨感。代码写久了,人员变动多了,架构边界往往就会像一堵年久失修的墙,开始出现各种窟窿。今天有人图省事,在 Controller 里直接注入了 Repository,明天又有人在 Domain 里引入了 Spring 的注解。架构慢慢就腐烂了。
这时候,有一个非常有意思的工具叫 ArchUnit,它简直就是架构的守护者,代码界的纪律委员。你可以用代码来写架构规则,就像写单元测试一样。比如:
- “我规定,presentation 包下的所有类,都不允许依赖 infrastructure 包下的任何类!”
- “我规定,domain 包下的所有类,不允许依赖任何外部框架的包,除了 Java 标准库!”
一旦有人不小心违反了规则,比如新来的实习生图省事,在 Controller 里直接 new 了一个 JdbcUserRepository,ArchUnit 的测试就会在 CI(持续集成)阶段直接亮红灯,构建失败!这就像给你的架构加了一道自动化的安检闸机,任何人想往包里塞违禁品,都会被无情地拦住。
当团队规模从几个人扩大到几十个人时,这种自动化工具的价值简直是无法估量的。它把架构师的智慧和规则,固化成了机器可以执行的代码,比开一百次架构评审会都管用。
轻量 CQRS:Command 和 Query 的分离
再回头看我们一开始的那个项目结构,你可能会注意到一个小细节。我们把接口分成了 CommandUseCase 和 QueryUseCase,把仓库分成了 WriteRepository 和 ReadRepository。这其实就是一种非常轻量级的 CQRS(命令查询职责分离) 模式。
这里没有复杂的 Event Bus(事件总线),也没有双数据库同步,更没有引入任何复杂的中间件。它只是在代码结构上进行了分离。写操作(Create, Update, Delete)走一套接口,读操作(Query)走另一套接口。Controller 也跟着分成了 RestCommandController 和 RestQueryController。
这样做有什么好处呢?我跟你讲,好处大了去了。
首先,代码导航更清晰。如果你想找创建用户的逻辑,直接去 command 包下的 CreateUserCommand 和 CreateUserCommandHandler 里找,一目了然。如果你想找查询逻辑,就去 query 包。
其次,代码评审更轻松。同事提了个 PR,里面改了 command 包下的文件,你立刻就知道这是修改了写操作,需要重点关注事务、并发、数据一致性这些问题。如果改的是 query 包,你就可以把重点放在查询性能和数据返回格式上。
最后,未来扩展更平滑。如果有一天,你的系统读写压力差异巨大,真的需要把读库和写库分离,把查询请求路由到只读从库上去。因为你的代码已经按照命令和查询做了清晰的分离,这个改造过程会变得异常简单,只需要在基础设施层动动手脚就可以了。你的架构,已经为未来铺好了路。
把分层架构改成 Hexagonal:过程像搬家一样简单
接下来,我要讲一个可能会让很多整天鼓吹 Hexagonal Architecture 的架构师们集体沉默的事实。把我们刚才那个经典的分层架构项目,改造成一个标准的、教科书级别的 Hexagonal Architecture,需要做什么?你猜猜看?改代码?重构业务逻辑?重写 Service?
都不需要。
只需要改包名,重排一下目录结构。 代码一行都不用动!Service 还是那个 Service,Repository 还是那个 Repository,业务逻辑完完整整地保留下来。
我们只需要新建几个包,然后把类移动一下,就像搬家一样。原来的 domain 包,原封不动地搬进新的 core 包下。原来的 application.api 包,搬进新的 ports.in 包(表示这是入站端口)。原来的 infrastructure.api 包,搬进新的 ports.out 包(表示这是出站端口)。
原来的 presentation 包,整体搬进新的 adapters.in 包(表示这是入站适配器)。原来的 infrastructure.impl 包,整体搬进新的 adapters.out 包(表示这是出站适配器)。
改造完成后,目录结构就变成了下面这个样子,看起来是不是瞬间高大上了许多?
text
src/main/java/com/yourcompany/awesomeproject/
├── core/ # 核心业务
│ ├── domain/ # User record, UserNotFoundException
│ └── application/ # 应用层
│ ├── command/ # CreateUserCommand, UpdateUserCommand
│ ├── query/ # UserView
│ └── impl/ # UserCommandService, UserQueryService, ApplicationConfig
├── ports/ # 端口,定义边界
│ ├── in/ # CommandUseCase, QueryUseCase
│ └── out/ # ReadRepository, WriteRepository, IdGenerator
└── adapters/ # 适配器,实现技术细节
├── in/ # 入站适配器(驱动系统)
│ ├── common/dto/ # DTOs, Mapper
│ ├── rest/ # RestQueryController, RestCommandController,
│ │ # GlobalRestExceptionHandler
│ └── mvc/ # MvcQueryController, MvcFormController,
│ # MvcCommandController, GlobalMvcExceptionHandler
└── out/ # 出站适配器(被系统驱动)
├── jdbc/ # JdbcReadRepository, JdbcWriteRepository
├── jooq/ # JooqReadRepository, JooqWriteRepository
├── jpa/ # JpaReadRepository, JpaWriteRepository,
│ # UserJpaEntity, UserJpaMapper
└── id/ # UuidV7IdGenerator
逻辑完全一样,代码完全一样,只是名字变了,位置变了。从 presentation 和 infrastructure,变成了 adapters.in 和 adapters.out。从 application.api 变成了 ports。仅此而已。
代码:https://github.com/architectural-styles/architecture-hexagonal-sample
分层思维 vs 六边形思维
这两种架构,最大的区别其实不在代码里,而在我们思考问题的方式上。
分层架构是一种典型的垂直思维,或者说是线性思维。它看待世界的方式是自上而下的。一个请求进来了,先经过 Controller,再交给 Service,最后落到 Repository,就像瀑布一样,从上往下流。它的结构是按照技术职责来划分的:Web 层归 Web 层,业务层归业务层,数据层归数据层。这种思维很直观,也很容易理解。
而 Hexagonal Architecture 是一种向外辐射的思维,或者说是同心圆思维。它把世界看成一个中心,一个核心。最中心的地方,是你最宝贵的资产——核心业务逻辑(Core Domain),它不依赖任何人,只依赖它自己。围绕在核心周围的,是各种 Ports(端口),也就是核心业务对外暴露的接口。最外层的,是各种 Adapters(适配器),它们是用来连接外部世界的工具。
在这种思维里,HTTP 接口不是核心,它只是一个入站适配器,用来接收来自浏览器的请求。数据库也不是核心,它只是一个出站适配器,用来持久化数据。消息队列、命令行界面(CLI)、甚至是另一个系统的 API 调用,全都是适配器。核心业务逻辑就像一个强劲的发动机,而外部世界就像各种可以随时插拔的插头、仪表盘、油门踏板。你想换个仪表盘(比如从 REST API 换成 gRPC)?没问题,拔掉旧的,插上新的,发动机继续轰鸣。
六边形架构真正价值出现的场景
那么,是不是说 Hexagonal Architecture 就是银弹,所有项目都应该用它呢?当然不是。只有当你的系统出现以下这些情况时,它的真正价值才会凸显出来。
第一种情况,系统有多个入口适配器。你的服务不仅对外提供 REST API,还得支持 gRPC 调用,同时还有一个命令行工具(CLI)可以用来执行一些管理任务,甚至还要监听消息队列里的消息。在这种情况下,如果你的业务逻辑被紧紧地绑在 HTTP 协议里,那麻烦就大了。而 Hexagonal Architecture 可以让你很轻松地为每一种入口方式写一个适配器,它们都调用同一个核心业务逻辑。
第二种情况,系统对数据库技术高度敏感,或者有频繁更换的可能。比如,你的项目还在选型阶段,可能在 JDBC、jOOQ、JPA 之间摇摆不定。或者,你们公司战略调整,未来打算从 Oracle 迁移到国产数据库。如果你的业务逻辑和具体的 ORM 框架绑死,那迁移过程将是一场噩梦。而在 Hexagonal Architecture 里,你只需要为不同的数据库技术实现不同的出站适配器,然后在配置文件中切换一下,核心代码纹丝不动。
第三种情况,团队规模变得很大,可能有几十个甚至上百个开发者在同一个代码库里工作。这时候,架构的意图需要表达得非常清晰,包结构本身就是一份活文档。新同学入职,打开代码,看到 ports、adapters、core 这几个顶级包,立刻就能明白整个系统的设计理念:哦,这里是核心业务,不能乱动;那里是适配器,可以自由发挥。这能大大降低沟通成本和新人的上手难度。
简单 CRUD 系统里的真实情况
反过来,如果你的系统只是一个非常简单的 CRUD(增删改查)服务,比如说就是一个给内部管理后台用的,提供几个简单的 REST API,增删改查用户信息。而且数据库技术也很稳定,五年之内都不打算换。在这种情况下,你的经典分层架构和那个华丽的 Hexagonal Architecture,在实际的编码体验和维护成本上,差异几乎为零。你用了六边形,并不会让你的增删改查代码少写一行,也不会让系统的性能提升一倍。
所以说,架构设计有一个非常现实、非常朴素的原则,这个原则永远成立:架构是服务于问题的,而不是服务于潮流的。你的架构选型,应该基于你当前面临的实际问题:业务的复杂度、团队规模、未来的演化可能性。而不是因为某个架构听起来很酷,或者某某大厂在用。
最后的结论
一个写得足够优秀的、遵守依赖倒置原则的分层架构(Layered Architecture),它其实已经具备了 Hexagonal Architecture 的全部核心思想:
- 清晰的接口边界:定义了 UseCase 和 Repository 接口。
- 依赖反转:业务逻辑依赖抽象,具体实现反过来依赖业务逻辑。
- 实现隔离:具体的数据库技术被隔离在 impl 包里。
- 适配器替换能力:通过依赖注入和 Profile,可以轻松替换不同实现。
这些,正是 Hexagonal Architecture 之所以被称之为架构的全部核心。
因此,我们会得出一个非常有趣的结论:你其实有两个选择。
第一种情况,如果你现在的分层架构已经写得非常优秀,接口清晰,依赖干净,团队协作良好,代码跑得飞起。那你就继续用下去,完全合理。不要为了赶时髦而重构,不要为了在简历上多一行“精通 Hexagonal Architecture”而把系统搞得天翻地覆。你的架构,已经够用了。
第二种情况,如果你的团队确实被 Hexagonal Architecture 的理念所吸引,或者公司有明确的技术规范要求使用这种架构。那你也不用慌,因为你根本不需要从头重写。迁移过程会非常简单,像我刚才演示的那样,改改包名,重排一下目录结构,花一两个小时的时间,就能完成整个架构风格的转换。你的业务逻辑核心,可以原封不动地保留下来。
真相:
三层是具体依赖具体,六边形是具体依赖抽象,本质就是DIP(依赖倒置原则)的应用差异。