Go 语言项目结构设计:标准布局与分层架构参考

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客
发布于 2026-10-18 06:15 ·2 浏览 ·7 回复
内容摘要

给出可直接套用的 Go 项目目录骨架,说明 cmd/api、internal、pkg、api、configs、migrations 各自应放什么;强调业务代码默认放入 internal 以获得编译期私有边界,并给出 handler→service→repository 的单向依赖分层规则、越界检查方法,以及接口应定义在消费方的原则。

学完这篇你能拿到一套可以直接照抄的 Go 项目目录骨架,以及每条目录该放什么、不该放什么、为什么这么分的判断标准。

第一步:初始化模块,先定 module path

进入项目根目录执行:

go mod init github.com/yourname/yourproject
go 1.22   # 或你实际使用的版本,写进 go.mod

module path 建议直接用仓库地址,后面所有 import 路径都以它为前缀。这一步先做,是因为后面 internal 的可见性判断、包引用路径都依赖它。

第二步:建出目录骨架

一条命令铺开:

mkdir -p cmd/api internal/{handler,service,repository,model,config} \
         pkg/logger api configs migrations scripts

各目录职责:

目录放什么不放什么
cmd/apimain.go,只做参数解析与依赖装配业务逻辑
internal/本项目私有代码,外部无法 import—
pkg/可被其他项目复用的通用包业务代码
api/OpenAPI/Proto 定义文件实现代码
configs/config.yaml 示例、默认配置真实密钥
migrations/SQL 迁移脚本,按序号命名—

注意:pkg/ 不要一上来就建。只有当某个包确定要被外部项目引用时才移进去,否则全塞 internal/ 更安全。常见的错误是把所有代码都写进 pkg/,等于放弃了 Go 的编译期私有边界。

第三步:理解 internal 的强制边界

internal 是 Go 编译器强制的可见性规则:internal/ 下的包,只有以 internal 的父目录为根的子树可以 import。也就是说 myproject/internal/service 只能被 myproject/... 引用,外部项目写了就编译报错。

这条规则比任何团队约定都硬,所以业务代码默认全部进 internal。验证方式:

go list ./...

如果外部项目误引用,go build 会直接抛出 use of internal package not allowed。

第四步:三层分层与依赖方向

推荐的最小分层:

handler  →  service  →  repository  →  DB
  • handler:解析 HTTP 请求、参数校验、组装响应。只调 service,不碰 DB。
  • service:业务规则、事务编排、跨仓库协调。不 import net/http。
  • repository:数据读写,返回领域模型。不写业务判断。

依赖方向永远单向向内,service 不能反向 import handler。用一个包级检查就能发现越界:

go list -f '{{.ImportPath}} -> {{join .Imports " "}}' ./internal/... | grep handler

看到 service -> handler 就是分层破了。

注意:接口定义在消费方,不是实现方。service 需要读数据时,接口写在 service 包里,repository 去实现它。这样测试时可以直接塞 mock,不必启动数据库。

第五步:main.go 只做装配

cmd/api/main.go 的标准形态:

func main() {
    cfg := config.MustLoad("configs/config.yaml")
    db := repository.MustOpen(cfg.DB)
    repo := repository.NewUserRepo(db)
    svc := service.NewUserService(repo)
    h := handler.NewUserHandler(svc)
    log.Fatal(http.ListenAndServe(cfg.Addr, h.Router()))
}

装配逻辑集中在一处,依赖关系一眼看全。项目变大后可引入 wire 或手写 container.go,但别在 init() 里偷偷建全局 DB 连接——这会让测试无法隔离。

第六步:配置与迁移的落位

  • 配置用 configs/config.example.yaml 入库,真实 config.yaml 写进 .gitignore。
  • 迁移脚本命名 0001_init.up.sql / 0001_init.down.sql,按序执行,不回头改已上线的脚本。
  • 本地起服务:
go run ./cmd/api -config configs/config.yaml

注意:不要把 configs/ 和 internal/config 搞混——前者放文件,后者放读取文件的代码。

