目录与约定

布局不是审美问题:它决定了新代码该写在哪、依赖往哪个方向走、以及哪些规则可以交给 工具去拦。这页是目录表与四条硬约定的索引。

目录布局

路径 放什么 不放什么
cmd/<service>/ 一个二进制一个部署单元。当前是 cmd/demo/main.go:解析 -conf,调一次 verticle.Run 业务逻辑、装配代码
cmd/migrate/ 迁移工具:-driver / -dsn / -dir,把 migrations/*.sql 应用到库里 —
internal/app/ 实现 verticle.Service:Name() / Setup() / BuildRouter(),解码服务段、从 Env 取依赖、构造 domain、挂 Block 后台任务 业务规则(属于 domain)
internal/domain/<name>/ 业务域三件套:handler.go(HTTP 边界:解析入参 → 调 service → 返回 payload 或 errs)、service.go(纯业务逻辑,依赖经 Deps 注入)、dao.go(数据访问,SQL 与行结构) 路由注册、封套/状态码/中间件等框架关注点
pkg/core/ 可复用的基础设施,每个包单一职责:config / db / redis / memcache / kafka / httpx / obs / errs / verticle / app / auth / ratelimit / breaker / retry / idempotency / cache / cron / feature / audit / llm … 业务语义;也不 import internal/
configs/ 三份配置:config.min.yaml(零依赖,只有 http + log + 服务段)、config.sqlite.yaml(带纯 Go SQLite)、config.yaml(含 db/redis/memcache/kafka 段) 密钥明文——用 ${VAR:default} 从环境变量取
migrations/*.sql 数据库迁移,按文件名序执行 修改已提交的迁移文件——只追加
examples/<feature>/ 每个能力一个可运行示例:main.go + README.md + 测试(+ 需要 HTTP 形态时带 config.yaml) 只给片段不给跑法的"伪示例"
skills/ AI skill,matex- 前缀,每个能力一个目录(SKILL.md) —
.claude-plugin/ skill 插件清单:plugin.json(matex-skills 大伞)与 marketplace.json —
docs/ 本站静态站点源码与 design.md(逐项取舍说明) —

依赖方向

flowchart TD
    CMD["cmd/<service>
main:只调 verticle.Run"] APP["internal/app/
实现 verticle.Service
装配 domain"] DOM["internal/domain/<name>/
handler · service · dao"] CORE["pkg/core/*
唯一的基础设施入口"] DRV["第三方驱动
pgx · go-sql-driver · modernc-sqlite
go-redis · gomemcache · franz-go · grpc · rpcx"] CMD --> APP APP --> DOM APP --> CORE DOM --> CORE CORE -.-> DRV DOM -. "禁止(depguard 拦截)" .-> DRV classDef core fill:#fdf6f0,stroke:#c8641f,color:#2b2722 class CORE core classDef bad fill:#fff,stroke:#999,stroke-dasharray: 4 3,color:#777 class DRV bad
实线是允许的调用方向,虚线是 pkg/core 内部的驱动依赖。 domain 到驱动的直连被 depguard 拦住——它不是靠 review 记住的纪律,是构建期错误。

四条强制约定

"约定"意味着违反时应该有东西报错。下面四条分别由 .golangci.yml、 类型签名与 make ci 承担。

1. 业务代码不 import 驱动库

单一基础设施入口。业务要用数据库就经 pkg/core/db,要 Redis 就经 pkg/core/redis——换驱动只改 pkg/core,业务一行不动。 business-no-drivers 规则覆盖 **/internal/**, examples-no-drivers 覆盖示例(示例要示范正确写法):

business-no-drivers:
  files:
    - "**/internal/**"
  deny:
    - pkg: github.com/jackc/pgx
      desc: use pkg/core/db
    - pkg: github.com/go-sql-driver/mysql
      desc: use pkg/core/db
    - pkg: modernc.org/sqlite
      desc: use pkg/core/db
    - pkg: github.com/redis/go-redis
      desc: use pkg/core/redis
    - pkg: github.com/bradfitz/gomemcache
      desc: use pkg/core/memcache
    - pkg: github.com/twmb/franz-go
      desc: use pkg/core/kafka
唯一豁免 kafka 包自身的测试与示例测试用 franz-go/pkg/kfake 跑进程内 broker, 让 Kafka 用例离线可跑。kafka-test-fake-broker 规则把豁免收窄到 测试文件,非测试代码仍不得 import franz-go。

2. 不用 Web 框架

路由与中间件用标准库:Go 1.22 的 ServeMux 原生支持方法路由与路径参数, pkg/core/httpx 补齐统一封套、超时、trace、访问日志、指标与错误映射。 再引一个第三方路由只会多一份依赖面和一套分叉语义。no-web-framework 规则对 所有 Go 文件生效:

no-web-framework:
  files:
    - "**/*.go"
  deny:
    - pkg: github.com/gin-gonic/gin
      desc: matex uses net/http via pkg/core/httpx
    - pkg: github.com/labstack/echo
      desc: matex uses net/http via pkg/core/httpx
    - pkg: github.com/gofiber/fiber
      desc: matex uses net/http via pkg/core/httpx
    - pkg: github.com/go-chi/chi
      desc: matex uses net/http via pkg/core/httpx
    - pkg: github.com/gorilla/mux
      desc: matex uses net/http via pkg/core/httpx

