面向已经会写 Go、但想把项目”组织好”的读者:读完你应该能独立设计一个可维护、可测试、可演进的企业级 Go 服务目录,并知道每个决定背后的取舍。 本文只讲 工程结构与设计规范,示例都是与具体业务无关的通用骨架,便于直接套用到你自己的项目。 如果你还不熟悉 Go 语法本身,建议先看《Go 语言基础》那篇;本文默认你已经理解包、接口、error、context、goroutine 等概念。
目录
共 22 节 · 点此折叠 / 展开
一 · 结构总纲 —— 目录为什么这么长、从哪开始
二 · 顶层目录 —— cmd / internal / pkg 三个入口的取舍
三 · 分层与依赖 —— 领域为核心,依赖只向内
四 · 逐层详解 —— 每一层放什么、不放什么
五 · 横切关注点 —— 错误、日志、并发、依赖注入怎么落位
六 · 工程实践 —— 测试、多服务、构建发布
七 · 收束与参考 —— 避坑、照抄骨架、自查清单
1. 先讲清楚:目录结构是给谁看的
很多人一上来就问”Go 项目应该长什么样”,然后照抄一个 project-layout 仓库,把 cmd/、pkg/、api/、build/、deployments/ 一股脑建出来——哪怕项目里只有三个文件。这是本末倒置。
目录结构服务于三个目标,重要性从高到低:
- 表达依赖方向:一眼看出谁依赖谁,哪一层是核心,哪一层是可替换的外壳。
- 约束可见性:用
internal/让不该被外部依赖的代码在编译期就无法被导入。 - 降低定位成本:改一个功能时,知道该去哪个包找、该在哪个包加。
反过来说,目录结构不应该照搬别的语言的习惯(比如 Java 的 com/company/project/service),也不应该按”技术类型”一刀切成 models/、controllers/、utils/。
一句话原则:按”依赖方向”和”业务能力”组织包,而不是按”技术分类”或”文件类型”组织包。
小项目的正确形态可能就是一个 main.go 加几个包。结构应该随复杂度生长,而不是一开始就预支所有目录。 本文后面给的完整骨架是”长大之后”的样子,不是”第一天”的样子。
2. 一个 module 的骨架
一个仓库通常就是一个 module,go.mod 里的 module path 是所有内部 import 的前缀:
module github.com/acme/orders
go 1.22
那么 internal/domain 包的导入路径就是 github.com/acme/orders/internal/domain。module path 建议用真实可访问的域名/仓库路径,即使暂时不公开——这样将来拆分、被别的仓库依赖时不用改一堆 import。
一个中等规模服务的顶层长这样:
orders/
├── go.mod
├── go.sum
├── cmd/
│ ├── orders/ # 主服务入口
│ │ └── main.go
│ └── migrate/ # 附带的小工具入口(可选)
│ └── main.go
├── internal/ # 私有代码,外部 module 无法导入
│ ├── domain/ # 领域类型与缝合点接口
│ ├── service/ # 业务编排
│ ├── repository/ # 外部状态访问(DB、缓存、下游 HTTP)
│ ├── transport/ # 接入层(HTTP/gRPC handler、中间件)
│ ├── config/ # 配置加载与强类型结构
│ └── platform/ # 基础设施封装(db 连接、logger、metrics)
├── api/ # 对外契约(OpenAPI/proto),可选
├── configs/ # 各环境配置模板(不含真实密钥)
├── deployments/ # Dockerfile、k8s manifest、compose(可选)
├── scripts/ # 构建/运维脚本(可选)
├── Makefile
└── README.md
不要被吓到——api/、deployments/、scripts/ 都是可选的,只在真的需要时建。核心永远是 cmd/ + internal/。
3. cmd:程序入口只做装配
cmd/<name>/main.go 是可执行程序的入口。它唯一的职责是”装配”(wiring):读配置、建依赖、把各层连起来、启动、优雅退出。 业务逻辑一行都不该出现在这里。
一个典型的 main.go:
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/acme/orders/internal/config"
"github.com/acme/orders/internal/platform/database"
"github.com/acme/orders/internal/repository"
"github.com/acme/orders/internal/service"
"github.com/acme/orders/internal/transport/httpapi"
)
func main() {
if err := run(); err != nil {
slog.Error("service exited with error", "error", err)
os.Exit(1)
}
}
// run 把装配和运行集中在一处,返回 error 而不是到处 log.Fatal,
// 这样 defer 能正常执行,退出码也统一在 main 里处理。
func run() error {
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
cfg, err := config.Load()
if err != nil {
return err
}
logger := newLogger(cfg.LogLevel)
db, err := database.Open(ctx, cfg.Database)
if err != nil {
return err
}
defer db.Close()
// 自底向上装配:repository → service → handler。
orderRepo := repository.NewOrderRepo(db)
orderSvc := service.NewOrderService(orderRepo, logger)
handler := httpapi.NewRouter(orderSvc, logger)
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
return serve(ctx, srv, logger)
}
main 里为什么要抽一个 run() error?因为 main 不能返回 error,如果直接在 main 里到处 log.Fatal,那些 defer(关闭 DB、flush 日志)就不会执行。用 run() error 让所有清理逻辑走 defer,退出码只在 main 里定一次。
cmd/ 下可以有多个子目录,每个是一个独立二进制:主服务、数据迁移工具、离线任务、压测客户端等。它们共享 internal/ 里的代码。
4. internal:企业级项目的默认落脚点
Go 工具链对 internal/ 有特殊规则:internal/ 目录下的包,只能被”以该 internal 的父目录为根”的代码导入。 换句话说,github.com/acme/orders/internal/... 只能被 github.com/acme/orders/... 自己导入,任何别的 module import 它都会编译失败。
这带来一个非常重要的工程收益:你在 internal/ 里的任何东西都不是公开 API,可以随意重构、改签名、挪包,不用担心破坏别人。
所以企业级服务的默认策略是:除非你确定某段代码要作为公共库给别的 module 用,否则一律放 internal/。 这让你的”公开表面积”默认为零,把演进的自由度留给自己。
internal/ 内部可以再分层(见第 6 章)。也可以再嵌套 internal/:internal/service/internal/xxx 会把 xxx 的可见性进一步收窄到只有 service 子树能用。这个技巧在大仓库里用来防止跨子系统的越界依赖。
5. pkg:到底要不要建
pkg/ 是社区争议最大的目录。它的本意是”放可被外部 module 导入的公共代码”。但现实是:
- 大多数业务服务根本没有对外公开的库,全放
internal/就够了。 - 无脑建
pkg/然后把所有东西塞进去,等于什么都没约束——它和直接放根目录没区别。
建议:
- 如果你的仓库是纯服务(只产出二进制,不被别的 module 依赖):不要建
pkg/,全用internal/。 - 如果你确实要对外提供可复用库(SDK、客户端、共享协议类型),才把那一部分放
pkg/,其余仍在internal/。 pkg/里的东西是公开契约,改动要走版本管理(语义化版本、废弃周期),心智负担比internal/高得多。
一个常见误区是建 pkg/utils 或 internal/common。不要建 utils/common/helpers/base 这种没有信息量的桶。按职责命名:日期工具叫 timex,字符串工具叫 stringx,HTTP 辅助叫 httpx。名字要能回答”这个包负责什么”。
6. 分层架构:领域为核心的四层
企业级服务最实用的组织方式,是围绕”领域”做单向分层。一个经过大量项目验证的四层结构:
┌─────────────────────────────────────────┐
│ transport(接入层) │ HTTP/gRPC handler、中间件、序列化
├─────────────────────────────────────────┤
│ service(应用/编排层) │ 用例编排、事务边界、权限
├─────────────────────────────────────────┤
│ domain(领域层) │ 实体、值对象、领域规则、缝合点接口
├─────────────────────────────────────────┤
│ repository / adapter(基础设施层) │ DB、缓存、消息、下游服务
└─────────────────────────────────────────┘
对应到目录:
internal/
├── domain/ # 最内层,不依赖其他内部包
│ ├── order.go # Order 实体、OrderStatus 枚举
│ ├── errors.go # 领域错误(ErrOrderNotFound 等)
│ └── repository.go # OrderRepository 接口(缝合点,定义在这里)
├── service/
│ └── order_service.go
├── repository/
│ ├── postgres/
│ │ └── order_repo.go # 实现 domain.OrderRepository
│ └── redis/
│ └── cache.go
└── transport/
└── httpapi/
├── router.go
├── order_handler.go
└── middleware.go
关键在于依赖只能从外向内:
transport依赖service和domain。service依赖domain(以及domain里定义的接口)。repository实现domain里定义的接口,但上层不直接依赖repository的具体类型。domain谁都不依赖(除了标准库和极少数纯类型包)。
这不是”必须四层”。小项目 domain + service + transport 三层就够,repository 甚至可以先并进 service。层数随复杂度增长,不要为了”标准”而制造空壳层。
7. 依赖方向与依赖倒置
分层的灵魂是依赖方向单向、且指向核心。而”核心不能依赖外壳”和”业务要调用数据库”这对矛盾,靠依赖倒置(DIP) 解决:
接口定义在需要它的那一层(消费方),实现放在外层。上层依赖接口,下层实现接口,依赖方向因此被”倒置”过来指向核心。
具体做法:service 需要读写订单,它不 import repository/postgres,而是依赖一个定义在 domain(或 service 自己包里)的接口:
// internal/domain/repository.go
package domain
import "context"
// OrderRepository 是持久化订单所需的缝合点。
// 它定义在领域层,由基础设施层实现,从而让依赖方向指向领域。
type OrderRepository interface {
Save(ctx context.Context, o *Order) error
FindByID(ctx context.Context, id OrderID) (*Order, error)
}
service 只认这个接口:
// internal/service/order_service.go
package service
import "github.com/acme/orders/internal/domain"
type OrderService struct {
repo domain.OrderRepository // 依赖接口,不依赖具体实现
log *slog.Logger
}
func NewOrderService(repo domain.OrderRepository, log *slog.Logger) *OrderService {
return &OrderService{repo: repo, log: log}
}
repository/postgres 提供实现,并在编译期断言自己满足接口:
// internal/repository/postgres/order_repo.go
package postgres
import "github.com/acme/orders/internal/domain"
// 编译期断言:签名不符会在这里立刻报错,而不是等到 main 装配。
var _ domain.OrderRepository = (*OrderRepo)(nil)
type OrderRepo struct {
db *sql.DB
}
func (r *OrderRepo) Save(ctx context.Context, o *domain.Order) error { /* ... */ }
于是只有 main 知道”这次用的是 postgres 实现”——它在装配时把具体实现注入进 service。换掉数据库、加一层缓存、在测试里用内存实现,都不用动 service 和 domain 一行代码。
关于接口该放哪:Go 社区的强约定是接口放消费方。如果只有 service 用这个接口,放 service 包也完全可以;放 domain 的好处是多个上层都能共享同一份领域契约。不要犯”接口放实现方包里”的错——那会让消费方被迫 import 实现包,依赖方向就反了。
同样重要的一条:不要为了”将来可能有第二个实现”就给每个类型都提前抽接口。 先写具体类型,等真的出现第二个实现、或明确需要一个测试缝合点时,再抽接口。过早抽象出来的一堆 XxxInterface + 单一 XxxImpl 是 Java 味最重的 Go 反模式。
8. domain:最内层的领域模型
domain 包放纯粹的业务类型和规则,它是整个系统的”词汇表”。特征:
- 不 import 任何其他内部包,不 import 数据库/HTTP/框架。
- 只依赖标准库和极少数纯类型第三方库(如
decimal、uuid)。 - 放实体、值对象、领域枚举、领域错误、缝合点接口。
// internal/domain/order.go
package domain
import (
"time"
"errors"
)
type OrderID string
type OrderStatus int
const (
OrderStatusPending OrderStatus = iota
OrderStatusPaid
OrderStatusShipped
OrderStatusCancelled
)
type Order struct {
ID OrderID
UserID string
Amount int64 // 以最小货币单位(分)存,避免浮点
Status OrderStatus
CreatedAt time.Time
}
// Cancel 把领域规则放在领域对象上:只有未发货订单可取消。
func (o *Order) Cancel() error {
if o.Status == OrderStatusShipped {
return ErrOrderAlreadyShipped
}
o.Status = OrderStatusCancelled
return nil
}
领域错误集中定义,供上层用 errors.Is 判断:
// internal/domain/errors.go
package domain
import "errors"
var (
ErrOrderNotFound = errors.New("order not found")
ErrOrderAlreadyShipped = errors.New("order already shipped")
)
为什么把规则放在领域对象上而不是 service 里? 因为”未发货才能取消”是订单固有的不变式,任何调用方都必须遵守。把它放在 Order.Cancel() 上,就没人能绕过它。service 负责编排(查订单、调 Cancel、存回去),domain 负责规则本身。这就是”充血领域模型”的核心思想——别把领域对象退化成只有字段的 DTO,让规则散落在各个 service 里。
9. repository:把外部状态藏在接口后
repository(也叫 adapter/gateway)层封装一切”外部状态”:数据库、缓存、消息队列、下游 HTTP 服务。原则:
- 对外只暴露
domain接口,不泄漏底层细节:调用方拿到的是*domain.Order,不是sql.Rows或 ORM 的实体。 - 在这里做领域模型 ↔ 存储模型的转换:数据库表结构和领域模型不必一一对应。
- 把存储特有的错误翻译成领域错误:比如
sql.ErrNoRows翻成domain.ErrOrderNotFound。
// internal/repository/postgres/order_repo.go
package postgres
func (r *OrderRepo) FindByID(ctx context.Context, id domain.OrderID) (*domain.Order, error) {
const q = `SELECT id, user_id, amount, status, created_at FROM orders WHERE id = $1`
var row orderRow // 存储模型,字段对应表结构
err := r.db.QueryRowContext(ctx, q, string(id)).Scan(
&row.ID, &row.UserID, &row.Amount, &row.Status, &row.CreatedAt,
)
switch {
case errors.Is(err, sql.ErrNoRows):
// 把存储层错误翻译成领域错误,上层不必知道底层是 SQL
return nil, fmt.Errorf("find order %s: %w", id, domain.ErrOrderNotFound)
case err != nil:
return nil, fmt.Errorf("find order %s: %w", id, err)
}
return row.toDomain(), nil // 存储模型 → 领域模型
}
为什么要区分”存储模型 orderRow”和”领域模型 domain.Order”? 因为它们的变化原因不同:表结构因为索引、分库分表、字段冗余而变;领域模型因为业务规则而变。如果直接把领域对象拿去 ORM 映射,任何一次数据库优化都会污染领域层。在小项目里两者可以先合一,但一旦表结构开始为性能做妥协,就该拆开。
下游 HTTP 服务同理:包一个 client,输入输出都用领域类型,把重试、超时、熔断、协议解析全挡在这一层里,上层只看到”给我一个用户”这样的语义方法。
10. service:业务编排层
service(应用层/用例层)负责编排一个完整用例:校验输入、调领域对象、协调多个 repository、管理事务边界、发领域事件。它不含底层技术细节,也不含 HTTP/gRPC 的东西。
// internal/service/order_service.go
package service
func (s *OrderService) CancelOrder(ctx context.Context, id domain.OrderID) error {
order, err := s.repo.FindByID(ctx, id)
if err != nil {
return err // 领域错误原样上抛,让上层决定 HTTP 状态码
}
if err := order.Cancel(); err != nil { // 领域规则在领域对象里
return err
}
if err := s.repo.Save(ctx, order); err != nil {
return fmt.Errorf("cancel order %s: %w", id, err)
}
s.log.InfoContext(ctx, "order cancelled", "order_id", id)
return nil
}
service 的几条纪律:
- 一个方法对应一个用例,方法名用业务动词:
CancelOrder、PlaceOrder,不要Update、Process这种含糊的名字。 - 事务边界在 service 层划定:如果一个用例要改多张表,事务应该由 service 开启并传递,而不是每个 repository 各自开事务。常见做法是让 repository 接口接受一个”执行器”或用
WithTx包一层。 - 不返回 HTTP/gRPC 相关的东西:service 返回领域错误和领域类型,由 transport 层翻译成协议响应。
- ctx 一路透传:所有 I/O 都带上 ctx。
11. server / handler:接入层
transport(接入层)负责协议:把 HTTP/gRPC 请求解析成领域输入,调 service,再把结果和错误编码成协议响应。它是”最薄”的一层,不含业务规则。
// internal/transport/httpapi/order_handler.go
package httpapi
func (h *OrderHandler) Cancel(w http.ResponseWriter, r *http.Request) {
id := domain.OrderID(r.PathValue("id"))
err := h.svc.CancelOrder(r.Context(), id)
switch {
case err == nil:
w.WriteHeader(http.StatusNoContent)
case errors.Is(err, domain.ErrOrderNotFound):
writeError(w, http.StatusNotFound, "order_not_found", err)
case errors.Is(err, domain.ErrOrderAlreadyShipped):
writeError(w, http.StatusConflict, "already_shipped", err)
default:
h.log.ErrorContext(r.Context(), "cancel order failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal", nil)
}
}
这一层的关键设计点:
- 领域错误 → HTTP 状态码的映射集中在这里。service 返回语义化的领域错误,transport 决定它对外是 404 还是 409。这样同一个 service 既能被 HTTP 也能被 gRPC 复用。
- 请求/响应 DTO 独立于领域模型:对外 JSON 结构由 transport 层定义(
orderResponse之类),不要直接把domain.Order序列化出去——否则领域模型一改字段,API 契约就跟着变,还可能泄漏内部字段。 - 中间件(日志、认证、限流、recover、trace)挂在这一层,通过
http.Handler链式组合。 - panic recover 兜底放在最外层中间件,防止一个 handler 的 panic 拖垮整个进程。
12. config:强类型配置与密钥
配置是企业级项目最容易乱的地方。核心规范:
- 配置在启动时一次性加载进强类型结构体,
map[string]any绝不泄漏出 config 包。 - 每个可调参数都有安全默认值和校验,非法配置启动即失败(fail fast),不要等到第一次请求才崩。
- 密钥不进仓库,来自环境变量或密钥系统;仓库里
configs/只放占位符模板。
// internal/config/config.go
package config
type Config struct {
HTTPAddr string `env:"HTTP_ADDR" default:":8080"`
LogLevel string `env:"LOG_LEVEL" default:"info"`
Database DatabaseConfig
}
type DatabaseConfig struct {
DSN string `env:"DB_DSN,required"` // 密钥,只从环境变量来
MaxOpenConn int `env:"DB_MAX_OPEN" default:"20"`
Timeout time.Duration `env:"DB_TIMEOUT" default:"3s"`
}
// Load 读取环境/文件,校验后返回强类型配置;任何非法值都让启动失败。
func Load() (Config, error) {
var cfg Config
if err := envconfig.Process(&cfg); err != nil {
return Config{}, fmt.Errorf("load config: %w", err)
}
if err := cfg.validate(); err != nil {
return Config{}, fmt.Errorf("validate config: %w", err)
}
return cfg, nil
}
func (c Config) validate() error {
if c.Database.MaxOpenConn <= 0 {
return errors.New("DB_MAX_OPEN must be positive")
}
return nil
}
configs/ 目录放各环境的非密钥配置模板:
configs/
├── config.example.yaml # 带注释的完整示例,提交进仓库
├── config.dev.yaml # 开发默认值(无密钥)
└── config.prod.yaml # 生产结构(密钥用 ${DB_DSN} 占位)
为什么强调”启动即失败”? 因为配置错误是最廉价的错误——在部署时暴露远比在深夜的生产请求里暴露好。把校验放在 Load() 里,一个拼错的环境变量会让容器起不来、CI 直接红,而不是悄悄用了一个 0 值超时把线上打挂。
热更新的配置(feature flag、限流阈值)走另一条路:加载进一个不可变快照,用 atomic.Pointer 整体替换,而不是原地改正在被读取的结构。
13. 错误、日志与可观测性的落位
这三样东西”放哪一层”是企业项目的高频困惑,规范如下:
错误
domain定义领域错误(哨兵错误ErrXxx或错误类型XxxError)。repository把底层错误(sql.ErrNoRows、网络错误)翻译成领域错误或用%w包装。service一般原样上抛领域错误,只在需要时加上下文。transport把领域错误映射成协议响应码。- 全程用
%w包装保留错误链,用errors.Is/As判断,永远不比较err.Error()字符串。
日志
- 用结构化日志(
log/slog),全项目一个 logger,通过依赖注入传递,不要用包级全局 logger 到处log.Printf。 - 字段名 snake_case 且稳定(
order_id、user_id),方便日志系统检索。 - 级别语义:
Error要人介入;Warn是一次成功的降级;Info是生命周期事件(启动、关闭、配置加载);单请求成功用Debug或采样。 - logger 通过 ctx 或参数传递,用
InfoContext(ctx, ...)带上 trace ID 等请求范围字段。
指标 / trace
- 封装在
platform/下(platform/metrics、platform/tracing),通过中间件和依赖注入接入。 - 指标标签基数要有界:
request_id、user_id这类高基数值不能做标签,否则会把监控系统打爆。 - 每个外部依赖(DB、缓存、下游)都要有延迟、错误率、超时指标。
落位原则一句话:可观测性是横切关注点,用中间件 + 依赖注入接入,不要污染领域和业务逻辑。 domain 和 service 的核心方法里不应该到处塞埋点代码。
14. 并发骨架:生命周期与优雅退出
企业级服务几乎都要跑后台 goroutine(消费队列、定时任务、批处理)。目录上通常放在 internal/worker 或对应业务包里,但骨架规范比放哪更重要:
- 每个 goroutine 都有明确的退出方式,通过
ctx取消或关闭的 channel,禁止”即发即忘”。 - 进程级生命周期由
main用信号派生的 ctx 统一管理,收到 SIGTERM 时所有子系统一起优雅退出。 - 请求路径上的扇出必须有界(worker 池或
errgroup带 limit),不能为每个任务开无限 goroutine。
优雅退出的骨架(接第 3 章的 serve):
func serve(ctx context.Context, srv *http.Server, log *slog.Logger) error {
errCh := make(chan error, 1)
go func() {
log.Info("http server listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done(): // 收到 SIGINT/SIGTERM
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx) // 停止接新连接,等在途请求做完
}
}
多个后台子系统时,用 golang.org/x/sync/errgroup 统一管理:它能在任意一个子系统返回错误时取消整个组的 ctx,并等所有子系统退出。这比手写一堆 WaitGroup + channel 清晰得多,是企业级服务管理并发生命周期的推荐骨架。
15. 依赖注入:手写装配 vs 框架
“依赖注入”在 Go 里不需要框架。最推荐的方式就是第 3 章那样的构造函数注入 + main 里手写装配。 每个组件用 NewXxx(deps...) 接收它的依赖(都是接口或具体类型),main 自底向上把它们拼起来。
db := database.Open(...)
repo := postgres.NewOrderRepo(db)
svc := service.NewOrderService(repo, logger)
handler := httpapi.NewOrderHandler(svc, logger)
手写装配的好处: 显式、可读、无魔法、编译期检查、易测试。看一眼 main 就知道整个系统怎么连起来的。对绝大多数服务,这就是终点。
什么时候考虑 google/wire 这类编译期 DI 工具?当依赖图大到”手写装配几百行、加一个依赖要改十几处”时。Wire 用代码生成产出的其实就是你会手写的那段装配代码,没有运行时反射,所以它只是帮你自动写 main 的一部分,而不是引入一个运行时容器。
要避免的是运行时反射式 DI 容器(把所有东西塞进一个 container.Get("xxx"))——它把编译期错误推迟到运行时,破坏了 Go “显式、可静态检查”的优势。宁可多写几行构造函数,也别引入这种黑箱。
16. 测试的目录与分层
Go 的测试文件(_test.go)和被测代码放同一个包同一个目录,这是语言约定,不要另建 tests/ 目录去镜像目录结构。分层规范:
单元测试:与代码同目录,不走网络、不连真实外部依赖。领域逻辑、service 编排都在这一层测。service 的测试用内存实现的 repository(fake),而不是 mock 一堆方法:
// internal/service/order_service_test.go
type fakeOrderRepo struct {
orders map[domain.OrderID]*domain.Order
}
func (f *fakeOrderRepo) FindByID(ctx context.Context, id domain.OrderID) (*domain.Order, error) {
o, ok := f.orders[id]
if !ok {
return nil, domain.ErrOrderNotFound
}
return o, nil
}
// ... Save 等
只在缝合点(接口)上做测试替身,不要去 mock 被测包的未导出函数。 一个写好的内存 fake 往往比自动生成的 mock 更好用、更能复用。
集成测试:连真实 DB/缓存的测试放单独文件并加构建标签,默认 go test 不跑,CI 显式开启:
//go:build integration
package postgres_test
go test ./... # 只跑单元测试
go test -tags=integration ./... # 跑集成测试
黑盒测试包:测试文件可以用 package foo_test(而不是 package foo),强制只通过导出 API 测试,这对验证公共契约很有用。
测试纪律:测试要确定性(不依赖真实时钟,把 time.Time 或 clock 接口注入进去);缺陷修复要附一个”修复前会失败”的测试;go test -race ./... 必须过。
17. 多服务仓库:monorepo 与 go.work
当一个仓库里要放多个服务或多个 module 时,有两种组织方式:
方式一:单 module + 多 cmd(推荐作为起点)
platform/
├── go.mod # 一个 module
├── cmd/
│ ├── orders/main.go
│ ├── payments/main.go
│ └── gateway/main.go
└── internal/
├── orders/ # 各服务的私有代码
├── payments/
└── shared/ # 服务间共享的内部代码
一个 module 管所有服务,依赖版本统一,重构跨服务共享代码很方便。中小团队优先选这个——简单、一致、没有版本地狱。
方式二:多 module + go.work(大型 monorepo)
当各服务需要独立的依赖版本、独立发布节奏时,才拆成多 module,用 Go 1.18+ 的 workspace(go.work)在本地把它们粘起来:
platform/
├── go.work
├── orders/
│ └── go.mod
├── payments/
│ └── go.mod
└── libs/
└── shared/
└── go.mod
// go.work
go 1.22
use (
./orders
./payments
./libs/shared
)
go.work 让本地开发时各 module 直接引用彼此的源码(不用发版),但它不应该提交给依赖你的外部用户(go.work 通常进 .gitignore 或只用于本地/CI)。
选择建议: 从单 module 开始,只有当”依赖冲突”或”独立发布”成为真实痛点时,再拆多 module。过早拆分会带来大量 replace、版本对齐、CI 复杂度的成本。
18. 构建、发布与配置文件
顶层的辅助目录,按需建:
.
├── Makefile # 统一命令入口:make build/test/lint/run
├── Dockerfile # 或放 deployments/
├── .golangci.yml # linter 配置
├── .github/workflows/ # CI 定义
├── api/ # OpenAPI / proto 契约(对外接口的单一事实来源)
├── deployments/ # k8s manifest、helm chart、compose
└── scripts/ # 一次性运维/构建脚本
一个务实的 Makefile 把常用命令固化下来,让新人和 CI 用同一套命令:
.PHONY: build test lint run
build:
CGO_ENABLED=0 go build -o bin/orders ./cmd/orders
test:
go test -race ./...
lint:
golangci-lint run
run:
go run ./cmd/orders
CI 门禁(每次合并前必须绿):
gofmt -l . # 必须无输出
go vet ./...
golangci-lint run
go build ./...
go test -race ./...
构建产物是单个静态二进制,配合多阶段 Dockerfile 能做出几 MB 的镜像:
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/orders ./cmd/orders
FROM gcr.io/distroless/static
COPY --from=build /bin/orders /orders
ENTRYPOINT ["/orders"]
api/ 放对外契约(OpenAPI/proto)时,把它当作单一事实来源:契约先行,handler DTO 和客户端代码尽量从契约生成,避免手写两份不一致。
19. 反模式清单
企业项目里最常见的目录/结构错误,逐条对照自查:
- 按技术类型分包:
models/、controllers/、services/三个大桶。→ 改成按业务能力/领域分包,让相关代码聚在一起。 utils/common/base万能桶:什么都往里塞,最后变成隐形的循环依赖源。→ 按职责拆成有名字的小包(timex、httpx)。- 过早抽象接口:给每个 struct 配一个
XxxInterface+ 单一实现,只为”方便 mock”。→ 先写具体类型,出现第二实现或真需要缝合点再抽。 - 接口定义在实现方包里:迫使消费方 import 实现包,依赖方向反了。→ 接口放消费方。
- 领域模型直接当 DTO 和 ORM 实体:一处改动三处遭殃,还泄漏内部字段。→ 领域模型、存储模型、传输 DTO 分开。
- 贫血领域模型:
domain只有字段没有行为,规则散落在 service。→ 把不变式放回领域对象方法上。 - 全局单例满天飞:包级
var DB *sql.DB、全局 logger、init()里连数据库。→ 依赖注入,main装配。 init()里做 I/O:连库、读配置、发请求。→init()只做纯内存注册;I/O 放main/NewXxx。- config 用
map[string]any到处传:类型不安全,拼错 key 运行时才炸。→ 强类型结构 + 启动校验。 - 一开始就建满
pkg/api/build/deployments:空壳目录制造认知负担。→ 结构随复杂度生长。 internal/用得太少:什么都放成公开的,重构处处掣肘。→ 默认放internal/,只把真正的公共库放外面。- 循环依赖:靠建
xxxutil包临时绕开。→ 把共享类型下沉到domain,或引入接口打断环。
20. 一个可直接照抄的完整骨架
把前面所有规范落到一起,一个中等规模企业级 Go 服务的完整目录:
orders/
├── go.mod
├── go.sum
├── Makefile
├── README.md
├── .golangci.yml
├── Dockerfile
├── cmd/
│ └── orders/
│ └── main.go # 装配 + run() + 优雅退出
├── internal/
│ ├── domain/ # 最内层,零内部依赖
│ │ ├── order.go # 实体 + 领域规则方法
│ │ ├── errors.go # 领域错误
│ │ └── repository.go # 缝合点接口(OrderRepository)
│ ├── service/ # 用例编排
│ │ ├── order_service.go
│ │ └── order_service_test.go # 用内存 fake 测
│ ├── repository/ # 基础设施:实现 domain 接口
│ │ ├── postgres/
│ │ │ ├── order_repo.go
│ │ │ └── order_repo_integration_test.go // +build integration
│ │ └── redis/
│ │ └── cache.go
│ ├── transport/ # 接入层
│ │ └── httpapi/
│ │ ├── router.go
│ │ ├── order_handler.go
│ │ ├── dto.go # 请求/响应 DTO,独立于 domain
│ │ └── middleware.go # 日志/认证/限流/recover
│ ├── config/
│ │ └── config.go # 强类型 + Load() + validate()
│ ├── platform/ # 基础设施封装
│ │ ├── database/
│ │ │ └── database.go
│ │ ├── logging/
│ │ │ └── logger.go
│ │ └── metrics/
│ │ └── metrics.go
│ └── worker/ # 后台任务(可选)
│ └── reconciler.go
├── configs/
│ └── config.example.yaml
├── api/ # 对外契约(可选)
│ └── openapi.yaml
├── deployments/ # 部署清单(可选)
│ └── k8s/
└── scripts/
依赖方向(编译期就该成立):
cmd/orders ──▶ transport ──▶ service ──▶ domain ◀── repository
│ ▲
└──────────▶ ────────────┘
(transport 也认识 domain 类型/错误)
platform、config 被 cmd 装配时注入,不被 domain 依赖
记住:这是”长大之后”的样子。一个刚起步的服务完全可以只有 cmd/orders/main.go + internal/{domain,service,transport},等某一层真的变复杂了再拆出 repository、platform、worker。
21. 落地清单
新建或重构一个企业级 Go 服务时,逐项对照:
- module path 用真实仓库/域名路径。
- 业务代码默认放
internal/,只有确定的公共库才放外面。 -
cmd/<name>/main.go只做装配,抽一个run() error,清理走 defer。 - 按业务能力/领域分包,不建
utils/common/models大桶。 -
domain零内部依赖,规则放在领域对象方法上(非贫血)。 - 缝合点接口定义在消费方,实现放外层,加编译期断言
var _ I = (*T)(nil)。 - 依赖方向单向指向
domain,没有循环依赖。 - 领域模型 / 存储模型 / 传输 DTO 各自独立。
- 依赖注入用构造函数 +
main手写装配,不用运行时反射容器。 - 配置强类型加载 + 启动校验 + fail fast,密钥不进仓库。
- 错误用
%w包装、errors.Is/As判断,领域错误在 transport 映射成状态码。 - 结构化日志(slog)依赖注入,字段 snake_case,级别语义清晰。
- 每个 goroutine 有退出方式,
main用信号 ctx 统一生命周期,优雅退出。 - 请求路径扇出有界(worker 池 / errgroup limit)。
- 单元测试同目录用内存 fake,集成测试加
//go:build integration。 - CI 门禁:gofmt / vet / golangci-lint / build /
test -race全过。 - 结构随复杂度生长,不预支空壳目录。
22. 进阶阅读
- Standard Go Project Layout —— 社区常见布局,参考即可,不要照抄。
- Organizing a Go module —— 官方给出的 module 组织建议,比社区 layout 更克制。
- Effective Go —— 命名、包组织、接口的权威来源。
- Go Code Review Comments —— 很多团队规约直接来自这里。
- Standard Package Layout(Ben Johnson) —— 领域为核心的分包思路,本文分层深受其影响。
- Style guideline for Go packages(Dave Cheney) —— 为什么不要
utils/common。 - google/wire —— 编译期依赖注入代码生成工具。
- Uber Go Style Guide —— 一份被广泛采用的工业级 Go 风格指南。
结语
Go 的工程哲学是克制:能不抽象就不抽象,能显式就不隐式,能少一层就少一层。好的目录结构不是”层数最多、最像某个 layout 模板”,而是依赖方向清晰、核心稳定、外壳可换、结构随需要生长。
从一个 main.go 开始,让每一次拆分都由真实的复杂度驱动。当你的 domain 谁都不依赖、main 一眼能看懂整个系统怎么连起来、换一个数据库不用动业务逻辑时,你就已经拥有一个健康的企业级 Go 工程了。