Ardan Labs Service

Ardan Labs 的 Go 服务参考实现,用「架构层横切 × 业务领域纵切」组织代码;本篇记录它的目录结构、包组织与依赖方向。

#type / resource #status / growing #tech / dev / backend #resource / go

[!info] related notes

Ardan Labs Service

这是什么

Ardan Labs 维护的 Go 服务参考实现 / starter kit,配套其企业培训课程。它不是框架,也不打算被当依赖引入,而是一份「可以整仓复制走再改」的工程样板。

项目自称的架构名是 Domain Driven, Data Oriented Architecture(领域驱动、面向数据的架构)。

[!tip] 理解它的关键 不要去背目录名。它同时沿两个方向组织代码:

  • 横向按架构层api / app / business / foundation,依赖方向单向向下
  • 纵向按业务领域:同一个 product 概念在每层各有一个包(productapp / productbus

目录树是这两个维度的笛卡尔积投影。

版本快照

分支master
commit43e8ae07abdc(2026-06-22)
抓取日期2026-08-10
modulegithub.com/ardanlabs/service
Go 版本1.26.0
依赖管理vendor/ 目录 vendoring
构建入口根目录 makefile

Web 层选型:纯 net/http

没有使用任何第三方 Web 框架。 go.mod 的直接依赖里不存在 gin / echo / fiber / chi / gorilla。(go-chi/chigorilla/mux 确实出现在 go.mod 里,但都标着 // indirect,是 markbates/gothopen-policy-agent/opa 拖进来的传递依赖,业务代码不碰。)

foundation/web 是一个约 5 个文件的自研极薄封装,直接建在标准库上:

文件职责
web.goApp 类型、路由注册、ServeHTTP、CORS、静态文件服务
middleware.goMidFunc 定义与 wrapMiddleware
context.gocontext 里塞 writer / tracer
request.goDecode 解码与校验
response.goRespond 统一编码响应

核心就三样东西:

// 1. 路由用 Go 1.22+ 标准库 ServeMux 的方法+路径模式
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/products", h)   // finalPath = fmt.Sprintf("%s %s", method, path)

// 2. 自定义 handler 签名:不给 ResponseWriter,强制返回值
type HandlerFunc func(ctx context.Context, r *http.Request) Encoder

// 3. App 自己实现 http.Handler,外面套 otelhttp
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request)
otmux: otelhttp.NewHandler(mux, "request")

[!tip] 这个 handler 签名是整个设计的支点 标准库的 http.HandlerFunc(w, r) 允许 handler 在任意位置写响应,于是错误处理必然散落各处。 这里把 w 藏进 context,handler 只能 return 一个 Encoder——正常结果和错误走同一条返回路径,Respond 统一编码,mid.Errors 统一转换错误码。 想抄这个项目的话,这一处比目录结构值得抄。

关键依赖

领域选型
Web无框架,net/http + 自研 foundation/web
配置ardanlabs/conf/v3
数据库迁移ardanlabs/darwin/v3
PostgreSQLjackc/pgx/v5 + jmoiron/sqlx
鉴权golang-jwt/jwt/v4 + open-policy-agent/opa(策略即代码)
OAuthmarkbates/goth
可观测go.opentelemetry.io/otel 全家桶 + otelhttp
缓存viccon/sturdyc
运行时可视化arl/statsviz

仓库鸟瞰:顶层目录结构

service/
├── api/                  # 可执行入口层:main、路由装配、运维工具
│   ├── frontends/
│   │   └── admin/
│   ├── services/
│   │   ├── auth/         # 认证服务:main.go + build/all.go
│   │   ├── metrics/      # 指标服务:main.go + collector/ + publisher/
│   │   └── sales/        # 主业务服务:main.go + build/{all,crud,reporting}.go + tests/
│   └── tooling/
│       ├── admin/        # 运维 CLI
│       └── logfmt/       # 日志格式化 CLI

├── app/                  # 应用层:HTTP 编解码、中间件、跨领域编排
│   ├── domain/           # 按业务领域纵切,一个领域一个 xxxapp 包
│   │   ├── auditapp/
│   │   ├── authapp/
│   │   ├── checkapp/         # 健康检查
│   │   ├── grpcauthapp/
│   │   ├── homeapp/
│   │   ├── oauthapp/
│   │   ├── productapp/
│   │   ├── rawapp/
│   │   ├── tranapp/          # 跨领域事务示例
│   │   ├── userapp/
│   │   └── vproductapp/      # view product:读模型
│   └── sdk/              # 应用层共享设施(非业务)
│       ├── apitest/          # API 测试脚手架
│       ├── auth/
│       ├── authclient/
│       ├── debug/
│       ├── errs/             # 统一错误与字段错误
│       ├── metrics/
│       ├── mid/              # 中间件
│       ├── mux/              # 路由装配
│       └── query/            # 查询参数解析

├── business/             # 业务层:领域模型、业务规则、存储端口
│   ├── domain/           # 一个领域一个 xxxbus 包
│   │   ├── auditbus/
│   │   ├── homebus/
│   │   ├── productbus/
│   │   ├── userbus/
│   │   └── vproductbus/
│   ├── sdk/              # 业务层共享设施
│   │   ├── dbtest/
│   │   ├── delegate/         # 领域间事件委托,避免横向 import
│   │   ├── migrate/
│   │   ├── order/            # 排序
│   │   ├── page/             # 分页
│   │   ├── sqldb/            # SQL 基础设施
│   │   └── unittest/
│   └── types/            # 强类型值对象,只允许出现在业务层
│       ├── domain/
│       ├── home/
│       ├── money/
│       ├── name/
│       ├── password/
│       ├── quantity/
│       └── role/

├── foundation/           # 与业务无关的基础库,任何层都可 import
│   ├── docker/
│   ├── keystore/
│   ├── logger/
│   ├── otel/                 # OpenTelemetry
│   ├── web/                  # 自研极薄 web 框架
│   └── worker/

├── zarf/                 # 部署与运维资产(波斯语「容器/器皿」)
│   ├── compose/
│   ├── docker/
│   ├── helm/charts/
│   ├── k8s/{base,dev}/
│   ├── keys/
│   └── prompts/

├── .agents/skills/       # 仓库自带的 AI 协作规则
│   ├── branching-logic-flow/
│   ├── business-layer-extensions/
│   ├── layered-architecture-types/
│   ├── review-pr/
│   └── use-modern-go/

├── vendor/
├── makefile
└── go.mod

顶层边界职责

顶层目录回答什么问题允许依赖备注
api/进程怎么起来、路由怎么装app、business、foundation每个可执行程序一个子目录,main.go 保持薄
app/一次请求怎么被处理business、foundation只有这里能同时 import 多个 xxxbus
business/业务规则是什么foundation不得 import app/xxxbus 之间不得互相 import
foundation/与业务无关的通用能力无(叶子包)不得 import app/business
zarf/怎么部署非 Go 代码

分层与依赖方向

flowchart TB
    API["api/ 入口装配"] --> APP["app/ 应用层"]
    APP --> BUS["business/domain 业务层"]
    BUS --> STORE["business/domain/*/stores/*db 存储实现"]
    APP --> FOUND["foundation/ 基础库"]
    BUS --> FOUND
    BUS --> TYPES["business/types 强类型"]
    APP -.->|不允许| BUS2["其他 xxxapp"]
    BUS -.->|不允许| BUS3["其他 xxxbus"]

仓库在 .agents/skills/layered-architecture-types/SKILL.md 里把规则写死了,几条硬约束:

1. 类型强度随层变化(primitive at edges, strong types in business)

API 层 (app/*)          Business 层 (business/domain/*)      Storage 层 (*db)
原始类型          ──►    强类型 business/types/*        ──►   数据库原生类型
request struct    toBus<T>          model type          toDB<T>      db<T> row
response struct   ◄── fromBus<T>Response  ◄──  toBus<T>  ◄──
  • app/* 的 request/response 结构体:只能用原始类型stringintbooltime.Timejson.RawMessage),禁止出现 business/types 的强类型
  • business/domain/*/model.go:ID、枚举、分类必须用强类型
  • *db 的行结构体:只能用 stringsql.Null*json.RawMessage 等原生类型,枚举也存成 string
  • 每次跨界都要走命名转换函数,禁止直接赋值

2. 禁止同层横向 import

  • business/domain/<x>bus 不得 import <y>bus
  • app/domain/<x>app 不得 import <y>app
  • 需要跨领域组合,只能在 app 层做(一个 app 包可以 import 多个 bus 包)
  • 领域之间要通信,走 business/sdk/delegate

3. business/types/*foundation/* 是共享叶子包

任何层都能 import 它们,它们不能 import app/business 领域包。且 foundation 类型不能直接出现在 business 模型里,要包一层 wrapper。

关键入口与调用链

入口路径说明
主业务服务api/services/sales/main.go
路由装配api/services/sales/build/{all,crud,reporting}.go用 build tag 组合不同路由集
认证服务api/services/auth/main.go
指标服务api/services/metrics/main.go
API 测试api/services/sales/tests/xxxapi 分组的黑盒测试

主线:一条请求的完整路径

POST /v1/products(创建产品)作为主线,因为它最短且穿过所有层。product 领域在每层的文件是完全对称的,这是它最好读的地方:

app/domain/productapp/        business/domain/productbus/
  route.go    路由表            productbus.go  业务逻辑 + Storer 接口
  productapp.go  handler        model.go       领域模型(强类型)
  model.go    DTO + 转换函数     filter.go / order.go
  filter.go / order.go          stores/productpg/     PostgreSQL 实现
                                stores/productsqlite/ SQLite 实现
                                stores/commondb/      共用行映射

装配阶段(进程启动时跑一次)

api/services/sales/main.go
  └─ api/services/sales/build/crud.go   ← build tag 决定装哪套路由
       Add(app, cfg) 依次调用各领域的 Routes()
         ├─ checkapp.Routes()
         ├─ homeapp.Routes()
         ├─ productapp.Routes()   ← 我们要追的
         └─ userapp / tranapp / auditapp …
            └─ app/sdk/mux/mux.go  WebAPI()
                 web.NewApp(全局中间件:Otel → Logger → Errors → Metrics → Panics)
                 routeAdder.Add(app, cfg)

productapp/route.go 里五行就是全部路由:

app.HandlerFunc(http.MethodPost, "v1", "/products", api.create, authen, ruleUserOnly)
//                                                   ↑handler   ↑该路由独有的中间件

请求阶段(每次请求跑一遍)

POST /v1/products

foundation/web  App.ServeHTTP        CORS、HSTS、path.Clean

otelhttp.NewHandler                  开 trace span

http.ServeMux                        匹配 "POST /v1/products"

全局中间件(外→内)                    Otel → Logger → Errors → Metrics → Panics

路由级中间件                          mid.Authenticate → mid.Authorize(RuleUserOnly)

app/domain/productapp/productapp.go  func (a *app) create(ctx, r) web.Encoder
     web.Decode(r, &app)                 → NewProduct(DTO,全是原始类型)
     toBusNewProduct(ctx, app)           → 转强类型,校验失败返回 errs.InvalidArgument

business/domain/productbus/productbus.go  func (b *Business) Create(ctx, np)
     b.userBus.QueryByID()               → 查用户
     if !usr.Enabled → ErrUserDisabled   → 业务规则
     构造 Product{}

business/domain/productbus/stores/productpg/productpg.go  func (s *Store) Create()
     commondb.ToDBProduct(prd)           → 强类型转 DB 行结构
     INSERT INTO products …

返回时原路折回:Product → toAppProduct() → web.Encoder → Respond() 统一编码

怎么读第二条、第三条主线

第一条追完后,按这个顺序挑,每条都能暴露一个新机制:

顺序主线新增看点
1POST /v1/products基础三层穿透(上面这条)
2GET /v1/productsfilter.go / order.go / page 怎么在三层各写一遍
3PUT /v1/products/{product_id}mid.AuthorizeProduct 如何预取对象塞进 context
4tranapp 的路由NewWithTx 事务如何贯穿多个 bus 包
5authapp / oauthappOPA 策略鉴权与 goth OAuth

阅读路线:不要按目录读,要追一条线

[!tip] 学习目标 不要追求“每个目录都看过”。真正的完成标准是:不看代码,也能画出一个请求的对象装配图、调用链和错误返回链

按目录读很容易变成:foundation 看过、business 看过、app 看过,但不知道一次真实请求怎么穿过这些层。更有效的方法是:选一个真实用例,先追装配,再追请求;向下走到数据库,再反向走回 HTTP。

两遍阅读法:先装配,再请求

第一遍只看启动阶段,回答“这些对象是谁创建出来的、谁依赖谁”。建议从 POST /v1/users 对应的 User 领域开始:

api/services/sales/main.go

PostgreSQL DB

userdb.NewStore

usercache.NewStore

userbus.NewBusiness

mux.BusConfig.UserBus

userapp.Routes

HTTP Server

这一遍先不要钻进 handler 和 SQL。只画出 Dependency Graph / Object Graph,搞清楚:

  • 谁创建 UserBus
  • UserBus 依赖哪个 Storer
  • Cache 和 DB Store 谁包谁
  • UserBus 最后怎么被放进 mux.Config
  • 哪个 runtime 拥有这些对象、它们跟哪个进程一起生灭

第二遍才追每次请求实际执行的调用链:

POST /v1/users

foundation/web + 全局中间件

Authenticate / Authorize

app/domain/userapp.create

web.Decode(NewUser DTO)

toBusNewUser

business/domain/userbus.Create

Storer.Create

usercache.Create

userdb.Create

PostgreSQL

原路返回 HTTP Response

为什么第一条建议追 POST /v1/users

它一次就能看到这个项目最重要的几个边界:

位置重点看什么
api/services/sales/main.goRuntime / Composition Root 怎么组装依赖
app/domain/userapp/route.goURL、认证和授权如何挂到 handler
app/domain/userappHTTP DTO 如何 Decode,并转换成 Business Model
business/domain/userbus真正的业务规则与领域数据如何形成
userbus.StorerBusiness 对持久化能力只依赖什么抽象
stores/usercacheDecorator 如何包住底层 Store
stores/userdbBusiness Model 如何映射成 SQL / DB Model

关键不是把每个函数都看完,而是在每一层只回答“这一层为什么存在,它把什么转换成了什么”。

看到接口时,先看抽象,不要立刻跳实现

userbus.Create 调用 b.storer.Create(...) 时,先停下来理解 Storer

userbus
   ↓ depends on
Storer interface
   ↑ implemented/wrapped by
usercache

userdb

PostgreSQL

这一步能看清真正的依赖方向:Business 不需要知道 PostgreSQL,只要求“给我一个能保存 User 的东西”。具体实现由 runtime 在外层组装。

Cache 要当成 Store Decorator 理解

启动时如果看到类似:

usercache.NewStore(
    userdb.NewStore(...),
)

不要把 usercacheuserdb 理解成两个并列 Repository。运行时真实调用链是:

userbus

usercache

userdb

PostgreSQL

也就是说 usercache 是包在 userdb 外面的 decorator。读 GET /v1/users/{id} 时尤其要关注 cache hit / miss;读 Create、Update、Delete 时则关注缓存如何写入或失效。

一条线必须追两种方向

只追:

HTTP → App → Business → Store → DB

还不算看懂。还要反向追:

DB Error

Store Error Mapping

Business Error

App Error

HTTP Status / Response

正常返回也一样:

DB / Business Model

Business

fromBus / Response DTO

web.Encoder

Respond

HTTP Response

这样才能看懂错误边界、模型边界和职责边界,而不是只看一串函数调用。

每追一条线,固定回答 6 个问题

  1. 谁启动它? → 哪个 api/services/* runtime。
  2. 谁暴露接口? → 哪个 app/domain/*app
  3. 输入在哪变成 Business Model?toBus... 一类转换函数。
  4. 真正业务规则在哪? → 对应 business/domain/*bus
  5. Business 依赖什么抽象?Storer、扩展接口或其他明确端口。
  6. 最终谁实现这个抽象? → Cache / DB / 外部客户端等具体 adapter。

如果这 6 个问题可以不看代码回答出来,这条线才算真正掌握。

推荐的完整学习顺序

按“每条只多引入一个新机制”的顺序走:

  1. POST /v1/users:先打通 Runtime → App → Business → Store → DB。
  2. GET /v1/users/{id}:理解 Query、Cache hit / miss 和返回模型转换。
  3. PUT /v1/users/{id}:理解更新、对象预取和缓存更新/失效。
  4. DELETE /v1/users/{id}:理解删除后的 delegate / 领域间通知。
  5. POST /v1/products:把同一套结构迁移到第二个领域,并观察 Product 对 User 能力的依赖。
  6. tranapp:理解跨领域事务以及 NewWithTx 如何重组 Business。
  7. auth service:理解独立 runtime、远程 AuthClient 与本地 Business 的边界。
  8. metrics / OTel / foundation:最后再补可观测性和通用基础设施,不要一开始就陷进去。

[!important] 一个实用的停止条件 当你能闭着眼画出:

Sales main.go → userdb → usercache → userbus → userapp → POST /v1/users → userbus.Create → usercache.Create → userdb.Create → PostgreSQL → Response

并能解释每个箭头为什么存在,就可以进入下一条线。不要为了“完整”先把整个仓库所有目录都读一遍。

请求运行时心智模型:Handler、Middleware、Context 与 Cache 怎么串起来

前面的主线解决“代码分层怎么走”,这一节解决更容易混淆的运行时问题:路由什么时候注册、*http.Request 从哪里来、中间件如何得到当前用户与目标资源、Context 和 Cache 到底有什么区别。

先分清两个时间点:启动时注册 vs 请求时执行

例如:

app.HandlerFunc(http.MethodPost, version, "/users", api.create, authen, ruleAdmin)

这里的 api.create 没有括号,传进去的是函数值,不是当场执行 create。启动阶段做的是:

api.create 函数值

包上 route middleware

再包全局 middleware

转换成 net/http 能识别的 handler

注册到 http.ServeMux:POST /v1/users

真正的执行要等客户端请求到达:

Client
  ↓ raw HTTP
net/http Server
  ↓ 解析
*http.Request

web.App.ServeHTTP

http.ServeMux 匹配 POST /v1/users

已注册的 handler 链

api.create(ctx, r)

所以要固定一个判断:

HandlerFunc(...) = 启动阶段声明“以后谁处理这个路由”
api.create(ctx, r) = 运行阶段真的处理某一次请求

foundation/web.HandlerFunc 是一个 Adapter + Middleware Composer

标准库认的 handler 形状是:

func(http.ResponseWriter, *http.Request)

项目自己的业务 handler 形状是:

type HandlerFunc func(context.Context, *http.Request) Encoder

web.App.HandlerFunc 在两者中间做适配:

Go net/http 世界
func(w, r)

web.HandlerFunc adapter
     ├─ 从 r.Context() 建立当前请求 ctx
     ├─ 把 writer / tracer 等放入 ctx
     ├─ 执行 middleware chain
     ├─ 调业务 handler(ctx, r)
     └─ 用 Respond(ctx, w, encoder) 统一输出

Ardan Handler 世界
func(ctx, r) Encoder

这解释了为什么 api.create 只接收 ctx*http.Request,却不直接操作 http.ResponseWriter业务 handler 负责返回结果,外围 Web 层负责统一编码响应和错误。

*http.Request 是 Go HTTP Runtime 创建的

*http.Request 不是 app.HandlerFunc 预先构造的。客户端发来的 method、path、header、body 会先被 net/http 解析成一个请求对象,再传给匹配到的 handler。

HTTP 请求
├─ Method: POST
├─ Path: /v1/users
├─ Authorization header
├─ Query string
└─ JSON Body
       ↓ net/http 解析
*http.Request
├─ r.Method
├─ r.URL / r.PathValue(...)
├─ r.Header
├─ r.Body
└─ r.Context()

所以不同 app 读取的是同一个请求对象的不同部分:

  • web.Decode(r, &dto):读 r.Body
  • 查询参数解析:读 r.URL.Query()
  • path 参数:读 r.PathValue(...)
  • 认证:读 Authorization header
  • 请求级状态:沿 r.Context() 派生并传递

Middleware 是业务 Handler 外面的请求流水线

项目中 middleware 的本质是:

type MidFunc func(HandlerFunc) HandlerFunc

也就是拿一个 handler,返回一个包装后的 handler。因此 middleware 可以在调用 next 前后执行逻辑。

这个项目有两层 middleware:

全局 middleware(所有 API 路由)
Otel → Logger → Errors → Metrics → Panics

路由 middleware(按端点选择)
Authenticate → Authorize / AuthorizeUser / AuthorizeProduct / AuthorizeHome → Transaction?

它们分别回答不同问题:

Middleware核心问题
Otel这次请求如何进入 trace?
Logger这次请求发生了什么?
Errors下游错误如何统一变成 HTTP 错误?
Metrics请求、失败等计数怎么统计?
Panics下游 panic 怎么 recover 并交回正常错误链?
Authenticate当前操作者是谁?
Authorize当前身份是否满足某条角色/策略规则?
AuthorizeUser是否能操作指定 User,并预取 User?
AuthorizeProduct是否能操作指定 Product,并预取 Product?
AuthorizeHome是否能操作指定 Home,并预取 Home?
BeginCommitRollback是否把本次 use case 包进 DB transaction?

其中认证、授权、日志、错误处理、恢复、指标、Tracing 都是正常生产后端常见的横切能力;资源预取式授权与事务 middleware 则是具体架构选择。

Authentication、Authorization 与资源预取要分开理解

身份相关逻辑可以统一成三个问题:

1. Who are you?
   Authenticate
   → 从凭证得到 Claims / SubjectID

2. What are you operating on?
   AuthorizeUser/Product/Home
   → 从 URL 找目标资源,必要时 QueryByID

3. Are you allowed?
   Authorization Rule
   → 比较 Claims、角色、目标用户或资源 Owner

例如管理员 A 修改 User B:

PUT /users/B
Authorization: Bearer <A token>

Authenticate

Actor = A, Role = ADMIN

AuthorizeUser
  ├─ 从 URL 取 target user id = B
  ├─ UserBus.QueryByID(B)
  ├─ 得到 User B
  └─ 用 Actor/Role/Target 做授权

Context 中同时保存 Claims(A) 与 User(B)

userapp.update

mid.GetUser(ctx) 得到的是 Target User B

这里要刻意区分三个身份概念:

Actor  = 当前谁在操作,来自 token / claims
Target = 这次直接操作的资源,例如 /users/{user_id}
Owner  = 某个资源属于谁,例如 Product.UserID / Home.UserID

User 资源通常直接有 Actor 与 Target;Product/Home 这类资源则常常需要先加载资源,再得到 Owner:

PUT /products/P

Authenticate → Actor = Tom

AuthorizeProduct

Query Product P

Owner = Product.UserID

比较 Actor / Role / Owner

创建 Product 又是另一种情况:Owner 不应相信客户端随意传入,而应从认证上下文得到当前 SubjectID,再在 App → Business 转换时形成 productbus.NewProduct.UserID

Context 不是缓存:它是单次请求的数据总线

context.Context 最容易和 usercache 混淆。它们解决的是完全不同的问题。

context.Context
生命周期:单次 HTTP Request
用途:沿调用链传 request-scoped data
例子:Claims、预取的 User/Product、transaction、trace 信息

usercache
生命周期:Sales 进程
用途:跨不同 HTTP Request 复用 User 数据,减少 DB 查询

PostgreSQL
生命周期:持久化
用途:事实来源 / durable state

因此:

ctx.Value(userKey)

不是“去缓存查用户”。它只是从当前请求的 context 取出前面 middleware 已经放进去的 userbus.User

典型配对关系是:

Authenticate      → set Claims → GetClaims / GetSubjectID
AuthorizeUser     → set User   → GetUser
AuthorizeProduct  → set Product→ GetProduct
AuthorizeHome     → set Home   → GetHome
Transaction       → set Tx     → GetTran

这些 Get... 是 Context Accessor,不是 middleware,也不会自己访问数据库。

为什么 GET/PUT /users/{id} 的 Handler 看不到 QueryByID

如果 userapp.queryByIDupdate 里只看到:

usr, err := mid.GetUser(ctx)

不要得出“没有查数据库”的结论。真正的查询发生在更早的资源 middleware:

GET /users/B

Authenticate

AuthorizeUser

UserBus.QueryByID(B)

usercache.QueryByID
  ↓ cache miss 时
userdb.QueryByID

PostgreSQL

User B

context.WithValue(..., userKey, User B)

userapp.queryByID / update

mid.GetUser(ctx)

这样做的核心原因是 load once, use downstream:授权本来就需要加载目标资源,把它放进 Context 后,handler 不必再 Query 一次。

usercache 不是第二个数据库,而是 Storer Decorator

Business 只依赖 userbus.Storer

Business
  ↓ 只认识 Storer interface
UserCache Store
  ↓ 内部又持有一个 Storer
UserDB Store

PostgreSQL

usercache.Storeuserdb.Store 实现同一个接口,是为了让 Cache 可以透明地包住真正 Store。Business 仍然只写:

b.storer.QueryByID(...)
b.storer.Create(...)

到底是直连 DB:

Business → UserDB

还是加缓存:

Business → UserCache → UserDB

sales/main.go 的 composition root 决定,Business 不需要修改。

这是 Decorator Pattern 在 Repository/Store 边界上的直接应用。

Cache 的核心策略:读时命中,写时先事实来源

usercache 只缓存适合稳定 key 的单对象查询,例如 ID / Email;复杂列表 QueryCount 通常直接下沉到真实 Store。

读路径:

QueryByID

Cache.Get(id)
  ├─ hit  → 直接返回 User
  └─ miss → UserDB.QueryByID

           PostgreSQL

           Cache.Set

           return User

写路径遵循“先 DB,后 Cache”:

Create
DB Create 成功 → 写 Cache

Update
DB Update 成功 → 更新 / 失效 Cache

Delete
DB Delete 成功 → 删除 Cache

不能先写 Cache 再写 DB,否则 DB 失败时会出现:

Cache = 新数据
DB    = 旧数据 / 无数据

因此 PostgreSQL 是 source of truth,Cache 只是可丢弃的加速层。

Transaction 中为什么要绕过或失效 Cache

事务带来一个额外一致性问题:事务中的写入在 COMMIT 前并不是最终事实,甚至可能 ROLLBACK

如果事务内提前把未提交数据写进全局 Cache:

BEGIN

DB: Tom → Jerry(尚未 commit)

Cache.Set(Jerry)

ROLLBACK

最终 DB = Tom
但 Cache = Jerry

所以事务模式下应优先保证正确性:

事务读:绕过普通 Cache,走 transaction DB
事务写:不要提前写入新缓存,必要时 invalidate

核心原则:correctness > cache hit rate。缓存 miss 最多只是多查一次数据库,缓存错误则会把错误业务数据返回给用户。

把一次请求最终压缩成一张图

                     Go net/http Runtime

                       *http.Request

                         web.App

                Global Middleware Pipeline
          Otel → Logger → Errors → Metrics → Panics

                       Authenticate

                  Claims / Actor → Context

                 Authorize Resource

              Query Target / Resolve Owner

                 Resource → Context

                           Handler

                 HTTP DTO → Bus Model

                          Business

                   Storer interface

                    Cache Decorator

                        DB Store

                       PostgreSQL

           Error / Model 沿边界逐层返回 HTTP

读任何新 Route 时,可以在原来的 6 个问题之外再补 4 个运行时问题:

  1. 这条 Route 注册时包了哪些 middleware?
  2. Actor、Target、Owner 分别从哪里得到?
  3. 哪些对象是 middleware 预取后放进 Context 的?
  4. Storer 当前实际装配的是直连 DB,还是 Cache → DB decorator 链?

能回答这 10 个问题,基本就能从“认识文件”升级到理解这套服务在运行时到底怎么工作。

深挖一条线:把 POST /v1/products 跑起来跟一遍

主线一节已经把调用链讲清楚了,这里给它一个「可操作」的跟读方案——打开编辑器按这个顺序下断点,比纯读更快建立对象图。

要打开的文件(按顺序)

  1. api/services/sales/main.go —— Composition Root,看 userdb/usercache/userbus/userapp 怎么被组装进 mux.Config
  2. api/services/sales/build/crud.go —— build tag 决定装哪套路由;productapp.Routes() 在这里被调
  3. app/domain/productapp/route.go —— 全文件五行路由,找到 app.HandlerFunc(POST,"v1","/products",api.create, authen, ruleUserOnly)
  4. app/domain/productapp/productapp.go —— create:断在 web.Decode 之后、toBusNewProduct 之前,看原始类型 DTO 如何转强类型
  5. business/domain/productbus/productbus.go —— Create:断在 b.userBus.QueryByIDErrUserDisabled 处,看业务规则
  6. business/domain/productbus/stores/productpg/productpg.go —— Create:断在 commondb.ToDBProductINSERT

断点后回答 6 个问题(见阅读路线):谁启动、谁暴露接口、输入在哪变 Business Model、业务规则在哪、依赖什么抽象、谁实现抽象。能闭眼画出 main → userdb → usercache → userbus → userapp → POST /v1/products → userbus.Create → usercache.Create → userdb.Create → PostgreSQL → Response 即过关。

值得偷师 / 不建议照抄

做法评价我的判断
api / app / business / foundation 四层横切
领域纵切 + 后缀命名(xxxapp / xxxbus
强类型只活在业务层,边缘用原始类型
禁止同层横向 import,用 delegate 解耦
自研 foundation/web 而非用 Gin/Echo
vendor/ 目录 vendoring
把架构约束写成 .agents/skills/ 给 AI 读

我的疑问与待验证

[!warning] 已发现:文档规则与实际代码不一致 .agents/skills/layered-architecture-types/SKILL.md 明确写着「No cross-domain Business imports. A Business domain package (business/domain/<x>bus) must not import another Business domain package」,但实际代码里:

  • business/domain/productbus/productbus.goimport ".../business/domain/userbus",且 Business 结构体持有 userBus userbus.ExtBusiness 字段,Create() 里直接调 b.userBus.QueryByID(ctx, np.UserID)
  • business/domain/homebus/homebus.go → 同样 import userbus
  • userbusvproductbusauditbus 则不 import 任何其他 bus 包

也就是说 userbus 事实上是被其他领域依赖的「根领域」。 待判断:这是遗留未清理,还是规则实际含义为「禁止双向 / 环形依赖」而非「禁止一切横向依赖」?读 business/sdk/delegate 的用法可能有线索。

沉淀出的笔记

相关链接 / 官方入口

入口地址
仓库https://github.com/ardanlabs/service
READMEhttps://github.com/ardanlabs/service/blob/master/README.md
仓库内 AI 协作规则.agents/skills/layered-architecture-types 最值得读)
Ardan Labs 官网https://www.ardanlabs.com/
创建于 2026/8/10 更新于 2026/8/11