Skip to content
Charles Shao
Go back

Go 工程目录:企业级项目结构与设计规范

–views

面向已经会写 Go、但想把项目”组织好”的读者:读完你应该能独立设计一个可维护、可测试、可演进的企业级 Go 服务目录,并知道每个决定背后的取舍。 本文只讲 工程结构与设计规范,示例都是与具体业务无关的通用骨架,便于直接套用到你自己的项目。 如果你还不熟悉 Go 语法本身,建议先看《Go 语言基础》那篇;本文默认你已经理解包、接口、error、context、goroutine 等概念。


目录

共 22 节 · 点此折叠 / 展开

一 · 结构总纲 —— 目录为什么这么长、从哪开始

  1. 先讲清楚:目录结构是给谁看的
  2. 一个 module 的骨架

二 · 顶层目录 —— cmd / internal / pkg 三个入口的取舍

  1. cmd:程序入口只做装配
  2. internal:企业级项目的默认落脚点
  3. pkg:到底要不要建

三 · 分层与依赖 —— 领域为核心,依赖只向内

  1. 分层架构:领域为核心的四层
  2. 依赖方向与依赖倒置

四 · 逐层详解 —— 每一层放什么、不放什么

  1. domain:最内层的领域模型
  2. repository:把外部状态藏在接口后
  3. service:业务编排层
  4. server / handler:接入层
  5. config:强类型配置与密钥

五 · 横切关注点 —— 错误、日志、并发、依赖注入怎么落位

  1. 错误、日志与可观测性的落位
  2. 并发骨架:生命周期与优雅退出
  3. 依赖注入:手写装配 vs 框架

六 · 工程实践 —— 测试、多服务、构建发布

  1. 测试的目录与分层
  2. 多服务仓库:monorepo 与 go.work
  3. 构建、发布与配置文件

七 · 收束与参考 —— 避坑、照抄骨架、自查清单

  1. 反模式清单
  2. 一个可直接照抄的完整骨架
  3. 落地清单
  4. 进阶阅读

1. 先讲清楚:目录结构是给谁看的

很多人一上来就问”Go 项目应该长什么样”,然后照抄一个 project-layout 仓库,把 cmd/、pkg/、api/、build/、deployments/ 一股脑建出来——哪怕项目里只有三个文件。这是本末倒置。

目录结构服务于三个目标,重要性从高到低:

  1. 表达依赖方向:一眼看出谁依赖谁,哪一层是核心,哪一层是可替换的外壳。
  2. 约束可见性:用 internal/ 让不该被外部依赖的代码在编译期就无法被导入。
  3. 降低定位成本:改一个功能时,知道该去哪个包找、该在哪个包加。

反过来说,目录结构不应该照搬别的语言的习惯(比如 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 导入的公共代码”。但现实是:

建议:

一个常见误区是建 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

关键在于依赖只能从外向内:

这不是”必须四层”。小项目 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 包放纯粹的业务类型和规则,它是整个系统的”词汇表”。特征:

// 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 服务。原则:

// 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 的几条纪律:


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)
    }
}

这一层的关键设计点:


12. config:强类型配置与密钥

配置是企业级项目最容易乱的地方。核心规范:

// 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. 错误、日志与可观测性的落位

这三样东西”放哪一层”是企业项目的高频困惑,规范如下:

错误

日志

指标 / trace

落位原则一句话:可观测性是横切关注点,用中间件 + 依赖注入接入,不要污染领域和业务逻辑。 domain 和 service 的核心方法里不应该到处塞埋点代码。


14. 并发骨架:生命周期与优雅退出

企业级服务几乎都要跑后台 goroutine(消费队列、定时任务、批处理)。目录上通常放在 internal/worker 或对应业务包里,但骨架规范比放哪更重要:

优雅退出的骨架(接第 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. 反模式清单

企业项目里最常见的目录/结构错误,逐条对照自查:


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 服务时,逐项对照:


22. 进阶阅读


结语

Go 的工程哲学是克制:能不抽象就不抽象,能显式就不隐式,能少一层就少一层。好的目录结构不是”层数最多、最像某个 layout 模板”,而是依赖方向清晰、核心稳定、外壳可换、结构随需要生长。

从一个 main.go 开始,让每一次拆分都由真实的复杂度驱动。当你的 domain 谁都不依赖、main 一眼能看懂整个系统怎么连起来、换一个数据库不用动业务逻辑时,你就已经拥有一个健康的企业级 Go 工程了。


–views
Share this post on:

Previous Post
Go 日志设计笔记:从照搬 logback 到删掉一半设计
Next Post
Go 语言基础:核心语法与工程规范系统梳理