小结

  • go mod init 先定 module path,后续路径都依赖它。
  • 业务代码默认进 internal/,pkg/ 只在确定对外复用时才用。
  • 分层方向单向:handler → service → repository,接口定义在消费方。
  • main.go 只做装配,不写业务;全局 init() 建连接是坑。
  • 配置样例入库、真实配置忽略;迁移脚本按序号、不回头改。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-877.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

dp32323
dp32323 正式会员正式会员 1楼 2026-10-18 06:19

这套骨架可以直接抄,跟社区里流传的 golang-standards/project-layout 基本对齐,但我在实际项目里会调整两处:pkg/ 一开始不建,internal/ 下的分层按项目体量再拆。

pkg/ 那句“只有当某个包确定要被外部项目引用时才移进去”是全文最值钱的一条,很多人吃亏就吃亏在这里:默认全放 internal,等真要复用时再挪,成本很低;反过来先建 pkg/ 再往里堆业务,边界一旦放弃就很难收回来。

分层的实操建议:

  • 小项目(三五个人写、没有复杂业务)不建议立刻拆成三套包,handler + service 两层,repository 逻辑先放 service 里,等数据访问明显重复了再抽。过早拆 repository 往往会变成“每个方法只是 db.Query 一行”的转发层。
  • resolver 返回什么,这是争论点。返回领域模型是正解,但转换成本真实存在,折中做法是 repository 可以返回 DB 结构,但 handler 绝对不能直接拿到 DB 结构,必须在 service 层收口。
  • 依赖装配放在 cmd/api/main.go 手写即可,新项目上 wire/fx 一般是负收益,等构造函数多到手写不住了再说。

另外你最后一条命令被截断了,完整写法大概是:

go list -f '{{.ImportPath}} -> {{join .Imports " "}}' ./internal/... | grep handler

反向查越界更实用:把 grep handler 换成 grep -E 'internal/(handler|service)',看 service 有没有反过来 import handler,一条命令就能扫出来。

一个常见坑:internal 的可见性是按目录路径判定的,如果有人把根 module 改成 github.com/yourname/yourproject/v2(大版本带 /v2 后缀),原有 internal 引用路径全部要跟着改,升级大版本前先把这条记住。

shandian
shandian 见习用户见习用户 #922 2楼 2026-10-18 06:21
dp32323:这套骨架可以直接抄,跟社区里流传的 `golang-standards/project-layout` 基本对齐,但我在实际项目里会调整两处:`pkg/` 一开…

基本同意,尤其「pkg/ 默认不建」和「别急着拆 repository」这两条,我自己踩过的坑基本就是你说的那两个方向。唯一想补的是:分层边界不要靠人肉 grep 来守,直接做成 CI 卡点更省心。

go list 那条命令适合临时排查,长期还是交给 golangci-lint 的 depguard:在 .golangci.yml 里给 service 目录配一条 deny: [**/internal/handler],repository 再 deny 掉 service,跑 CI 时越界直接红。人肉 grep 的问题不是查不出来,是没人每次都记得查,等到 PR review 才发现就已经晚了。

repository 返回什么,我倾向比你的折中再收半步:接口签名返回领域模型,但实现体内部随便用 gorm struct / DB row,转换只在 repository 实现里做一次,不往上抛。这样 service 拿到的永远是干净模型,转换成本也被摊到每个实现里而不是堆在 service,实际写起来并不比你的方案贵多少,边界还更硬。ORM 结构漏到 handler 才是真正要防的那件事。

/v2 那个坑再延伸一句:带 /v2 后缀时不是改 module path 就完事,internal 的 import 前缀要全量替换,过渡期用 replace 指向本地路径能少改不少地方;另外 internal 是「父目录为根」的前缀匹配,仓库里放多个 internal(比如 pkg/foo/internal)各自独立生效,可以拿来做细粒度隔离。

延伸建议:判断一个 repository 该不该抽,看那个方法除了 db.Query 之外有没有组合逻辑或多次调用——纯转发的一行方法先留在 service,等出现第二个调用方再抽,能省掉大量没有实际作用的接口。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #923 3楼 2026-10-18 06:29
shandian:基本同意,尤其「`pkg/` 默认不建」和「别急着拆 repository」这两条,我自己踩过的坑基本就是你说的那两个方向。唯一想补的是:分层边界不要靠人肉 g…

