Go项目应避免controller/service/dao分层,因其破坏包边界、导致网状依赖;应按业务能力划分internal子目录,同一包内按主题聚类文件,pkg/仅放稳定对外API。

Go 项目不需要“框架式多层架构”,强行套用 controller/service/dao 会破坏包边界、增加无谓耦合,反而让代码更难维护。
为什么 controllers/ 和 services/ 目录在 Go 里是坏信号
Go 的包系统天然按功能隔离,不是靠目录名伪装分层。当你看到一个项目有 controllers/、services/、repositories/ 这类目录,通常意味着:
- 包之间互相 import,比如
controllers直接调用repositories,但repositories又依赖models—— 实际形成网状依赖,而非单向分层 - 每个目录下塞一堆同质化文件(
user_controller.go、order_service.go),但它们之间没有共享接口或抽象,只是命名相似 - 测试时必须 mock 整条链路,哪怕只改一个字段校验逻辑,也要启一堆依赖
-
go list ./...输出几十个包,但其中一半只是空壳,没导出任何类型或函数
internal/ 下该按什么逻辑建子目录
以业务能力为单位,不是技术角色。例如一个交易机器人项目,应该有:
-
internal/tradebot/:封装tradeBot类型、生命周期、事件循环 -
internal/trade/:专注交易动作本身——acceptTrade()、declineTrade()、状态校验、错误分类 -
internal/steamapi/:只做一件事:把第三方 SDK(如go-steam)的裸 API 包装成可测试、带重试和日志的客户端
关键点:trade/ 不依赖 tradebot/,也不依赖 steamapi/;它只接收明确参数(如 tradeID string, items []Item),返回明确错误类型(如 ErrTradeNotFound)。依赖由上层组合注入,而非包内硬编码。
立即学习“go语言免费学习笔记(深入)”;
同一包里多个 .go 文件怎么拆
按主题聚类,不是按方法名拆。比如 internal/trade/ 下可以有:
-
actions.go:放acceptTrade()、declineTrade()、sendTrade()—— 它们共享输入校验、日志上下文、错误映射逻辑 -
validation.go:提取出validateItems()、validateCounterparty()等纯函数,不依赖外部状态 -
errors.go:定义var ErrInvalidItems = errors.New("items invalid"),供整个包使用
避免:accept_trade.go + decline_trade.go + send_trade.go —— 这三者共用大量前置检查,拆开后会导致重复代码或隐式耦合。
pkg/ 和 internal/ 的边界到底在哪
pkg/ 是你愿意写文档、加版本号、承诺向后兼容的模块;internal/ 是今天能跑、下周可能全删的实现细节。实际判断标准很直白:
- 如果另一个项目
import "yourproject/pkg/logger"能直接用,且你不打算在 v2 改logger.Info()签名 → 放pkg/ - 如果
internal/trade/里有个type TradeValidator struct{},只被tradebot用,且内部用了未公开的 Steam 协议字段 → 必须留在internal/ -
go build时不会检查internal/是否被外部引用,但一旦有人绕过限制 import 了,go工具链会在构建时报错 —— 这是编译器强制的封装,不是约定。
真正容易被忽略的是:很多团队把“未来可能复用”的代码提前放进 pkg/,结果没人用、没人维护、接口越改越碎。不如等第一个外部调用出现后再提,更稳。


















