Ardan Labs Service
Ardan Labs 的 Go 服务参考实现,用「架构层横切 × 业务领域纵切」组织代码;本篇记录它的目录结构、包组织与依赖方向。
[!info] related notes
- 所属 MOC:源码阅读 MOC · Go 服务工程化 MOC
- 理论对照:Go 项目结构与分层 · 项目分层架构 · DDD 架构 · 限界上下文
- 并列案例:Memos
- 相关概念:internal 包 · 包与可见性 · Handler/Service/Repository
Ardan Labs Service
这是什么
Ardan Labs 维护的 Go 服务参考实现 / starter kit,配套其企业培训课程。它不是框架,也不打算被当依赖引入,而是一份「可以整仓复制走再改」的工程样板。
项目自称的架构名是 Domain Driven, Data Oriented Architecture(领域驱动、面向数据的架构)。
[!tip] 理解它的关键 不要去背目录名。它同时沿两个方向组织代码:
- 横向按架构层:
api/app/business/foundation,依赖方向单向向下- 纵向按业务领域:同一个
product概念在每层各有一个包(productapp/productbus)目录树是这两个维度的笛卡尔积投影。
版本快照
| 项 | 值 |
|---|---|
| 分支 | master |
| commit | 43e8ae07abdc(2026-06-22) |
| 抓取日期 | 2026-08-10 |
| module | github.com/ardanlabs/service |
| Go 版本 | 1.26.0 |
| 依赖管理 | vendor/ 目录 vendoring |
| 构建入口 | 根目录 makefile |
Web 层选型:纯 net/http
没有使用任何第三方 Web 框架。 go.mod 的直接依赖里不存在 gin / echo / fiber / chi / gorilla。(go-chi/chi、gorilla/mux 确实出现在 go.mod 里,但都标着 // indirect,是 markbates/goth 和 open-policy-agent/opa 拖进来的传递依赖,业务代码不碰。)
foundation/web 是一个约 5 个文件的自研极薄封装,直接建在标准库上:
| 文件 | 职责 |
|---|---|
web.go | App 类型、路由注册、ServeHTTP、CORS、静态文件服务 |
middleware.go | MidFunc 定义与 wrapMiddleware |
context.go | 往 context 里塞 writer / tracer |
request.go | Decode 解码与校验 |
response.go | Respond 统一编码响应 |
核心就三样东西:
// 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 |
| PostgreSQL | jackc/pgx/v5 + jmoiron/sqlx |
| 鉴权 | golang-jwt/jwt/v4 + open-policy-agent/opa(策略即代码) |
| OAuth | markbates/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 结构体:只能用原始类型(string、int、bool、time.Time、json.RawMessage),禁止出现business/types的强类型business/domain/*/model.go:ID、枚举、分类必须用强类型*db的行结构体:只能用string、sql.Null*、json.RawMessage等原生类型,枚举也存成string- 每次跨界都要走命名转换函数,禁止直接赋值
2. 禁止同层横向 import
business/domain/<x>bus不得 import<y>busapp/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() 统一编码
怎么读第二条、第三条主线
第一条追完后,按这个顺序挑,每条都能暴露一个新机制:
| 顺序 | 主线 | 新增看点 |
|---|---|---|
| 1 | POST /v1/products | 基础三层穿透(上面这条) |
| 2 | GET /v1/products | filter.go / order.go / page 怎么在三层各写一遍 |
| 3 | PUT /v1/products/{product_id} | mid.AuthorizeProduct 如何预取对象塞进 context |
| 4 | tranapp 的路由 | NewWithTx 事务如何贯穿多个 bus 包 |
| 5 | authapp / oauthapp | OPA 策略鉴权与 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.go | Runtime / Composition Root 怎么组装依赖 |
app/domain/userapp/route.go | URL、认证和授权如何挂到 handler |
app/domain/userapp | HTTP DTO 如何 Decode,并转换成 Business Model |
business/domain/userbus | 真正的业务规则与领域数据如何形成 |
userbus.Storer | Business 对持久化能力只依赖什么抽象 |
stores/usercache | Decorator 如何包住底层 Store |
stores/userdb | Business 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(...),
)
不要把 usercache 和 userdb 理解成两个并列 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 个问题
- 谁启动它? → 哪个
api/services/*runtime。 - 谁暴露接口? → 哪个
app/domain/*app。 - 输入在哪变成 Business Model? →
toBus...一类转换函数。 - 真正业务规则在哪? → 对应
business/domain/*bus。 - Business 依赖什么抽象? →
Storer、扩展接口或其他明确端口。 - 最终谁实现这个抽象? → Cache / DB / 外部客户端等具体 adapter。
如果这 6 个问题可以不看代码回答出来,这条线才算真正掌握。
推荐的完整学习顺序
按“每条只多引入一个新机制”的顺序走:
POST /v1/users:先打通 Runtime → App → Business → Store → DB。GET /v1/users/{id}:理解 Query、Cache hit / miss 和返回模型转换。PUT /v1/users/{id}:理解更新、对象预取和缓存更新/失效。DELETE /v1/users/{id}:理解删除后的 delegate / 领域间通知。POST /v1/products:把同一套结构迁移到第二个领域,并观察 Product 对 User 能力的依赖。tranapp:理解跨领域事务以及NewWithTx如何重组 Business。authservice:理解独立 runtime、远程 AuthClient 与本地 Business 的边界。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(...) - 认证:读
Authorizationheader - 请求级状态:沿
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.queryByID 或 update 里只看到:
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.Store 与 userdb.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;复杂列表 Query 与 Count 通常直接下沉到真实 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 个运行时问题:
- 这条 Route 注册时包了哪些 middleware?
- Actor、Target、Owner 分别从哪里得到?
- 哪些对象是 middleware 预取后放进 Context 的?
Storer当前实际装配的是直连 DB,还是Cache → DBdecorator 链?
能回答这 10 个问题,基本就能从“认识文件”升级到理解这套服务在运行时到底怎么工作。
深挖一条线:把 POST /v1/products 跑起来跟一遍
主线一节已经把调用链讲清楚了,这里给它一个「可操作」的跟读方案——打开编辑器按这个顺序下断点,比纯读更快建立对象图。
要打开的文件(按顺序):
api/services/sales/main.go—— Composition Root,看userdb/usercache/userbus/userapp怎么被组装进mux.Configapi/services/sales/build/crud.go—— build tag 决定装哪套路由;productapp.Routes()在这里被调app/domain/productapp/route.go—— 全文件五行路由,找到app.HandlerFunc(POST,"v1","/products",api.create, authen, ruleUserOnly)app/domain/productapp/productapp.go——create:断在web.Decode之后、toBusNewProduct之前,看原始类型 DTO 如何转强类型business/domain/productbus/productbus.go——Create:断在b.userBus.QueryByID与ErrUserDisabled处,看业务规则business/domain/productbus/stores/productpg/productpg.go——Create:断在commondb.ToDBProduct与INSERT处
断点后回答 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.go→import ".../business/domain/userbus",且Business结构体持有userBus userbus.ExtBusiness字段,Create()里直接调b.userBus.QueryByID(ctx, np.UserID)business/domain/homebus/homebus.go→ 同样 importuserbususerbus、vproductbus、auditbus则不 import 任何其他 bus 包也就是说
userbus事实上是被其他领域依赖的「根领域」。 待判断:这是遗留未清理,还是规则实际含义为「禁止双向 / 环形依赖」而非「禁止一切横向依赖」?读business/sdk/delegate的用法可能有线索。
沉淀出的笔记
相关链接 / 官方入口
| 入口 | 地址 |
|---|---|
| 仓库 | https://github.com/ardanlabs/service |
| README | https://github.com/ardanlabs/service/blob/master/README.md |
| 仓库内 AI 协作规则 | .agents/skills/(layered-architecture-types 最值得读) |
| Ardan Labs 官网 | https://www.ardanlabs.com/ |