第二季第 4 篇。拆解对象:DeepFlux 仓库里一道 264 行的架构检查配置(
server/.go-arch-lint.yml)和它的 CI 门禁。这篇不要求你懂 Go 或 DDD,用到的概念当场讲,所有的"我跑过"都带 2026-09-04 的时间戳。
先交代真实状态。今天(2026-09-04)我在仓库根跑了一条命令:
$makeddd-check module: github.com/deepflux-cn/deepflux linters: On|Base: component imports# always onOff|Advanced: vendor imports# 开关在配置里,未开Off|Advanced: method calls and dependency injections# deepScan 未开OK - No warnings found输出只有一行绿的OK - No warnings found。但为了让你知道这一行绿值多少钱,得先把背景讲一下:这个"OK"背后是六条铁律,而这六条铁律在几个月里经历过"写在文档里 → 被人违反 → 机器强制"的三段式进化,中间还有过一次真实的"假绿"事故。
一、先弄懂三个词
DDD(领域驱动设计):一种组织代码的方法,核心观点是"代码的结构应该匹配业务的结构"。订单相关的代码放一起,库存相关的放一起,两家之间别乱串门。它不像"代码必须缩进 4 空格"那样能被编译器检查——它一开始只是写作风格约定,靠人自觉。
限界上下文(Bounded Context,下文简称 BC):DDD 里的"业务子域"。一个订单系统可能有订单、库存、客户、物流四个 BC,各自有各自的模型和规则。上面说的"别乱串门",串门闹得最凶的就是 BC 之间。
架构检查器(go-arch-lint):一个 Go 命令行工具,输入一份 YAML 配置(“哪些目录属于哪个组件、谁允许依赖谁”),输出"谁违反了依赖规则"。它读的是源码的 import 语句——每个文件开头那一排 import 就是它的证据。所以它检查的是"编译期可检查的红线":过不了这一关,代码照样能编译、能跑,但架构已经走样了。
打个比方:DDD 像小区规矩(“邻居之间不走私货”),go-arch-lint 像小区的门卫(每个快递箱都过一遍,该谁家的放谁家)。规矩靠人记总会忘,门卫记性不会忘——这就是本篇的主题:把铁律交给机器,而不是交给自觉。
二、六条铁律:谁在守它们
仓库里的架构文档(docs/architecture/ddd-bounded-contexts.md§3)列了一张 D 规则表,标题叫"D-rules(6 条编译期可检查的红线)"。原文如下,我按"当前实际由什么守"分了三档:
| # | 铁律 | 内容 | 本文档标注的检查方式 | 实际由什么守(2026-09-04 核实) |
|---|---|---|---|---|
| D1 | domain/只能依赖domain/自己 + 标准库 | 领域层不碰外部框架、不碰别的层 | go-arch-lint | go-arch-lint(机器) |
| D2 | application/不能 importinterfaces/ | 应用层不知道 HTTP/gRPC 的存在 | go-arch-lint | go-arch-lint(机器) |
| D3 | 聚合根之间通过 ID 引用 · 不持有彼此指针 | 领域对象不互相持有对方对象 | code review · 模板示例 | 模板 + 评审(人) |
| D4 | 跨 BC 协作只走 gRPC + ACL | BC 之间不能 import 对方的代码包 | grep + 评审 | go-arch-lint(机器) |
| D5 | Repository 接口写 domain · 实现写 infrastructure | 数据存取接口与实现分层存放 | 模板 | 模板 + 评审(人) |
| D6 | 领域事件先写 outbox | 业务变更与投递事件同事务落库 | code review | 集成测试(机器) |
几个如实说明,这篇的分量就在这些"如实"里:
1. "D4 靠 grep + 评审"这句已经是历史了。文档表格里 D4 那格写着"grep + 评审",但现状是 go-arch-lint 的配置里每个 BC 的deps白名单都不含其它 BC——跨 BC 的 import 一出现,工具直接报违规。文档滞后于实现,这是本篇要反复强调的现象。
2. D6 那格写着"code review",实际已有集成测试。2026-06-02 起,仓库里有一组专门验证 D6 的集成测试(server/tests/d6atomicity),方法是"负向验证":临时给events_outbox表加一个CHECK (false)约束(每次插入必失败),然后调用各 BC 的事务方法,断言聚合行的落库也被回滚——事件写不进去,业务数据也不许留下。这节下面的"五"里我跑了一遍,4 个用例全过。文档表格没更新(检查方式栏还是"code review"),但机器已经在守。
3. "编译期可检查"这个措辞有水分。D3、D5 至今没有机器门——"聚合根之间只通过 ID 引用"这种规则,import 图里看不出来(两个对象都在同一个 package 里,文件间可能根本不互相 import)。所以六条铁律的真实格局是:3 条机器守(D1/D2/D4 import 红线)、2 条人守(D3/D5 模板)、1 条测试守(D6)。这是一句比"六条编译期可检查的红线"更准确的话。
三、机器是怎么守的:264 行配置的解剖
门卫的"记性"放在server/.go-arch-lint.yml(264 行,2026-05-30 引入,commit 消息原话"SSRF guard, billing RLS, per-BC arch-lint, coverage"——彼时就是冲着"per-BC"去的)。它的核心结构只有两个部件:
部件一:组件(components)——给每个 BC 的四层各起一个名字,用目录通配符圈地。
agent_domain:{in:internal/agent/domain/**}agent_application:{in:internal/agent/application/**}agent_infrastructure:{in:internal/agent/infrastructure/**}agent_interfaces:{in:internal/agent/interfaces/**}15 个 BC(agent/attachment/data/audit/auth/hook/kb/memory/skill/tenant/tool/sop/orchestration/marketingcode/contenthub,audit/hook/tool/skill 在 2026-06-09 合并进 agent 统一体但仍各自建模)× 4 层,共 60 个层组件,再加共享组件、内核助手(kernels)、适配器豁免与横切组件(x_*),合计 87 个组件条目。注意这比"一租户一目录"的简单版难得多——每个 BC 的内部 layering 是独立建模的,agent 的 domain 允许依赖什么,与 kb 的 domain 允许依赖什么,是两行独立配置。文档 §3 里的示例配置(全局只有一个domain组件)是教学版;生产版早已 per-BC 化。
部件二:依赖白名单(deps)——每层"只允许依赖这些组件",没写的就是禁地。
agent_domain:{mayDependOn:[agent_domain,pkg]}agent_application:{mayDependOn:[agent_application,agent_domain,pkg,sharedinfra,x_observability]}读法:agent_domain只允许依赖「自己(同目录其它文件)+ pkg(共享包)」;agent_application在自己的层之外只能碰 domain、pkg、sharedinfra(LLM/存储/MCP 等共享基础设施)、x_observability(日志指标)。
这套白名单制有个先天的好设计:它不是审计员,是法官——任何没写进白名单的依赖出现,直接判违规,不需要配置方证明"这个 import 有问题",只需要证明"这不在清单里"。判断题变成了集合题。
第三个部件是"刻意豁免"清单,这是整份配置最有味道的地方。配置里有一批组件是"特殊身份":
# 刻意豁免:hooklocal 是单体 df_server 模式下的进程内 hook 适配器,# 有意复用 hook BC builtin handlers(见 runner.go 文件头);Sprint 2+ 改 gRPC。# 仅此目录可依赖 hook BC,其余 agent_infrastructure 仍受 D4 约束。agent_hooklocal:{in:internal/agent/infrastructure/hooklocal/**}类似的还有agent_hookdispatch(进程内 hook 分发)、agent_skilladapter(进程内技能适配)、agent_toollocal(进程内工具适配)、auth_localauth(bcrypt 口令哈希助手)。它们都是"合并 BC"时代(2026-06-09 几个 BC 合并进 agent 单体)留下的进程内适配层——正式版走 gRPC,单体模式先走函数直调,所以它们被特别放行"可以依赖另一个 BC"。
这张豁免清单的价值在边界:到底允不允许跨 BC 协同?答案不是"一票否决",而是"只有这几条走廊允许,每条走廊都有注释说明为什么"。架构规则一旦可以全部豁免,它就是一纸空文;但一张"无豁免"的规则表,又会在单体模式逼出"绕开规则"的脏活。豁免清单本身就是规则的组成部分。
还有几类"非 BC 组件":pkg(共享包,idgen 等)、sharedinfra(embedding/llm/prompt/storage/mcp,注释标明"mcp 红线#3 强制")、generated(gRPC 生成码,D4 的唯一合法跨 BC 通道)、以及cmd和一堆x_*(gateway/observability/billing/uiserve 等横切关注点)——横切组件标了anyProjectDeps: true:随便依赖谁,因为它们本来就负责把大家接起来。
四、门卫抓到过的真问题:五个案底
配置不是一次写对的。按时间顺序,门卫和它的管理员都摔过跤,每一跤都有一个 commit 编号可查:
案底① 幽灵 BC(2026-06-21,e155af9e):配置里声明了license_*、llmgateway_*、x_security三个组件,但internal/下根本没有对应目录——go-arch-lint 对不存在的目录直接报not found。commit 消息原话:“删除幽灵 BC 组件”,修复后本地输出"OK - No warnings found"。配置撒谎(声明了不存在的 BC)跑不掉,这是工具给的第一课:声明即承诺。
案底② 18 条 notice(2026-07-02,45d73af6):新引入的 sop BC 没有注册进 archfile,它下面的文件不被任何组件圈住,导致 13 条 not-attached(“这堆文件归谁管?”)通知;加上 agent_skilladapter/agent_toollocal 的 5 条依赖通知,共 18 条。修复动作就是两件事:把 sop 四层注册进配置;给两个适配器按 hooklocal 的先例建"窄组件"(只放行该目录依赖 skill/tool BC)。新 BC 忘了登记自己——门卫一清点就发现少人了。
案底③ 假绿事故(2026-07-21,b745303b,这是全篇最值得细读的一个 commit):commit 消息第一句:“发现 make ddd-check 本地空操作(go-arch-lint 未装静默跳过),前序’全绿’实为空过;本次装 go-arch-lint 后 ddd-check 首次真正 exit=0”。注意时间差:门禁 2026-05-30 装好,到 07-21 才发现本地这道门根本没在把关——工具没装,make ddd-check一句"工具未安装"就退出了,退出码还是 0(成功)。"全绿"是从没跑过的绿。同批修复还碰到 kb 的 5 个叶子助手包(chunking/tokenize/citation/filecheck/kernel)未注册,14 条 notice 归零。
案底④ 门卫自查手册(2026-07-31,Makefile 注释):code-review 发现本地make lint/make ddd-check的写法是A && B || echo "未安装"——这个模式有个致命属性:B 失败时也走||分支,于是"有 lint 错误"被打印成"工具没装",退出码还是 0。一整个会话都以为本地装不了工具,实际上它在报错。另发现command -v找不到装在$(go env GOPATH)/bin里的工具(go install的默认落点不在 PATH)。修复:改成单行if/else,且检查 GOBIN 目录。
案底⑤ CI 侧的隐藏链(2026-08-11,ci.yml 注释):CI 里装 go-arch-lint 的 step 原来在server/(模块根)里跑go install——这会把工具及其依赖的 hash 追加进server/go.sum,破坏 release 前置的指纹校验(go.sum 被污染 → 指纹红 → Lint + migration 任务长期失败)。修复:(cd /tmp && go install ...@v1.18.0)在隔离目录安装,并钉死版本 v1.18.0(注释:“D 规则判定逻辑在工具里,随版漂移同理”——版本钉住,铁律才是铁律)。
五个案底合起来是同一课:一道门值不值钱,取决于它有没有被"失控时静默"的路径。工具没装=静默通过(案底③④),门引用的路径不存在=静默成功(案底①),门装在有毒的环境=把别的好门拖红(案底⑤)。所以这个仓库后来加了"审计门的门":deploy/scripts/audit-ci-refs.sh(幽灵门清账,防"门被 label 遮蔽后引用失效")和 Gate Registry 配对("本仓一共有几道门、各守什么"登记成表)。
五、我今天跑了三个实验
写这篇之前,我在同一台机器上做了三件事,都留下原始输出:
实验一(真绿):make ddd-check→OK - No warnings found。注意门卫这版是走$(go env GOPATH)/bin找的(案底④的修复生效了),工具版本 v1.18.0 与 CI 钉版一致。
实验二(受控违规):我把配置复制到/tmp/arch-tight-d1.yml,把agent_domain的白名单从[agent_domain, pkg]收紧成[agent_domain](不许再用共享包),然后跑检查——收紧的是规则(不是代码),源代码零改动:
Component agent_domain shouldn't depend on github.com/deepflux/deepflux/internal/pkg/idgen in .../internal/agent/domain/model/interrupt.go:7 Component agent_domain shouldn't depend on github.com/deepflux/deepflux/internal/pkg/idgen in .../internal/agent/domain/model/message.go:7 Component agent_domain shouldn't depend on github.com/deepflux/deepflux/internal/pkg/idgen in .../internal/agent/domain/model/session.go:9 -- total notices: 3这就是一门"真"门的样子:违规清单精确到文件+行号+完整 import 的包路径,3 处,不多不少。同时也演示了"白名单允许共享组件"的必要性——domain 用 UUID 生成器(idgen)是合法依赖,配置不写白名单它就被误判。这也是"零外部 import"在实践中的取舍(第五节细说)。
实验三(D6 集成测试):go test -tags=integration ./tests/d6atomicity/,连上本机 PostgreSQL 18.4 的deepflux_test库:
--- PASS: TestD6_Auth_PersistTx_AtomicRollback (1.74s) --- PASS: TestD6_KB_DocumentSaveTx_AtomicRollback (0.36s) --- PASS: TestD6_Memory_SaveTx_AtomicRollback (0.95s) --- PASS: TestD6_Tenant_UpgradePlan_AtomicRollback (0.85s) PASS ok github.com/deepflux/deepflux/tests/d6atomicity 4.181s4 秒跑完,4 个 BC(认证/知识库/记忆/租户)的用例全绿。这里要强调它是负向验证——不是"跑通了就是对的",而是"故意让事件写入失败,断言业务数据也被回滚"。sabotageOutbox给events_outbox加CHECK (false) NOT VALID(NOT VALID关键词是关键:跳过存量行校验,只卡新写入),跑完每个用例healOutbox删掉约束还给数据库原样。顺手一提:这组测试是 2026-06-02 落地的,与第 103 篇那场"贫血→充血"重构是同一天——那天还有一条整改线在悄悄收尾(D6 事务化 + 三处 schema↔模型契约修复)。
六、机器守不了的,以及文档漂移
门卫只守 import。以下这些是诚实记录"哪些规则其实没被机器守":
1. D1 的"零外部 import"字面已破。docs/DESIGN.md里写编排 BC 的控制面原则是"D1:零外部 import, Eino 类型绝不进 domain"。但配置里所有 domain 组件都标着anyVendorDeps: true——vendor(第三方库)依赖全局放行,domain 里早年就引进了github.com/google/uuid(值对象生成器)。所以机器的真实语义是"不依赖本仓库其它 BC/其它层",不是"零外部依赖"。Eino(编排引擎)确实没有进 domain——它连一个 import 都没有,痕迹只出现在注释里("einoadapter 会把 . sanitize 成 _"这种说明文字)——但那是代码评审和模板守出来的,不是门卫守的。区别很大:门卫守的规则坏了会被拦住,人守的规则坏了只能靠下一次 review 看见。
2. D3/D5 仍无人守。“聚合根之间只持 ID 指针”“Repository 接口在 domain、实现在 infrastructure”,这两个无论怎么配 YAML 都拦不住(同一个 BC 的文件之间不 import 也会违反前者)。它们靠"起手式拷模板"(文档 §10 给了新 BC 的骨架)和 code review。
3. 文档漂移(这节的"超诚实"部分)。我核对了三处不一致,都如实列出:
docs/architecture/ddd-bounded-contexts.md表首注释说"红线见CLAUDE.md §2 D1-D6",但 CLAUDE.md 在 2026-07-18 被重写(430 行 → 5 行),§2 已不存在——引用悬空(好在 §3 自带完整表格,内容没丢)。- 上表的检查方式栏:D4 写"grep + 评审"(实际是 go-arch-lint 机器守)、D6 写"code review"(实际 6 月已有集成测试)。
- 连我这篇的规划条目里都有一处修正:规划时写"tests/d6atomicity 五个包",实际是一个包(package d6atomicity)里的5 个测试文件、4 个 BC 用例(atomicity_test.go 是注入基础设施,不出用例)。
文档滞后是常态,但这正是"机器铁律"与"文档铁律"的分水岭:文档错了会在某天误导一个新手,机器错了会在 CI 上立刻拦下一次违规。
小结:门禁的价值不在守住,在于"让违规变贵"
- 白名单制是最省心的架构门。不用枚举所有违规形态,只要写清"谁允许依赖谁",其余一概不许——配置即法律,豁免清单即司法解释,每条豁免都带理由注释。
- 机器守的 3 条 + 测试守的 1 条 + 人守的 2 条,是真实现状。“六条编译期可检查的红线"这句话宣传价值高于准确度;准确的版本是"其中 4 条有了机器兜底,2 条仍靠人”。
- 门本身是需要被审计的。假绿事故(工具没装、静默通过)、幽灵 BC(声明了不存在的目录)、go.sum 污染(安装位置不对)、规则版本漂移(不钉版)——四次案底全是被门自己"静默"出来的。所以这仓库最终配了"审计门的门":幽灵门清账脚本 + Gate Registry 配对表。让 CI 替你骂人之前,先保证 CI 不会替你装聋。