Go 项目结构——从 Standard Layout 到 Clean Architecture
摘要
Go 语言没有强制规定项目目录结构,这既是自由,也是陷阱——同一个团队里五个人可能写出五种不同风格的项目布局,让接手者无从下手。本文从”为什么需要项目结构约定”出发,梳理 Go 社区最广泛认可的 Standard Layout(/cmd、/internal、/pkg 等目录惯例),深入剖析每个目录的设计意图与边界,再介绍如何在 Standard Layout 之上引入 Clean Architecture(整洁架构)思想来组织业务逻辑——将 domain、usecase、repository、handler 分层,保持核心业务逻辑与框架、数据库等基础设施的解耦。最后给出从命令行工具、HTTP 微服务到 mono-repo 三种典型场景的具体目录模板和包设计决策框架。
第 1 章 为什么项目结构如此重要
1.1 结构即沟通
代码是写给人读的,项目结构是代码的第一印象。当一个新工程师打开 ls -la 看到项目根目录时,他应该能在 30 秒内回答以下问题:
- 这个项目有哪些可执行程序?
- 对外暴露的库代码在哪里?
- 数据库访问层、HTTP 路由、业务逻辑分别在哪里?
- 配置和部署文件在哪里?
如果一个项目的根目录是这样的:
myproject/
├── api.go
├── auth.go
├── config.go
├── database.go
├── handler.go
├── main.go
├── models.go
├── utils.go
└── ...(30 个 .go 文件全平铺)
那么即便每个文件的代码质量都很高,这个项目仍然是难以维护的——因为它没有表达任何关于”哪些东西属于一类”的信息。文件名是所有结构信息的唯一载体,当项目增长到 100 个文件时,这种平铺结构会完全失控。
好的项目结构应该做到:
- 自文档化(Self-documenting):目录名本身传达意图;
- 边界清晰:哪些代码是公开 API,哪些是内部实现,一目了然;
- 依赖方向可见:高层模块不依赖低层模块,依赖方向通过目录结构体现;
- 可扩展:增加新功能不需要重构整个目录树。
1.2 Go 的包设计哲学
在深入目录结构之前,有必要理解 Go 的包(package)设计哲学,因为目录结构本质上是包的物理组织方式:
包是封装的单元:Go 的 package 是比类更粗粒度的封装单元。一个包应该围绕一个单一的职责设计——所有暴露在包外的类型、函数、变量共同构成这个包的 API,它们应该相互关联,共同表达一个内聚的概念。
包名即文档:package http 里的 Client 不需要命名为 HTTPClient,因为使用方会写 http.Client,包名已经提供了上下文。好的包名应该简洁(单个英文词,不用下划线或驼峰),且能准确描述包的内容。
避免循环依赖:Go 编译器严格禁止包之间的循环依赖。这不是限制,而是强迫开发者理清依赖关系——如果 A 包和 B 包互相依赖,说明要么它们应该合并为一个包,要么需要提取公共接口打破循环。
internal 包的访问控制:Go 1.4 引入了 internal 目录——internal 下的包只能被其父目录下的代码引用,外部无法 import。这是 Go 唯一的包级访问控制机制(比 Java 的 protected 和 package-private 更简单粗暴,但足够用)。
第 2 章 Standard Layout:Go 社区的目录惯例
2.1 Standard Layout 的由来
golang-standards/project-layout 是 GitHub 上 star 最多的 Go 项目结构参考,归纳了 Go 大型项目(Kubernetes、etcd、Prometheus 等)的共同目录约定。它不是官方标准,但已成为事实上的社区共识。
完整的 Standard Layout 如下:
myproject/
├── cmd/ # 主程序入口(每个子目录对应一个可执行文件)
│ ├── server/
│ │ └── main.go
│ └── worker/
│ └── main.go
├── internal/ # 项目内部代码(外部不可 import)
│ ├── app/ # 应用初始化和组装
│ ├── config/ # 配置解析
│ ├── handler/ # HTTP/gRPC handler
│ ├── service/ # 业务逻辑
│ └── repository/ # 数据访问层
├── pkg/ # 可被外部项目 import 的公共库
│ ├── logger/
│ └── middleware/
├── api/ # API 定义(OpenAPI spec、proto 文件)
│ ├── openapi/
│ └── proto/
├── web/ # Web 静态资源(HTML、CSS、JS)
├── configs/ # 配置文件模板
├── scripts/ # 构建、安装、分析脚本
├── build/ # 打包和 CI/CD 配置
│ ├── ci/
│ └── docker/
├── deployments/ # 部署配置(Kubernetes YAML、Helm charts)
├── test/ # 集成测试、端到端测试数据
├── docs/ # 设计文档
├── tools/ # 项目依赖的工具(通过 tools.go 管理)
├── vendor/ # 依赖包(可选,通常用 go.sum 替代)
├── go.mod
├── go.sum
├── Makefile
└── README.md
2.2 核心目录详解
/cmd:可执行程序的入口
/cmd 下每个子目录对应一个可执行程序,子目录名即是程序名(go build ./cmd/server 生成 server 二进制)。
关键原则:main.go 只做装配,不写业务逻辑。main.go 应该只做:解析配置、初始化依赖(DB 连接、Logger 等)、组装应用、启动 HTTP/gRPC server、处理优雅退出信号。业务逻辑全部在 internal/ 中。
// cmd/server/main.go — 典型的装配代码
func main() {
cfg, err := config.Load() // 读取配置
if err != nil {
log.Fatal(err)
}
db, err := postgres.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatal(err)
}
// 组装依赖(依赖注入,手动或通过 wire)
userRepo := repository.NewUserRepository(db)
userService := service.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userService)
// 启动 HTTP 服务
srv := server.New(cfg.Port, userHandler)
// 优雅退出
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := srv.Start(ctx); err != nil {
log.Fatal(err)
}
}为什么不把 main.go 写得很厚?因为 main 包不可被测试(其他包不能 import main),业务逻辑放在 main.go 里等于无法测试。
/internal:项目私有代码
/internal 是 Go 项目中最重要的目录。其下的所有包只能被本项目代码引用——这个限制由 Go 编译器强制,防止外部项目意外依赖内部实现细节,给重构带来障碍。
/internal 内部的子目录划分对应业务层次,详见第 3 章的 Clean Architecture 讨论。
/pkg:公共可复用库
/pkg 下的代码可以被外部项目 import。放入 /pkg 的代码应该:
- 与业务无关(通用工具、中间件、日志库等);
- API 稳定,轻易不会破坏性修改;
- 有完善的测试和文档。
一个常见的误区:将所有代码都放在 /pkg 中,以为”万一以后别人要用”。这种过度设计会导致外部实现细节被迫稳定,增加维护成本。正确做法是:除非已经有外部用户,否则放在 /internal;确实需要对外暴露时,再移到 /pkg(或独立为一个新模块)。
/api:接口定义
存放 API 合约文件:OpenAPI/Swagger YAML、Protocol Buffer .proto 文件、JSON Schema 等。这些是服务与外界沟通的契约,独立存放便于:
- 前端/客户端团队直接查阅;
- 代码生成工具(
protoc、oapi-codegen)找到输入文件; - API 变更通过 diff 一目了然。
第 3 章 Clean Architecture:内部分层的设计哲学
3.1 为什么需要分层
Standard Layout 解决了”文件放在哪个目录”的问题,但没有解决 internal/ 内部如何组织代码——如果把所有业务代码都平铺在 internal/ 下,同样会陷入混乱。
Clean Architecture(Robert C. Martin 提出,也称六边形架构、洋葱架构的变体)的核心思想是:
依赖方向只能从外层指向内层,内层不知道外层的存在。
┌─────────────────────────────────┐
│ Frameworks & Drivers │ ← 最外层:HTTP框架、数据库驱动、消息队列
│ ┌───────────────────────────┐ │
│ │ Interface Adapters │ │ ← 适配层:Handler、Repository实现、Presenter
│ │ ┌─────────────────────┐ │ │
│ │ │ Use Cases │ │ │ ← 业务规则:应用服务、用例
│ │ │ ┌───────────────┐ │ │ │
│ │ │ │ Entities │ │ │ │ ← 最内层:领域模型、核心业务规则
│ │ │ └───────────────┘ │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
最内层(Entities / Domain):纯 Go struct 和接口,代表业务的核心概念(User、Order、Product 等)及其不变的业务规则。这层代码不依赖任何第三方库,不依赖数据库、HTTP、框架——它是整个应用中最稳定、最易测试的部分。
第二层(Use Cases / Application Service):编排业务流程。例如”用户注册”用例:验证邮箱格式 → 检查是否已注册 → 创建用户 → 发送欢迎邮件。这层代码通过接口(而非具体实现)与外部交互——它知道”需要一个 UserRepository 来存储用户”,但不知道具体是 PostgreSQL 还是 MySQL。
第三层(Interface Adapters):将外层的数据格式转换为内层能理解的格式,或将内层的数据转换为外层需要的格式。HTTP Handler 把 HTTP 请求转换为用例的输入;Repository 的 PostgreSQL 实现把数据库行转换为领域对象。
最外层(Frameworks & Drivers):框架、数据库驱动、第三方客户端。这层变化最频繁(换数据库、换HTTP框架),但因为它在最外层,对内层没有影响。
3.2 在 Go 项目中落地 Clean Architecture
将上述思想映射到具体目录:
internal/
├── domain/ # 最内层:领域模型(Entities)
│ ├── user.go # User struct,业务规则方法
│ ├── order.go
│ └── errors.go # 领域错误定义
│
├── port/ # 接口定义(连接各层的"插座")
│ ├── repository.go # UserRepository 接口
│ └── service.go # EmailService 接口(外部依赖的接口)
│
├── usecase/ # 第二层:应用业务规则(Use Cases)
│ ├── user_register.go
│ ├── user_login.go
│ └── order_create.go
│
├── adapter/ # 第三层:适配器
│ ├── handler/ # HTTP Handler(gin/echo/chi)
│ │ ├── user_handler.go
│ │ └── middleware.go
│ ├── repository/ # Repository 实现(PostgreSQL、Redis)
│ │ ├── postgres/
│ │ │ └── user_repo.go
│ │ └── redis/
│ │ └── cache_repo.go
│ └── grpc/ # gRPC 适配器
│
├── infrastructure/ # 最外层:框架和驱动
│ ├── database/ # 数据库连接池初始化
│ ├── cache/ # Redis 客户端
│ └── messaging/ # Kafka/RabbitMQ 客户端
│
└── app/ # 应用组装(依赖注入)
└── app.go # 将所有层组装在一起
3.3 依赖倒置:接口是关键
Clean Architecture 在 Go 中落地的关键是依赖倒置原则(DIP)——高层模块(Use Case)不依赖低层模块(Repository 实现),两者都依赖抽象(接口):
// domain/user.go — 领域对象(无任何外部依赖)
type User struct {
ID int64
Email string
Name string
CreatedAt time.Time
}
// 领域级别的业务规则
func (u *User) IsActive() bool {
return u.CreatedAt.After(time.Now().Add(-365 * 24 * time.Hour))
}// port/repository.go — 接口定义(Use Case 所依赖的抽象)
// 注意:接口定义在"消费者"侧(use case 包),而非"实现者"侧(repository 包)
// 这是 Go 接口的惯用法:接口应该由使用者定义,实现者只需满足接口即可
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*domain.User, error)
FindByEmail(ctx context.Context, email string) (*domain.User, error)
Save(ctx context.Context, user *domain.User) error
}// usecase/user_register.go — 业务逻辑(只依赖接口)
type UserRegisterUseCase struct {
userRepo port.UserRepository // 接口,不是具体实现
emailSender port.EmailSender // 接口
}
func (uc *UserRegisterUseCase) Execute(ctx context.Context, req RegisterRequest) (*domain.User, error) {
// 检查邮箱是否已注册
existing, err := uc.userRepo.FindByEmail(ctx, req.Email)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("check email: %w", err)
}
if existing != nil {
return nil, domain.ErrEmailAlreadyExists
}
// 创建用户
user := &domain.User{
Email: req.Email,
Name: req.Name,
}
if err := uc.userRepo.Save(ctx, user); err != nil {
return nil, fmt.Errorf("save user: %w", err)
}
// 发送欢迎邮件(接口调用,不关心具体实现是 SendGrid 还是 SMTP)
uc.emailSender.SendWelcome(ctx, user.Email, user.Name)
return user, nil
}// adapter/repository/postgres/user_repo.go — 具体实现(依赖数据库)
type PostgresUserRepository struct {
db *sql.DB
}
// 实现 port.UserRepository 接口(Go 的隐式接口,无需显式声明 implements)
func (r *PostgresUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
row := r.db.QueryRowContext(ctx, "SELECT id, email, name, created_at FROM users WHERE email = $1", email)
var u domain.User
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
return &u, nil
}这种设计的好处:
- Use Case 完全可测试:测试时只需 mock
UserRepository接口,不需要真实数据库; - 实现可替换:将来把 PostgreSQL 换成 MySQL,只需要换
adapter/repository/mysql/的实现,usecase/代码不需要改动; - 框架无关:将来把 Gin 换成 Echo,只需要换
adapter/handler/的代码,业务逻辑不受影响。
第 4 章 三种典型场景的项目结构
4.1 命令行工具(CLI Tool)
对于简单的命令行工具,不需要完整的 Clean Architecture,Standard Layout 的简化版即可:
mycli/
├── cmd/
│ └── mycli/
│ └── main.go # cobra/urfave-cli 根命令
├── internal/
│ ├── command/ # 每个子命令的实现
│ │ ├── build.go
│ │ └── deploy.go
│ └── config/ # 配置加载
├── pkg/ # 可复用的工具库
│ └── template/
├── go.mod
├── go.sum
└── Makefile
4.2 HTTP 微服务
user-service/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── domain/ # User, Address 等领域对象
│ ├── port/ # Repository/Service 接口
│ ├── usecase/ # 注册、登录、查询等用例
│ ├── adapter/
│ │ ├── http/ # gin/echo handler + router 注册
│ │ ├── grpc/ # gRPC server 实现
│ │ └── repo/ # postgres/redis 实现
│ ├── infrastructure/ # DB/cache/messaging 初始化
│ └── app/ # 依赖注入组装
├── api/
│ ├── openapi/
│ │ └── user.yaml # OpenAPI 3.0 spec
│ └── proto/
│ └── user.proto
├── configs/
│ ├── config.yaml
│ └── config.local.yaml
├── deployments/
│ └── k8s/
│ ├── deployment.yaml
│ └── service.yaml
├── go.mod
└── Makefile
4.3 Mono-repo(多服务单仓库)
大型团队常采用 mono-repo 将多个服务放在同一个 Git 仓库中管理,便于跨服务代码复用和原子性提交:
company-backend/
├── services/ # 各微服务
│ ├── user-service/
│ │ ├── cmd/
│ │ ├── internal/
│ │ └── go.mod # 每个服务有独立的 go.mod
│ ├── order-service/
│ │ ├── cmd/
│ │ ├── internal/
│ │ └── go.mod
│ └── payment-service/
│
├── shared/ # 跨服务共享代码(独立 Go module)
│ ├── pkg/
│ │ ├── logger/
│ │ ├── tracing/
│ │ └── middleware/
│ └── go.mod
│
├── proto/ # 全局 proto 定义
│ ├── user/
│ └── order/
│
├── scripts/ # 全局构建脚本
│ ├── build-all.sh
│ └── test-all.sh
│
└── Makefile # 统一入口
Mono-repo 的关键决策:每个服务是独立的 Go module(独立的 go.mod)还是共享同一个 go.mod?
- 独立
go.mod:服务间依赖通过版本号管理(replace指令指向本地路径),构建隔离性好,但跨服务修改需要同步升级版本号; - 共享
go.mod:最简单,但所有服务必须使用相同版本的依赖,大型项目中灵活性差。
第 5 章 包设计的实践决策
5.1 包的大小:多大合适
Go 没有规定包的大小,但有两个反面教材:
过大的包(Fat Package):一个包承担太多职责,内部耦合高,外部 API 臃肿。典型症状:包名是宽泛的词(util、common、helper、misc)——这类包名无法精确描述包的内容,往往是各种不相关代码的垃圾桶。
过小的包(Nano Package):每个文件都是一个包,包间跳转频繁,循环依赖风险高。Go 的包比 Java 的类粒度更粗,一个包完全可以有十几个源文件。
合适的包大小判断标准:能用一句话描述包的职责,且这句话不需要”和(and)“这个词。package http 负责 HTTP 客户端和服务端(这里的”和”是合理的,因为两者天然属于一个 RFC 标准);package userutil 负责”用户相关的工具函数”——这个包名本身就是警告信号。
5.2 循环依赖的解决方案
Go 禁止循环依赖,但写代码时很容易无意识地引入。常见解法:
方案一:提取公共接口。如果 A 包依赖 B 包,同时 B 包依赖 A 包的某个类型,将该类型提取到一个新包 C,让 A 和 B 都依赖 C。
方案二:合并包。如果两个包彼此高度耦合(几乎每个函数都用到对方的类型),说明它们本来就应该是一个包。
方案三:依赖反转。A 包定义一个接口,B 包实现这个接口,A 只依赖接口不依赖 B 的具体类型。
5.3 避免 God Package(上帝包)
// 反面教材:package main 里写了所有业务逻辑
// 或者 package api 里既有路由、又有业务逻辑、又有数据库访问
// 一个包同时做了三件事(违反单一职责):
package user
func CreateUser(db *sql.DB, w http.ResponseWriter, r *http.Request) {
// 解析 HTTP 请求
var req CreateUserRequest
json.NewDecoder(r.Body).Decode(&req)
// 业务逻辑
if req.Email == "" {
http.Error(w, "email required", 400)
return
}
// 数据库操作
result, err := db.ExecContext(r.Context(), "INSERT INTO users ...", req.Email)
// 返回 HTTP 响应
json.NewEncoder(w).Encode(result)
}这个函数把 HTTP 处理、业务验证、数据库操作混在一起——任何一层的变化都需要修改这个函数,且无法单独测试任何一层的逻辑。
总结
本篇从包设计哲学出发,梳理了 Go 项目结构的两个层次:
Standard Layout(物理目录约定):/cmd 存放各可执行程序入口(main 只做装配);/internal 存放项目私有代码(编译器强制访问控制);/pkg 存放可复用公共库(非必要不对外);/api 存放接口定义文件。根据项目规模选择完整版或简化版。
Clean Architecture(逻辑分层约定):在 internal/ 内部按照 domain → port(接口)→ usecase → adapter → infrastructure 的洋葱结构分层,依赖方向只能从外到内。核心工具是 Go 的隐式接口:Use Case 定义它所需要的 Repository 接口(消费者定义接口),具体的 PostgreSQL 实现只需满足这个接口——解耦框架与业务逻辑,让 Use Case 层完全可测试。
三个关键原则:
main.go只做装配,不写业务;- 除非有外部消费者,代码默认放
internal/; - 接口由消费者(Use Case)定义,而非生产者(Repository 实现)。
下一篇介绍 Go 模块系统与依赖管理的完整机制:02 Go Module 与依赖管理。
参考资料
- golang-standards/project-layout: https://github.com/golang-standards/project-layout
- Robert C. Martin,《Clean Architecture》, Pearson 2017
- Go Blog,《Organizing a Go module》: https://go.dev/doc/modules/layout
- Dave Cheney,《Practical Go: Real world advice for writing maintainable Go programs》
思考题
- 在一个包含 gRPC 服务、HTTP 网关和定时任务三种入口的 Go 项目中,
cmd/下有三个main.go,它们共享internal/service层的业务逻辑。如果某天需要将 gRPC 服务和 HTTP 网关合并为同一个进程(减少部署复杂度),Standard Layout 的哪些目录约定会成为阻碍?你会如何调整项目结构?internal/目录利用 Go 编译器的访问限制实现了包级别的封装。但如果你的项目是一个开源框架(如 Gin),核心逻辑放在internal/下会导致外部用户无法扩展。Go 生态中的知名开源项目(如 Kubernetes、Prometheus)是如何处理’既要封装内部实现,又要暴露扩展点’这个矛盾的?- Clean Architecture 强调依赖方向从外层指向内层(Handler → UseCase → Repository)。在 Go 中用 interface 实现依赖反转时,interface 应该定义在调用方(UseCase 层)还是实现方(Repository 层)?Go 社区的惯例与 Java 社区有什么本质区别?为什么?