整洁架构落地实践
Aug 4 2025最近在新公司接手了一个历史悠久的项目,功能不多,代码量却不小。深入其中,才发现有太多太多的槽点,简直让人头大。每当要新增一个功能或者修复一个 Bug,都感觉像是在雷区里跳舞,步步惊心。
总结下来,这个项目主要有这么几个“硬伤”:
- 代码逻辑不内聚:同一个功能的实现,像天女散花一样分散在好几个不同的文件里,想理清完整的逻辑链条,得在 IDE 里跳来跳去,极其耗费心智。
- 分层过多且混乱:一个简单的请求,要穿越重重关卡,经过好几个层级的调用才能抵达终点。有时你都分不清某一层到底是干嘛的,感觉纯粹是为了分层而分层。
- 内部自研框架不好用:项目依赖了一个内部的
kgo
框架,但文档缺失,设计理念也比较陈旧,出了问题排查起来非常困难。 - 外部依赖离散:对数据库、缓存、外部 API 的调用封装得五花八门,没有统一的规范和入口,整个项目像一个杂乱的“百宝箱”。
恰好,Leader 对这个项目制定了新的方向与目标,之前这些遗留问题实实在在成了我们前进路上的绊脚石。因此,一次彻底的架构升级与重构势在必行。同时,部门内也正在推广项目规范化和最佳实践输出,这简直是天赐良机。
很久以前我就读过《架构整洁之道》这本书,在之前的项目中也或多或少受到一些启发,但从未有机会从一个项目初始就完整地贯彻整洁架构的理念。这次,我决定抓住机会,来一场彻彻底底的整洁架构实践。在这个过程中,我感觉自己对整洁架构的理解,以及对 SOLID 原则的落地方式,都有了前所未有的深入体会。
在实践的过程中,我将心得与经验沉淀下来,开源了一个即开即用的 Go 整洁架构项目模板,希望能给有同样需求的同学一些参考: https://github.com/zhu327/go-clean-arch
项目模板特性
- Clean Architecture: 清晰地分离业务逻辑与基础设施。
- Dependency Injection: 使用 Google Wire 实现编译时依赖注入,避免反射。
- Structured Logging: 开箱即用的结构化日志。
- Configuration Management: 基于 Viper 的环境化配置管理。
- Docker Support: 包含
Dockerfile
和docker-compose.yaml
,便于部署。
架构设计
这个项目严格遵循整洁架构(Clean Architecture)的原则,确保代码库的可扩展性、可维护性和可测试性。
各层描述
- 🔵 Domain 层: 包含核心的业务逻辑和实体。它是最独立的层,不依赖于任何其他层。
- 🟣 Use Case 层: 通过与 Domain 层交互来编排业务工作流。它定义了供 Adapter 层实现的接口。
- 🟠 Adapter 层: 作为与外部世界(如 UI、数据库、外部 API)沟通的桥梁。它实现了 Use Case 层定义的接口。
- 🟢 External World: 代表与应用程序交互的外部系统,如 Web 客户端、数据库或第三方服务。
核心原则
- 依赖方向: 所有依赖都必须指向内部。Domain 层位于中心,任何内层都不能依赖于外层。这是依赖倒置的核心。
- 接口隔离: 接口由消费者(Use Case 层)定义,由提供者(Adapter 层)实现。这使得业务逻辑与基础设施细节解耦。
- 分层隔离: 每一层只与它的相邻层交互,保持清晰的职责分离。
项目结构
internal/
├── domain/ # Domain 层 (业务实体和规则)
├── usecase/ # Use Case 层 (业务逻辑, 接口, DTOs)
├── adapter/ # Adapter 层
│ ├── delivery/ # 交付机制 (例如, HTTP, gRPC handlers)
│ ├── repository/ # 仓库实现 (数据库访问)
│ └── gateway/ # 到外部服务的网关
└── di/ # 依赖注入配置 (Wire)
实践中的思考与“顿悟”
理论总是美好的,但实践才是检验真理的唯一标准。在重构过程中,我遇到了不少困惑,也收获了很多“原来如此”的顿悟时刻。
1. 每一层到底应该放什么?
刚开始划分代码时,我经常会纠结一个结构体、一个文件到底该放在哪。经过反复的思考和试错,我总结出了一套相对清晰的指导方针。
internal/domain/
- ✅ 应该包含: 核心业务实体(代表业务对象的 Struct)、值对象、领域服务接口、与领域相关的枚举和常量。
- ❌ 不应包含: HTTP 请求/响应结构体、分页或 API 特定数据等应用级概念、任何基础设施细节(如 JSON 标签)。
internal/usecase/
- ✅ 应该包含: 具体业务用例的实现(如
CreateUser
,LoginUser
)、依赖项的接口定义(如UserRepository
)、请求和响应的 DTO(数据传输对象)。
- ✅ 应该包含: 具体业务用例的实现(如
internal/adapter/
- ✅ 应该包含: 将请求转换为用例调用的 HTTP/gRPC 处理器、实现 Use Case 层接口的数据库仓库、外部服务的客户端(网关)。
通过这个项目我深刻地体会到,项目的根本目的是解决现实世界的问题。我们需要对现实中的实体进行建模,这就是 domain
层的使命。这个实体必须纯粹,不依赖任何外部技术,只包含自身的业务规则和逻辑。
而 usecase
层,则是针对领域模型的一次具体应用和落地。它需要依赖外部能力,比如查询一次数据库、调用一次 gRPC。这时,我们不应该直接在 usecase
中去 new 一个 DB client。而是应该在 usecase
中定义业务所需的 interface
,然后由 adapter
层的 repository
或 gateway
来实现这些接口。
这样一来,repository
就不再只是对 GORM 或 sqlx
的简单封装,它变成了 usecase
接口的具体“提供者”。它需要将从数据库查出的数据模型,转换为 usecase
能理解的 domain
模型。最后,通过依赖注入,实现了完美的依赖倒置。
2. 关于依赖注入:从排斥到接受
在以往的项目中,我基本没用过依赖注入,项目里充斥着各种 init()
函数和全局变量,给测试和维护带来了无尽的痛苦。后来接触到依赖注入,有个老哥自己撸了一套基于反射的 DI 框架,读他的代码真的太难受了,完全不知道依赖到底是怎么注入进来的,魔法感十足,这导致我对依赖注入一度有先入为主的排斥。
但在实践整洁架构的过程中,由于 usecase
必须做依赖倒置,我不得不引入 DI 工具。最终选择了 Google 的 wire
。用过之后才发现,它其实并没有什么魔法,只是通过扫描代码,自动生成了那些“手动挡”的初始化代码而已(wire_gen.go
)。相对于通过反射实现的“自动挡”,这种代码生成的方式让一切依赖关系都变得明确和可知,可读性好太多了。
3. DTO 的归属之争
这是一个让我纠结了很久的问题。usecase
理想情况下只应该依赖 domain
定义的模型。然而在实际业务中,总会有一些不属于 domain
核心模型的结构,比如 CreateUserRequest
、UserListResponse
。注意,这并非 delivery
层用于解析 JSON 的结构体,而是 usecase
自身执行业务逻辑所需要的数据结构。
我曾经犹豫过,要不要把这些结构体也塞进 domain
层?
纠结过后,我得出的结论是:不能!一定要保持 domain
层的纯粹性。这些结构体实际上是和某个具体的 usecase
强绑定的,它们应该属于 usecase
层。所以,我在 usecase
层下也创建了 dto
目录,用来存放这些用例专属的请求/响应结构。
这确实会导致 delivery
(HTTP) 层和 usecase
层可能都有各自的 DTO,初看可能会有些冗余和困惑。但这样做的好处是职责更清晰,usecase
不会因为 delivery
层的变化(比如修改一个 JSON 字段名)而被迫修改。这是为了层与层之间的解耦,必须付出的代价。
4. 我终于悟了:“在调用者包中定义接口”
我之前读到过 Go 社区的一个广为流传的建议(出自 colobu.com/gotips/018.html):
在使用者的包中定义接口,而不是提供者的包中定义。
说实话,在没有深入实践整洁架构之前,我对这条原则一直是一知半解。为什么接口要让“用的人”来定义,而不是“做的人”来定义呢?
在这次重构之后,我“悟了”。这不就是对“依赖倒置原则”最精准、最通俗的诠释吗!
在我们的架构里:
- usecase
是接口的使用者(消费者)。
- adapter
是接口的实现者(提供者)。
usecase
说:“我需要一个能根据用户 ID 找到用户,并返回 domain.User
的能力,我不管你是从 MySQL、Redis 还是从文件中获取,总之,你得满足我定义的这个 UserRepository
接口。”
// in usecase/iface/repository.go
package iface
import "your_project/internal/domain"
type UserRepository interface {
FindByID(ctx context.Context, id uint) (*domain.User, error)
}
然后,adapter/repository
层去实现它。
// in adapter/repository/user_gorm.go
package repository
// ...
func (r *userRepository) FindByID(ctx context.Context, id uint) (*domain.User, error) {
// gorm query logic...
// convert gorm model to domain.User
}
这样做的好处是巨大的:
- 完美解耦:
usecase
只关心它需要什么,不关心底层如何实现。更换数据库实现对usecase
毫无影响。 - 易于测试:在为
usecase
写单元测试时,我们可以轻而易举地 mock 这个UserRepository
接口,而无需一个真实的数据库连接。 - 符合最小知识原则:如果让
adapter
来定义接口,它可能会暴露很多usecase
根本不需要的方法,增加了使用者的心智负担。而由使用者定义,则可以确保接口的精简和必要。
总结
从一个混乱的遗留项目开始,到最终落地一套清晰、可维护的整洁架构,这次重构之旅让我收获颇丰。整洁架构并不仅仅是一套死板的目录结构或分层规则,它更是一种引导我们写出高内聚、低耦合代码的思维方式。
它迫使我们去思考: - 什么是业务的核心?(Domain) - 业务流程是怎样的?(UseCase) - 技术细节如何与业务逻辑解耦?(Adapter & Dependency Inversion)
通过这次实践,我对依赖倒置、接口隔离等 SOLID 原则有了远比书本上更深刻的理解。当你真正理解了“为什么”要这么做,而不是仅仅停留在“是什么”和“怎么做”的层面时,你会发现,写出整洁、健壮的代码,其实是一件充满乐趣的事情。
希望这篇文章能为同样在重构道路上探索的你,提供一些有价值的参考与启发。