后端开发

Go 服务项目结构实践

2026-08-14 #工程化#Go#微服务

Go 服务并不存在一套适用于所有团队的“标准目录”。目录的价值不是看起来整齐,而是让依赖方向、修改范围和运行边界一眼可见。

本文以一个连接 PostgreSQL 的订单 HTTP API 为例,从可运行的最小结构开始,逐步加入配置、协议适配、数据访问、测试和优雅退出。示例适合一个独立部署、由 2~10 人维护的服务;如果项目只是几百行的小工具,保持一个 main 包通常更好。

Go 服务中入站适配器、应用层、领域层、端口和基础设施的依赖方向图

图:依赖箭头指向稳定的业务抽象,领域核心不依赖 HTTP、数据库或消息中间件;点击查看原图。

一、先确定结构要解决什么问题

在创建目录前,先回答四个问题:

  1. 部署单元是什么? 本文只有 order-api 一个进程,因此先使用一个 go.mod。
  2. 核心规则是什么? 创建订单、查询订单等业务规则不应依赖 HTTP 框架或数据库驱动。
  3. 外部变化从哪里进入? HTTP、PostgreSQL、配置和日志都属于外层适配。
  4. 怎样验证边界没有被破坏? 单元测试验证业务规则,协议测试验证 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 正确映射为领域错误;
  • 事务失败后确实回滚;
  • 测试数据按用例隔离并可清理。

集成测试可以使用构建标签:

1
//go:build integration

本地或 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,不要一次性搬完所有文件。更安全的顺序是:

  1. 补特征测试:先锁定当前对外行为和关键错误响应。
  2. 瘦身入口:把服务启动与退出移到 internal/app,业务行为暂时不变。
  3. 提取一个业务用例:例如只迁移“创建订单”,建立 internal/order。
  4. 反转数据库依赖:由 order 定义最小 Repository,原 SQL 代码实现它。
  5. 隔离 HTTP 转换:Handler 只做解析、调用和响应映射。
  6. 补三层测试:业务单测、HTTP 测试、Repository 集成测试。
  7. 删除旧路径:确认指标和日志无回归后,再清理重复实现。

每一步都应保持可构建、可测试、可回滚。提交信息关联需求或缺陷,避免把目录搬迁和行为修改塞进同一个巨大提交:

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,确认外部消费者后再提取独立模块

十四、上线前检查清单

代码边界

  • main 只处理进程级职责;
  • 业务包不导入 HTTP 框架、SQL 驱动和具体消息 SDK;
  • 接口由消费方定义,并保持最小;
  • 包名短、明确,不使用含糊的 common、util、types;
  • 没有可变全局 Service、数据库连接或配置。

正确性

  • 业务规则和失败路径有单元测试;
  • HTTP 错误映射和响应契约有 httptest 测试;
  • Repository 在真实同版本数据库上执行集成测试;
  • gofmt、go vet、竞态测试和最终构建均通过;
  • 超时、取消和事务回滚路径已验证。

运行与发布

  • 启动时校验必填配置,日志不包含凭据;
  • HTTP Server 配置读取、写入、空闲和优雅退出超时;
  • /livez 与 /readyz 语义分离;
  • 数据库连接池上限与实例副本数一起核算;
  • 发布记录包含镜像摘要、迁移版本、观察窗口和回滚目标。

小结

一个好的 Go 服务结构,不是目录越多越专业,而是能够回答三个问题:业务规则在哪里、外部依赖从哪里接入、修改后怎样证明没有破坏行为。

从单模块、薄入口和 internal 开始;按业务能力聚合代码;让接口靠近消费方;在装配层连接具体实现;最后用单元测试、协议测试、集成测试和 CI 固化边界。等独立发布、所有权或故障隔离需求真正出现后,再拆模块或服务。这种结构既能服务今天的代码,也为明天的演进保留了空间。

一手资料

资料与版本边界复核日期:2026-08-22。示例中的模块路径、数据库参数和 ID 生成器需要替换为项目实际实现;工具行为可能演进,实施前以锁定 Go 版本对应的官方文档为准。

评论
分享

评论