Go 服务并不存在一套适用于所有团队的“标准目录”。目录的价值不是看起来整齐,而是让依赖方向、修改范围和运行边界一眼可见。
本文以一个连接 PostgreSQL 的订单 HTTP API 为例,从可运行的最小结构开始,逐步加入配置、协议适配、数据访问、测试和优雅退出。示例适合一个独立部署、由 2~10 人维护的服务;如果项目只是几百行的小工具,保持一个 main 包通常更好。

图:依赖箭头指向稳定的业务抽象,领域核心不依赖 HTTP、数据库或消息中间件;点击查看原图。
一、先确定结构要解决什么问题
在创建目录前,先回答四个问题:
- 部署单元是什么? 本文只有
order-api 一个进程,因此先使用一个 go.mod。
- 核心规则是什么? 创建订单、查询订单等业务规则不应依赖 HTTP 框架或数据库驱动。
- 外部变化从哪里进入? HTTP、PostgreSQL、配置和日志都属于外层适配。
- 怎样验证边界没有被破坏? 单元测试验证业务规则,协议测试验证 HTTP,集成测试验证真实数据库,CI 负责重复执行。
由此得到几条约束:
main 只读取启动参数、建立进程级上下文并调用装配函数;
- 业务包只依赖标准库和稳定的领域类型;
- 接口定义在消费它的一方,而不是统一放进
interfaces 目录;
- 基础设施实现依赖业务接口,业务代码不导入数据库驱动;
- 配置在启动阶段一次性读取和校验,运行时按值传递;
- 先做单模块,只有出现真实的发布或所有权边界时才拆模块。
这些约束比“必须有几层目录”更重要。
二、一个可演进的目录结构
下面的结构足以承载一个中等规模的 HTTP 服务,同时没有提前引入过多抽象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| order-api/ ├── cmd/ │ └── order-api/ │ └── main.go # 进程入口:信号、配置、退出码 ├── internal/ │ ├── app/ │ │ └── app.go # 依赖装配与服务生命周期 │ ├── config/ │ │ ├── config.go # 环境变量读取与启动校验 │ │ └── config_test.go │ ├── order/ │ │ ├── model.go # 订单模型与领域错误 │ │ ├── service.go # 用例及其需要的端口 │ │ └── service_test.go # 无外部依赖的业务测试 │ ├── httpapi/ │ │ ├── order_handler.go # HTTP 请求与领域输入的转换 │ │ ├── router.go │ │ └── order_handler_test.go # httptest 协议测试 │ └── postgres/ │ ├── order_repository.go # Repository 的 PostgreSQL 实现 │ └── order_repository_test.go ├── api/ │ └── openapi.yaml # 对外协议契约 ├── migrations/ # 可审计、按版本执行的数据库变更 ├── test/ │ └── integration/ # 跨包和真实依赖测试 ├── go.mod ├── go.sum ├── Makefile └── README.md # 运行、验证、配置和故障排查入口
|
这里的 internal 是 Go 工具链可以执行的边界,并非团队口头约定。把服务实现放在仓库根部的 internal 下,可以避免其他模块直接依赖这些包,让内部重构不必承担公开 API 的兼容成本。
不要因为在其他仓库看到了某个目录就照搬:
- 没有多个可执行程序时,
cmd 仍可保留,但不必再制造多层空目录;
- 没有对外复用的 Go 库时,不需要
pkg;
- 没有数据库时,不需要
repository 或 migrations;
- 没有实际的平台级能力时,不要先建立巨大的
platform 包;
utils、common、types 往往会掩盖职责,应按具体能力命名。
三、依赖方向:目录只是结果
这个示例的编译期依赖关系如下:
1 2 3 4 5
| cmd/order-api ──► internal/app ├──► internal/config ├──► internal/httpapi ──► internal/order ├──► internal/postgres ─► internal/order └──► internal/order
|
更准确地说:
app 知道所有具体实现,因为它负责装配;
httpapi 使用 order 的输入、输出和错误,但不认识 PostgreSQL;
postgres 实现 order 所需的数据访问接口;
order 不导入 httpapi、postgres、SQL 驱动或 Web 框架;
cmd/order-api 不包含业务分支。
允许最外层知道更多,换取内层稳定。若某次修改让 internal/order 开始导入 internal/httpapi,应先停下来确认:这是业务规则,还是协议细节泄漏到了核心包?
四、业务包:先写用例,再接基础设施
4.1 领域模型和可识别错误
领域错误应能被上层稳定识别,但不携带 HTTP 状态码或数据库错误码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| package order
import ( "errors" "time" )
var ( ErrInvalidInput = errors.New("invalid order input") ErrNotFound = errors.New("order not found") )
type Status string
const StatusPending Status = "pending"
type Order struct { ID string CustomerID string AmountCent int64 Status Status CreatedAt time.Time }
type CreateInput struct { CustomerID string AmountCent int64 }
|
HTTP 层可以把 ErrInvalidInput 映射为 400,把 ErrNotFound 映射为 404;数据库层可以把 sql.ErrNoRows 转换为 ErrNotFound。这样协议和存储的变化都不会污染业务模型。
4.2 接口放在消费方
订单用例需要存取订单,因此由 order 包定义它实际使用的最小接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| package order
import ( "context" "fmt" "strings" "time" )
type Repository interface { Save(ctx context.Context, value Order) error ByID(ctx context.Context, id string) (Order, error) }
type Service struct { repo Repository newID func() string now func() time.Time }
func NewService(repo Repository, newID func() string, now func() time.Time) *Service { return &Service{repo: repo, newID: newID, now: now} }
func (s *Service) Create(ctx context.Context, in CreateInput) (Order, error) { customerID := strings.TrimSpace(in.CustomerID) if customerID == "" { return Order{}, fmt.Errorf("%w: customer_id is required", ErrInvalidInput) } if in.AmountCent <= 0 { return Order{}, fmt.Errorf("%w: amount_cent must be positive", ErrInvalidInput) }
value := Order{ ID: s.newID(), CustomerID: customerID, AmountCent: in.AmountCent, Status: StatusPending, CreatedAt: s.now().UTC(), } if err := s.repo.Save(ctx, value); err != nil { return Order{}, fmt.Errorf("save order: %w", err) } return value, nil }
func (s *Service) ByID(ctx context.Context, id string) (Order, error) { if strings.TrimSpace(id) == "" { return Order{}, fmt.Errorf("%w: id is required", ErrInvalidInput) } value, err := s.repo.ByID(ctx, id) if err != nil { return Order{}, fmt.Errorf("find order %q: %w", id, err) } return value, nil }
|
newID 和 now 也是依赖,但没必要为每个依赖都创建一个只有单方法的命名接口。函数参数足以让测试固定 ID 和时间,代码也更直观。
五、适配器:把变化挡在业务包外
5.1 PostgreSQL 实现业务端口
数据库包导入业务包并实现其接口,依赖方向仍然指向内层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| package postgres
import ( "context" "database/sql" "errors" "fmt"
"example.com/order-api/internal/order" )
type OrderRepository struct { db *sql.DB }
func NewOrderRepository(db *sql.DB) *OrderRepository { return &OrderRepository{db: db} }
func (r *OrderRepository) Save(ctx context.Context, value order.Order) error { const query = ` INSERT INTO orders (id, customer_id, amount_cent, status, created_at) VALUES ($1, $2, $3, $4, $5)`
if _, err := r.db.ExecContext(ctx, query, value.ID, value.CustomerID, value.AmountCent, value.Status, value.CreatedAt, ); err != nil { return fmt.Errorf("insert order: %w", err) } return nil }
func (r *OrderRepository) ByID(ctx context.Context, id string) (order.Order, error) { const query = ` SELECT id, customer_id, amount_cent, status, created_at FROM orders WHERE id = $1`
var value order.Order err := r.db.QueryRowContext(ctx, query, id).Scan( &value.ID, &value.CustomerID, &value.AmountCent, &value.Status, &value.CreatedAt, ) if errors.Is(err, sql.ErrNoRows) { return order.Order{}, order.ErrNotFound } if err != nil { return order.Order{}, fmt.Errorf("select order: %w", err) } return value, nil }
var _ order.Repository = (*OrderRepository)(nil)
|
最后一行是编译期断言。接口变化后,适配器会在构建阶段失败,而不是到运行时才暴露。
生产代码还应为查询设置超时、为连接池设置上限,并根据所用驱动识别唯一键冲突等错误。不要把原始 SQL 错误直接返回给客户端,也不要在日志中记录数据库连接串。
5.2 HTTP 层定义自己真正需要的能力
处理器并不需要知道完整的 *order.Service。它只声明当前路由需要的能力:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| package httpapi
import ( "context" "encoding/json" "errors" "io" "net/http"
"example.com/order-api/internal/order" )
type OrderCreator interface { Create(context.Context, order.CreateInput) (order.Order, error) }
type Handler struct { creator OrderCreator }
func NewHandler(creator OrderCreator) *Handler { return &Handler{creator: creator} }
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) { var body struct { CustomerID string `json:"customer_id"` AmountCent int64 `json:"amount_cent"` }
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&body); err != nil { status := http.StatusBadRequest var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { status = http.StatusRequestEntityTooLarge } writeError(w, status, "invalid request body") return } if err := dec.Decode(&struct{}{}); err != io.EOF { writeError(w, http.StatusBadRequest, "request body must contain one JSON object") return }
value, err := h.creator.Create(r.Context(), order.CreateInput{ CustomerID: body.CustomerID, AmountCent: body.AmountCent, }) if errors.Is(err, order.ErrInvalidInput) { writeError(w, http.StatusBadRequest, err.Error()) return } if err != nil { writeError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusCreated, value) }
|
这里还需要统一的 writeJSON、writeError 和日志中间件。需要特别约定:
- 对外错误响应使用稳定错误码,不依赖可变的错误文本;
- 日志写入请求 ID、耗时和内部错误链,但不回传堆栈和 SQL;
- 请求体设置大小上限,解码时拒绝未知字段;
- 由入口或中间件设置请求超时,超时与取消继续通过
context 传递;
- 只有在确实需要时才引入 Web 框架,项目边界不应由框架目录决定。
六、装配层:唯一知道所有实现的地方
internal/app 负责创建数据库连接、业务服务、路由和 HTTP Server。下面展示关键生命周期代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
| package app
import ( "context" "database/sql" "errors" "fmt" "net/http" "time"
_ "github.com/jackc/pgx/v5/stdlib"
"example.com/order-api/internal/config" "example.com/order-api/internal/httpapi" "example.com/order-api/internal/order" "example.com/order-api/internal/postgres" )
func Run(ctx context.Context, cfg config.Config) error { db, err := sql.Open("pgx", cfg.DatabaseURL) if err != nil { return fmt.Errorf("open database: %w", err) } defer db.Close()
db.SetMaxOpenConns(cfg.DBMaxOpenConns) db.SetMaxIdleConns(cfg.DBMaxIdleConns) db.SetConnMaxLifetime(30 * time.Minute)
pingCtx, cancelPing := context.WithTimeout(ctx, 3*time.Second) err = db.PingContext(pingCtx) cancelPing() if err != nil { return fmt.Errorf("ping database: %w", err) }
repository := postgres.NewOrderRepository(db) service := order.NewService(repository, newOrderID, time.Now) handler := httpapi.NewHandler(service)
server := &http.Server{ Addr: cfg.HTTPAddr, Handler: httpapi.NewRouter(handler, db), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, }
serveErr := make(chan error, 1) go func() { err := server.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { err = nil } serveErr <- err }()
select { case err := <-serveErr: return err case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { return fmt.Errorf("shutdown http server: %w", err) } return <-serveErr } }
|
newOrderID 的具体实现可以基于团队选定的 UUID 或其他 ID 库。装配层显式传递依赖,通常比全局 Service Locator 更易读、更易测试。只有对象图大到人工装配明显失控时,才考虑代码生成式依赖注入;不要用运行时容器隐藏构造失败。
进程入口保持很薄:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| package main
import ( "context" "fmt" "log/slog" "os" "os/signal" "syscall"
"example.com/order-api/internal/app" "example.com/order-api/internal/config" )
func main() { if err := run(); err != nil { slog.Error("order-api stopped", "error", err) os.Exit(1) } }
func run() error { cfg, err := config.Load() if err != nil { return fmt.Errorf("load config: %w", err) }
ctx, stop := signal.NotifyContext( context.Background(), syscall.SIGINT, syscall.SIGTERM, ) defer stop()
return app.Run(ctx, cfg) }
|
这样 main 不负责创建路由、拼 SQL 或处理订单,它只管理进程边界。
七、配置:启动时失败,运行时只读
推荐使用“加载—解析—校验—返回值对象”的流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
| package config
import ( "errors" "fmt" "os" "strconv" )
type Config struct { HTTPAddr string DatabaseURL string DBMaxOpenConns int DBMaxIdleConns int }
func Load() (Config, error) { maxOpen, err := intOr("DB_MAX_OPEN_CONNS", 20) if err != nil { return Config{}, err } maxIdle, err := intOr("DB_MAX_IDLE_CONNS", 5) if err != nil { return Config{}, err }
cfg := Config{ HTTPAddr: envOr("HTTP_ADDR", ":8080"), DatabaseURL: os.Getenv("DATABASE_URL"), DBMaxOpenConns: maxOpen, DBMaxIdleConns: maxIdle, }
if cfg.DatabaseURL == "" { return Config{}, errors.New("DATABASE_URL is required") } if cfg.DBMaxOpenConns <= 0 || cfg.DBMaxIdleConns < 0 { return Config{}, errors.New("database connection limits are out of range") } if cfg.DBMaxIdleConns > cfg.DBMaxOpenConns { return Config{}, errors.New("DB_MAX_IDLE_CONNS must not exceed DB_MAX_OPEN_CONNS") } return cfg, nil }
func envOr(key, fallback string) string { if value := os.Getenv(key); value != "" { return value } return fallback }
func intOr(key string, fallback int) (int, error) { value := os.Getenv(key) if value == "" { return fallback, nil } parsed, err := strconv.Atoi(value) if err != nil { return 0, fmt.Errorf("%s must be an integer: %w", key, err) } return parsed, nil }
|
如果 DB_MAX_OPEN_CONNS=abc,服务会明确报错并拒绝启动,而不是悄悄回退到默认值。配置测试至少覆盖缺失必填项、非法数字、边界值和默认值。
配置实践还应遵循以下规则:
- 密钥来自 Secret 或密钥管理系统,不提交到仓库;
- 启动日志可打印非敏感配置摘要,但数据库 URL、令牌和密码必须脱敏;
- 不在业务代码中随处调用
os.Getenv;
- 需要动态配置时,单独设计版本、缓存、失败回退和审计机制,不要把启动配置对象偷偷改成全局变量。
八、测试分层:不同风险交给不同测试
8.1 业务单元测试
业务测试不启动 HTTP,也不连接数据库。内存替身只实现当前测试需要的行为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| package order_test
import ( "context" "errors" "testing" "time"
"example.com/order-api/internal/order" )
type repositoryStub struct { saved order.Order err error }
func (r *repositoryStub) Save(_ context.Context, value order.Order) error { r.saved = value return r.err }
func (r *repositoryStub) ByID(context.Context, string) (order.Order, error) { return order.Order{}, order.ErrNotFound }
func TestServiceCreate(t *testing.T) { fixedTime := time.Date(2026, 8, 14, 9, 0, 0, 0, time.FixedZone("CST", 8*60*60)) repo := &repositoryStub{} service := order.NewService(repo, func() string { return "ord-001" }, func() time.Time { return fixedTime })
got, err := service.Create(context.Background(), order.CreateInput{ CustomerID: " customer-01 ", AmountCent: 9900, }) if err != nil { t.Fatalf("Create() error = %v", err) } if got.ID != "ord-001" || got.CustomerID != "customer-01" { t.Fatalf("Create() = %#v", got) } if !got.CreatedAt.Equal(fixedTime.UTC()) { t.Fatalf("CreatedAt = %v, want %v", got.CreatedAt, fixedTime.UTC()) } if repo.saved != got { t.Fatalf("saved = %#v, want %#v", repo.saved, got) } }
func TestServiceCreateRejectsInvalidAmount(t *testing.T) { service := order.NewService(&repositoryStub{}, func() string { return "unused" }, time.Now)
_, err := service.Create(context.Background(), order.CreateInput{ CustomerID: "customer-01", AmountCent: 0, }) if !errors.Is(err, order.ErrInvalidInput) { t.Fatalf("Create() error = %v, want ErrInvalidInput", err) } }
|
还应补充保存失败、上下文取消、空客户编号等分支。重点不是追求一个漂亮的覆盖率数字,而是让关键规则和失败路径都能稳定复现。
8.2 HTTP 协议测试
使用标准库 httptest 验证:
- 合法请求返回 201 和约定 JSON;
- 缺字段、未知字段、超大请求体返回 400 或 413;
- 领域错误与 HTTP 状态码的映射稳定;
Content-Type、请求 ID 等响应头符合契约;
- 内部错误不会泄漏 SQL、路径或凭据。
处理器测试可以注入一个只实现 OrderCreator 的 stub,无需启动整个服务。路由级测试再通过 httptest.NewServer 验证中间件和路径参数。
8.3 数据库集成测试
Repository 的正确性无法靠 Mock SQL 充分证明。应在隔离环境连接与生产同大版本的 PostgreSQL,执行真实迁移并验证:
- 写入后可按 ID 读取;
- 唯一约束、非空约束和类型转换符合预期;
sql.ErrNoRows 正确映射为领域错误;
- 事务失败后确实回滚;
- 测试数据按用例隔离并可清理。
集成测试可以使用构建标签:
本地或 CI 中显式执行:
1
| go test -tags=integration ./test/integration/... -count=1
|
构建标签不能成为“长期不运行”的借口。至少在合并请求或主分支流水线中执行,并为依赖服务设置明确超时。
九、健康检查与可观测性也属于结构设计
健康端点应表达不同语义:
/livez:进程事件循环仍可响应,不因数据库短暂故障而重启;
/readyz:实例是否可以接收新流量,可检查必要依赖,但要有很短的超时;
/metrics:请求量、错误量、延迟和资源指标;
- 日志:结构化记录请求 ID、业务动作、耗时和错误链,避免记录敏感请求体。
不要在每次存活检查里执行复杂 SQL。否则一次数据库抖动可能触发所有实例重启,进一步放大故障。就绪失败负责摘流量,存活失败只处理进程本身无法恢复的状态。
OpenTelemetry、Prometheus 或具体日志库应在适配层初始化,通过小接口或上下文传入业务用例。业务规则可以记录“订单创建失败”这类业务事件,但不应直接拼装某家可观测平台的字段格式。
十、用 CI 把结构约束变成证据
每次提交至少执行下面的检查:
1 2 3 4 5 6
| test -z "$(gofmt -l .)" go mod tidy git diff --exit-code -- go.mod go.sum go vet ./... go test -race -shuffle=on ./... go build ./cmd/order-api
|
这些命令分别守住格式、依赖声明、静态问题、并发安全和最终可构建性。若项目还包含集成测试,应在准备好隔离数据库后追加对应命令。
边界检查也可以从简单命令开始:
1
| go list -f '{{join .Imports "\n"}}' ./internal/order
|
审查输出中是否出现 HTTP 框架、SQL 驱动或项目外层包。团队规模扩大后,可把允许的依赖列表写成测试或静态检查规则,避免只依赖评审者记忆。
流水线通过不等于可以上线。发布阶段仍要保存镜像摘要、迁移版本、配置版本、健康检查结果和回滚目标;涉及数据库变更时使用扩展—迁移—收缩策略,确保新旧版本在滚动发布期间可以共存。
十一、从混杂结构渐进迁移
假设现有服务把路由、SQL 和业务判断全写在 main.go,不要一次性搬完所有文件。更安全的顺序是:
- 补特征测试:先锁定当前对外行为和关键错误响应。
- 瘦身入口:把服务启动与退出移到
internal/app,业务行为暂时不变。
- 提取一个业务用例:例如只迁移“创建订单”,建立
internal/order。
- 反转数据库依赖:由
order 定义最小 Repository,原 SQL 代码实现它。
- 隔离 HTTP 转换:Handler 只做解析、调用和响应映射。
- 补三层测试:业务单测、HTTP 测试、Repository 集成测试。
- 删除旧路径:确认指标和日志无回归后,再清理重复实现。
每一步都应保持可构建、可测试、可回滚。提交信息关联需求或缺陷,避免把目录搬迁和行为修改塞进同一个巨大提交:
1 2 3
| 【REQ-1842】- 提取订单创建用例并增加业务规则测试 【REQ-1842】- 将 PostgreSQL 访问接入订单 Repository 【BUG-1967】- 修复服务退出时未等待在途请求的问题
|
评审时可以直接按提交验证依赖方向和行为变化,故障发生后也更容易定位与回退。
十二、什么时候需要继续拆分
12.1 拆包的信号
出现以下情况时,可以把一个业务包继续拆成子能力:
- 文件长期由不同小组修改,合并冲突频繁;
- 某组规则有清楚的输入、输出和独立测试;
- 包名已经无法准确概括其中内容;
- 为完成一个小需求,必须理解大量不相关代码。
不要只因为文件超过某个行数就拆包。内聚的 800 行代码可能比互相跳转的 8 个抽象层更容易维护。
12.2 拆模块的信号
一个仓库可以长期只使用一个 go.mod。只有出现独立发布版本、外部消费者、不同兼容承诺或明确所有权边界时,才考虑多模块。
多模块本地联调可以使用 Go Workspace:
1 2
| go work init ./order-api ./shared-contracts go work sync
|
Workspace 解决的是本地同时开发多个模块的问题,不会自动解决版本治理。CI 和发布仍应验证各模块在不依赖本地 replace 的情况下可以独立构建。是否提交 go.work 应由团队根据仓库形态统一决定,不能让个人工作区文件意外改变流水线解析结果。
12.3 拆服务的信号
服务拆分应由独立扩缩容、故障隔离、数据所有权、发布节奏或团队所有权驱动。仅仅因为目录很多就拆微服务,通常会把本地函数调用变成需要超时、重试、幂等、鉴权和观测的网络调用,整体复杂度反而更高。
十三、常见反模式与修正方式
| 反模式 |
直接后果 |
更可落地的修正 |
顶层只有 controller/service/repository |
一个需求横跨全部目录,业务边界不可见 |
先按订单、库存等业务能力聚合,再在包内按需要拆文件 |
所有接口集中在 interfaces |
接口变大且脱离消费者 |
在使用方定义最小接口,由实现方做编译期断言 |
utils、common 持续膨胀 |
职责模糊、依赖扩散 |
按真实能力命名,如 money、clock、requestid |
main.go 包含业务逻辑 |
难以测试,多个入口无法复用 |
入口只处理进程,装配移到 internal/app |
| 全局数据库和配置单例 |
测试互相污染,初始化顺序隐蔽 |
构造函数显式注入,配置对象启动后只读 |
| 业务包返回 HTTP 状态码 |
核心逻辑绑定协议 |
返回领域错误,由 HTTP 或 gRPC 适配层映射 |
一开始创建多个 go.mod |
版本和联调成本过早出现 |
单模块起步,等独立发布边界真实存在再拆 |
为“以后复用”建立 pkg |
内部实现被误当成稳定公共 API |
默认放 internal,确认外部消费者后再提取独立模块 |
十四、上线前检查清单
代码边界
正确性
运行与发布
小结
一个好的 Go 服务结构,不是目录越多越专业,而是能够回答三个问题:业务规则在哪里、外部依赖从哪里接入、修改后怎样证明没有破坏行为。
从单模块、薄入口和 internal 开始;按业务能力聚合代码;让接口靠近消费方;在装配层连接具体实现;最后用单元测试、协议测试、集成测试和 CI 固化边界。等独立发布、所有权或故障隔离需求真正出现后,再拆模块或服务。这种结构既能服务今天的代码,也为明天的演进保留了空间。
一手资料
资料与版本边界复核日期:2026-08-22。示例中的模块路径、数据库参数和 ID 生成器需要替换为项目实际实现;工具行为可能演进,实施前以锁定 Go 版本对应的官方文档为准。
评论