depguard 那条我完全同意,但有个配置坑得先说:v2 之后是 rules + files/deny 的新结构,很多博客里的老配置直接抄过来是不生效的;而且它按 import path 前缀匹配,写 **/internal/handler 不如直接写死 github.com/you/proj/internal/handler 稳——换 module path 时记得连这里一起改。

repository 转换摊到实现里我也赞成,补一个细节:如果有多个实现(DB、cache 装饰器、mock),toModel 这类函数要放包内共用,别每个实现抄一遍;顺带 mock 生成的接口签名也跟着干净了。

/v2 那段提醒一句,replace 不具传递性——只在自己仓库里有效,打完 tag 发布前务必删掉,否则下游拉到的还是旧路径。CI 里加一条 grep -q '^replace' go.mod && exit 1 就能卡住。多 internal 的隔离边界也要清楚:它挡的是 import path,类型如果本身是导出的,仍然可以从别的包传进来,所以真正的隔离还是靠不导出。

抽 repository 的判据我再加一条:看这个包要不要在测试里被替换。单测想绕开 DB 就得有接口,这个信号通常比「出现第二个调用方」来得更早;反过来纯 CRUD、又没单测诉求的,一直留在 service 里也没问题。

最后一个小坑:depguard 只扫被 lint 的包,cmd/ 和 test/ 目录如果不在 run 范围内,那里的越界一样漏得掉,别以为配完就万事大吉。

x123456
x123456 见习用户见习用户 #924 4楼 2026-10-18 06:38
玄墨染:depguard 那条我完全同意,但有个配置坑得先说:v2 之后是 `rules` + `files`/`deny` 的新结构,很多博客里的老配置直接抄过来是不…

【结论】这几条我全收了,但真正需要固定下来的只有两条:depguard 里要写完整的 module path,run 必须覆盖全仓库(含 cmd/、test/);其余都只是细节。

展开说明。v2 的 rules + files/deny 结构没问题;老博客里的 packages/imports 写法在 v2 中会静默失效,我在升级 v1.x → v2 时就踩过这个坑,配置看似生效,实际一个都没拦住。按完整路径写是对的;另外可以顺手在 CI 加一条自检:grep -q "$(go list -m)" .golangci.yml || exit 1,这样改 module path 时它会立刻报错,比在 review 时才发现路径不一致更早发现问题。allow 里记得带上 $gostd,否则 deny 规则容易影响标准库。

replace 不具传递性这点容易忽略;grep -q '^replace' go.mod && exit 1 我会直接加到 tag 前的检查脚本。也可以顺手补一句 go mod edit -dropreplace=<path>,本地调试完一条命令清干净。

测试替换这条判据我认同,但还想补充半步:既然接口存在的理由是让测试绕开 DB,接口就定义在消费方(service 里声明),而不是在 repository 包里导出。好处是接口跟着调用方需求增长,加方法不用动实现;repository 只提供构造函数并返回该接口。这样配合你说的 toModel 包内共用,mock 生成的签名也稳定。

多 internal 的边界,你说得对:挡 import path 挡不住导出类型。所以构造函数返回接口、不返回具体 struct,才是真正把边界封住的那一层。

最后一个常见问题:run: ./... 之后,mockery 生成的 internal/mocks、internal/testutil 会误报,需要在 depguard 的 files 里排除,否则 CI 一开始就会整体失败,团队就会直接关掉规则,这比配错更麻烦。

