Go 项目里的 internal 目录应该怎么组织?
- # go
- # project-layout
- # architecture
讨论 Go 项目目录结构时,internal 很容易被理解成一个“内部代码垃圾桶”:只要不想给外部项目 import,就统统塞进去。于是目录很快变成这样:
internal/
├── app/
└── pkg/
看起来很工整。app 放应用代码,pkg 放内部公共代码。但问题在于:如果项目无论多大,internal 下面永远只有 app 和 pkg 两个入口,那么 internal 本身就没有随着项目规模扩展。真正扩展的地方被推迟到了下一层,最后 internal/app 和 internal/pkg 会变成新的“大杂烩”。
这篇文章想说明一个更具体的判断:internal 目录应该按访问边界组织,而不是机械地按 app/pkg 二分。
internal 真正解决的是什么
internal 不是普通约定,它有 Go 工具链层面的限制。
Go 官方文档里对 internal package 的规则是:如果 import path 中包含 internal,那么只有 internal 父目录所在目录树里的代码可以 import 它。比如:
example.com/project/
├── cmd/
│ └── api/
│ └── main.go
└── internal/
└── user/
└── service.go
cmd/api/main.go 可以 import:
import "example.com/project/internal/user"
但另一个项目不能 import:
import "example.com/project/internal/user" // use of internal package not allowed
所以 internal 的核心不是“里面一定要再分 app 和 pkg”,而是:这部分代码属于当前父目录树,不承诺成为外部 API。
这也是它和顶层 pkg 的关键差别。pkg 只是社区约定,表示“这里的代码可以被外部项目使用”;internal 则会被 Go 命令真正检查。
可以参考 Go 官方文档里的说明:Internal Directories,以及 Go 1.4 引入 internal 包机制的发布说明:Go 1.4 Release Notes。
internal/app 和 internal/pkg 的问题
internal/app、internal/pkg 这套分法来自一种常见项目布局。比如 golang-standards/project-layout 里提到,可以把实际应用代码放到 /internal/app,把多个应用共享的内部库放到 /internal/pkg。
这种写法不是错。小项目、模板项目、只有一个服务的项目,用它没有太大问题。
真正的问题出现在项目继续变大之后。
假设一个项目开始只有一个 API 服务:
internal/
├── app/
│ └── api/
└── pkg/
├── config/
└── logger/
后来又加了 worker、admin、scheduler、billing、notification。目录可能变成:
internal/
├── app/
│ ├── api/
│ ├── admin/
│ ├── worker/
│ └── scheduler/
└── pkg/
├── config/
├── logger/
├── database/
├── cache/
├── auth/
├── billing/
└── notification/
这时 internal 顶层仍然只有两个目录,但它没有表达项目里真正的组件。你必须继续进入 app 和 pkg,才能知道这个系统里到底有哪些东西。
更麻烦的是,pkg 这个名字太宽了。一个包只要被两个地方用到,就容易被扔进 internal/pkg。久而久之,internal/pkg 会变成“内部共享代码”的集合,但“共享”并不是一个稳定的领域边界。今天共享的是 logger,明天共享的是 user,后天共享的是 billing 规则。目录名无法告诉你这些包为什么存在,也无法阻止依赖关系变乱。
这就是 internal/app 和 internal/pkg 的主要风险:它们按代码使用方式分类,而不是按系统边界分类。
更可扩展的方式:让 internal 直接承载组件
如果一个项目由一个或多个组件组成,可以让 internal 下面直接出现这些组件或领域包:
cmd/
├── api/
│ └── main.go
├── worker/
│ └── main.go
└── scheduler/
└── main.go
internal/
├── api/
│ ├── handler/
│ └── server/
├── worker/
│ └── consumer/
├── scheduler/
│ └── job/
├── user/
│ ├── service.go
│ └── repository.go
├── billing/
│ ├── invoice.go
│ └── payment.go
└── platform/
├── config/
├── database/
└── log/
这里有几个变化:
cmd仍然只放入口,main.go尽量薄,只负责组装配置、依赖和启动流程。internal/api、internal/worker、internal/scheduler表示不同运行组件的内部实现。internal/user、internal/billing表示业务领域或模块。internal/platform放跨组件的基础设施能力,比如配置、日志、数据库连接。
这样做的好处是:项目增加一个组件时,internal 可以直接增加一个对应目录。目录本身会随着系统增长而增长。
比如新增一个 notification worker,可以自然变成:
cmd/
└── notification-worker/
└── main.go
internal/
├── notification/
│ ├── sender.go
│ └── template.go
└── notificationworker/
└── consumer.go
这个结构比“全部塞到 internal/app 和 internal/pkg”更容易看出项目正在变成什么。
app 和 pkg 不是不能用,而是不要让它们成为默认答案
有些场景下,app 这个名字仍然有意义。
比如一个项目就是一个服务,内部结构很简单:
cmd/
└── server/
└── main.go
internal/
└── app/
├── app.go
├── handler.go
└── service.go
这时 app 表达的是“整个应用的组装对象”,不是一个长期承载所有业务代码的二级根目录。它只是一个小项目里的简单命名。
pkg 也不是绝对不能出现。问题是要先问清楚:这个包真的要作为外部 API 提供给其他项目 import 吗?
如果答案是否定的,优先放在 internal 里。如果只是多个内部组件复用,不一定要叫 pkg,可以叫更具体的名字:
internal/
├── platform/
│ ├── config/
│ ├── database/
│ └── log/
└── user/
└── service.go
platform 比 pkg 多表达了一层意思:这里放的是支撑业务运行的基础设施能力,而不是“任何共享代码”。
看几个开源项目
Go 官方源码本身就是一个很好的例子。go 命令的内部实现放在 cmd/go/internal 下,里面直接按能力拆出 load、modload、work、run、cache、vcs 等包,而不是统一塞进 app 和 pkg。
这说明 internal 可以出现在更深的位置。cmd/go/internal 的可见范围比仓库根目录下的 internal 更窄,它表达的是“这些包主要服务于 go 这个命令”。
GitHub CLI 也是类似思路。它有 cmd、pkg,也有 internal。在 internal 下面可以看到 config、browser、ghrepo、prompter、safepaths 等面向具体能力的包。它没有强迫所有内部实现都先进入 internal/app 或 internal/pkg。
GoReleaser 的 internal 更明显:里面有 archive、announce、upload、pipeline、middleware、tmpl、yaml 等包。它按发布流水线里的能力来拆分,而不是先问“这是 app 还是 pkg”。
这些例子不是说所有项目都应该照抄它们,而是说明一点:成熟 Go 项目里的 internal 往往是按实际边界生长出来的。internal/app、internal/pkg 只是其中一种模板,不是规则本身。
一个简单判断标准
设计 Go 项目目录时,可以先问三个问题:
- 这段代码是否允许外部项目 import?
- 这段代码服务于哪个运行组件、业务领域或基础设施能力?
- 新增一个组件时,目录结构能不能自然增加,而不是继续往某个大目录里堆?
如果代码不允许外部项目 import,就放进 internal。
如果它属于某个组件,就让组件成为目录:
internal/api
internal/worker
internal/scheduler
如果它属于某个业务领域,就让领域成为目录:
internal/user
internal/order
internal/billing
如果它是基础设施能力,就给它一个具体的基础设施边界:
internal/platform/config
internal/platform/database
internal/platform/log
最后再考虑是否需要 app 或 pkg。不要反过来,一开始就把所有代码塞进 app 和 pkg,再让真实的系统边界躲在第二层、第三层。
Go 的目录结构没有唯一标准。真正重要的是:目录能不能表达代码的访问边界、业务边界和增长方式。internal 的价值也在这里。它不是为了让项目看起来“标准”,而是为了把不该暴露的代码关在合适的边界里。