3. handler 签名固定

func(ctx context.Context, r *http.Request) (any, error)

handler 不碰 http.ResponseWriter:返回 nil 即 204, 返回 errs.* 即对应状态码加 {code,msg,data} 封套。 签名固定换来三件事:handler 能直接单测(不需要构造响应记录器以外的任何东西)、 能被中间件统一包装、能统一加超时与日志。 需要原生控制(流式、自定义状态码、SSE、文件下载)时走 HandleRaw, 它是唯一的逃生口,且不经过封套。细节见 HTTP 与路由。

4. 每个能力都有离线可跑的示例

examples/<feature>/ 一个目录一个能力,必须能go run、 必须有测试,且离线可跑:SQLite 用纯 Go 驱动(modernc.org/sqlite, 无 cgo)、Redis 用 redistest(miniredis)、Kafka 用 kfake、gRPC/rpcx 用进程内 server。所以 make ci 就能验证全部示例,不需要 Docker、不需要网络。

make example NAME=http        # 等价于 go run ./examples/http
make test-short               # 含 sqlite / miniredis / kfake / 进程内 RPC 用例
make ci                       # vet + test-short + build,示例一并被验证

一个部署单元 = 一个 cmd 二进制

verticle.Service 是部署单元的边界,而 cmd/<service>/ 是它的落地形态:一个 main 包对应一个 Name()、一个进程、 一份配置段、一套日志与指标标签。要加第二个部署单元,复制 cmd/demo 与 internal/app 改名即可——Name() 决定去配置文件的哪一段 取自己的配置,框架段(http、db…)与它无关。

// cmd/demo/main.go —— main 的全部内容
func main() {
    conf := flag.String("conf", "", "config file (default configs/config.yaml or $CONFIG_FILE)")
    flag.Parse()

    if err := verticle.Run(context.Background(), &app.DemoService{}, verticle.WithConf(*conf)); err != nil {
        fmt.Fprintln(os.Stderr, "fatal:", err)
        os.Exit(1)
    }
}

由此推出两条经常被问到的推论:不要在一个进程里塞两个服务(配置段、日志标签、 指标前缀都会打架,生命周期也会互相拖累);不要给不同环境准备不同的 main—— 依赖是否存在是部署事实,删掉配置里那一段即可,代码不用改。

"配了才建,没配为 nil" verticle.Run 按配置初始化基础设施:配了 db: 段才有 env.DB(),没配就是 nil。一个只有 http: 段的最小配置也能起服务 (configs/config.min.yaml 就是),其余依赖随加随用。

新代码放哪

你要做的事 落到哪
加一个新接口internal/domain/<name>/handler.go + 在 BuildRouter 注册
加一条业务规则internal/domain/<name>/service.go
加一条 SQLinternal/domain/<name>/dao.go;表结构改动写成 migrations/000N_*.sql
加一个后台任务或消费者internal/app 的 Setup 里 env.Block(...)
加一个横切能力(认证、限流…)优先看 pkg/core/ 是否已有;要新写就照该目录的包结构加,并补一个 example
加一段业务配置服务段结构体加 default:/env:/optional tag,在 configs/config.yaml 的服务段写值

下一步