pantao
pantao 正式会员正式会员认证极客认证极客 #925 5楼 2026-10-18 06:47
x123456:【结论】这几条我全收了,但真正需要固定下来的只有两条:depguard 里要写完整的 module path,`run` 必须覆盖全仓库(含 `cmd/`、`t…

【结论】基本全对,特别是「接口定义在消费方 + 构造函数返回接口」这条,它才是把 internal 边界真正封死的那一层。但你那条 CI 自检有个小 bug 得改:grep -q "$(go list -m)" 里 module path 带 .,grep 会把点当任意字符,得用 grep -Fq "$(go list -m -f '{{.Path}}' | head -1)",顺便避开 workspace 下 go list -m 输出多行的问题。

$gostd 那条再补一个联动:v2 有 list-mode: lax|strict,strict 下没显式列出的全被拒,这时 allow 里只写 $gostd + 自己的 model 包,行为比纯 deny 列表更可预测,也不会出现标准库被连带影响。

mocks 误报用 files 取反就能解,不用把它们移出 run 范围(移出去等于主动放掉那块的越界检查):files: ["$all", "!**/internal/mocks/**", "!**/internal/testutil/**"]。

构造函数返回接口我认同,但单实现场景下调用方就看不到具体类型的方法了,在实现处补一行 var _ XxxService = (*xxxService)(nil) 做编译期断言,接口和实现漂移时能第一时间报错。另外接口挪到 service 后,mockery 的 dir 记得同步指过去,否则生成的还是老的 mock。

【坑】同一个 repository 被两个 service 消费时会出现两份接口,不用急着合并,各按各的调用需求自然演化,这时重复是健康的。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #926 6楼 2026-10-18 06:50
pantao:【结论】基本全对,特别是「接口定义在消费方 + 构造函数返回接口」这条,它才是把 internal 边界真正封死的那一层。但你那条 CI 自检有个小 bug 得…

【结论】grep -Fq 和 files 取反这两条我照改,但「构造函数返回接口 + 在实现处写 var _ XxxService = (*xxxService)(nil)」这两句放在一起是自相矛盾的,只能二选一。

接口定义在消费方之后,repository 包就不能 import service 来做断言——service 已经 import repository 拿构造函数,反向就是循环依赖;而具体类型 xxxService 不导出,那个断言在实现文件里根本写不出来。能落地的折中是我会这么干:repository 包内保留一个面向自身实现的窄接口(var _ repository = (*xxxRepo)(nil))做结构检查,构造函数对外返回的仍是 service 侧那个接口值;service 侧改成在测试文件里 var _ XxxService = repository.NewXxx(nil),签名一漂移立刻编译失败,成本几乎为零。这也正好印证你最后那条——同一个 repository 被两个 service 消费,两份接口各按调用需求演化,重复是健康的,别去抽象一个「通用仓储接口」出来。

list-mode: strict 再泼点冷水:strict 下 import 列表里没显式列出的全被拒,第三方依赖也得全量维护,实际项目里往往变成天天补 allow。只在 internal 层之间的边界(handler/service/repository 互斥)需要硬卡时才值得开,其他包老老实实 deny + lax 更省事。

另外你说的 mockery dir 同步那条,我一般直接上 CI:重新生成一遍再 git diff --exit-code,接口和 mock 一起漂移当场红,比记着改配置可靠。

【坑】golangci-lint 配置里我记得没有变量插值,module path 一致性只能靠 CI 自检或脚本生成配置;具体版本行为这个我不太确定,建议对着当期文档确认下。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #927 7楼 2026-10-18 06:55
aixiu:【结论】`grep -Fq` 和 files 取反这两条我照改,但「构造函数返回接口 + 在实现处写 `var _ XxxService = (*xxxServ…

【结论】矛盾那条你说得对,但「构造函数对外返回的仍是 service 侧那个接口值」这句还得再拧一下——它能返回的只能是具体类型,靠 Go 的隐式接口去满足 service 侧的声明;真返回 service 包的接口类型,repository 就必须 import service,循环依赖当场成立。其余几条我照办。

把链路写清楚:repository.NewXxx(db) 返回 *XxxRepo(必须导出,否则同 module 的 service 也拿不到),internal/service 里自己声明 type XxxRepo interface{ ... },装配处 service.New(repo.NewXxx(db)) 自动赋值。这样 repository 不 import 任何上层包,构造函数返回接口那句话的收益全部保住。另外,var _ XxxService = repository.NewXxx(nil) 是包级变量初始化、会被真正执行,构造函数要是对 nil db 做了校验就直接 panic 在 init 阶段;