docs(system-design): add module communication and isolation standards

Give implementers one entry for narrow ports, the event bus, and the rule that a fault stays inside its module.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 13:22:11 +00:00
co-authored by Cursor
parent 0504884e04
commit e71db3dce6
2 changed files with 152 additions and 0 deletions
@@ -0,0 +1,150 @@
---
title: "模块开发规范:通信契约、接口与故障隔离"
status: active
created: 2026-09-20
updated: 2026-09-20
---
# 模块开发规范:通信契约、接口与故障隔离
> 本文件是开发者入口,不是新的架构 SSOT。
> 决策仍以 [模块化单体 ADR](../adr/2026-09-14-modular-monolith-decoupling.md) 为准。
> 机器强制仍以 [module-boundaries.yaml](./module-boundaries.yaml) 与 `./scripts/arch-check.sh --mode block` 为准。
> 影响面守则不在此重复,见 [partial-deferred 计划 §2](../remediation/2026-09-16-partial-deferred-completion-plan.md)。
2026-09-20 确认:继续模块化单体。模块之间用进程内窄端口或 PostgreSQL 领域事件通信。不新增通用 IPC/RPC,不拆微服务。跨进程只允许两类既有边界:HTTP(Web↔server、server↔PI Runner)和 `cmd/events` 经 PG lease 消费。
## 1. 问题必须停在本模块
一次故障只允许表现为下面之一,不允许连带改第二个领域包才能编译或才能回滚数据:
| 故障位置 | 允许看见的影响 | 禁止 |
|----------|----------------|------|
| 领域不变量 | 本包返回 typed error;同一 UnitOfWork 回滚 | 别的领域包 import 本包实现后自行改表 |
| 用例编排 | 组合根换一个端口实现 | handler 直接 new 领域服务或拿 `*pgxpool.Pool` 写别人的表 |
| 异步副作用 | 该消费者 delivery 重试、死信或 quarantine | 一个消费者失败卡住其他消费者,或在持锁时调用 LLM/HTTP |
| 对外协议 | server 把领域错误映射成固定错误信封 | 把 SQL、路径、prompt、正文放进 HTTP message |
静态底线已经在门禁里:领域实现互不 import(消费端口和稳定词汇除外)、每张表一个写者、每个事件名有生产者/消费者 allowlist。
## 2. 三种通信契约
只允许这三种。新增第四种(包括模块间 gRPC、进程内全局 bus 单例、跨包直接调未导出帮助函数)先写 ADR。
### 2.1 进程内窄端口
消费者拥有接口,组合根注入实现。参照 `internal/app/chatflow/ports.go`:接口只表达这一个用例要的能力。
| 规则 | 说明 |
|------|------|
| 谁定义 | 调用方包,不放在被调用的领域包里“顺便导出” |
| 宽度 | 一个用例一组方法。三个以上消费者语义相同时才提升为公共端口 |
| 禁止万能接口 | 不得出现聚合所有模块方法的 `Store` / `Engine` 门面 |
| 禁止传输细节 | 端口结构体不带 JSON tag,不返回 `http.Response`、SQL 行、`pgx.Tx` |
| 身份 | `Principal` 只来自已认证会话或服务身份,不从请求体覆盖 UserID/ProjectID |
| 装配 | 只有 `internal/app` 与 `cmd/*` 构造实现。`internal/server` 只拿用例或端口 |
### 2.2 领域事件(跨进程总线)
生产路径是 PostgreSQL outbox + `cmd/events` worker(`FOR UPDATE SKIP LOCKED`),不是内存 `MemoryBus`。`MemoryBus` 只用于 dispatcher 特征测试。
契约只有一处运行时副本:`internal/events/registry.go` 的 `StaticContracts()`,并且必须与 YAML 的 event 段 parity。未登记的事件名或消费者在构建期失败。`fav.artifact_staged` 保持 `Enabled: false`,直到独立 FAV 计划放量。
信封至少包含:`event_id`、`event_name`、`schema_version`、`environment`、`project_id`、aggregate 标识与版本、`actor_ref`、`correlation_id` / `causation_id`、`idempotency_key`、`payload_digest`、`occurred_at`。
事件不得携带 secret、token、prompt、正文或绝对路径。大 payload 先冻结为不可变引用,事件只留 digest。
投递是至少一次。消费者用唯一键或 inbox receipt 保证对外只看见一次结果。副作用与 receipt 能进同一数据库事务的必须同事务。永久失败进 dead letter,不得阻塞其他订阅者。
### 2.3 对外 wire DTO
HTTP/SSE 的请求响应只存在于 `internal/server`(及 `internal/stream` 的传输类型)。领域包不 import server。
错误信封沿用已合入的 typed envelope:`code` / `message` / `request_id` / 可选 `details`。`message` 用固定安全文案;`details` 只允许该 code 的公开字段。授权拒绝不得泄露资源是否存在。
PI Runner 继续 typed-result-v1。typed 字段存在但校验失败必须 fail closed,不得降级成旧解析。
## 3. 输入与输出
### 3.1 新增或修改端口时的 checklist
- [ ] 接口放在消费者用例包,方法名是业务动作而不是表操作。
- [ ] 入参是该动作的命令/查询结构;出参是结果或本用例的 typed error。
- [ ] 无 JSON tag,无 `pgx` 类型,无物理路径,无另一领域包的具体 struct(稳定词汇除外)。
- [ ] 错误值留在领域包或用例包;server 只做 `errors.Is` 到 HTTP code 的映射,映射表集中在一处,不散落在每个 handler。
- [ ] 组合根测试用假实现替换端口,不 import 对方的 SQL。
### 3.2 新增事件时的 checklist
- [ ] `event_name` 形如 `<aggregate>.<fact>`,表达已发生的事实,不是命令。
- [ ] `schema_version` 从 1 起;破坏性字段变更升版本,未知 major fail closed。
- [ ] payload 字段写入 `StaticContracts().PayloadFields`,并同步 YAML allowlist。
- [ ] 生产者、消费者包名写入同一条契约;运行时注册必须是静态清单的子集。
- [ ] 领域提交、envelope、delivery 行在同一个 UnitOfWork。缺 `environment` 或 `project_id` 时 fail closed,禁止写死 `"dev"` / `0`。
- [ ] 消费者有幂等键;重复投递测试证明只有一个可见结果。
- [ ] `./scripts/arch-check.sh --mode block` 与 registry parity 测试通过。
### 3.3 稳定词汇
无行为的枚举和值对象才可以进共享词汇。带解析、哈希、路径拼接的函数留在所属模块(现例:`internal/doccontent`),禁止塞进 vocabulary。
在批次 C 完成前,不要新增对 `docgov.State*` / `docgov.Role*` / `docgov.Err*` 的跨包引用。已有引用记在 [耦合审计](../audit/2026-09-20-module-coupling-audit.md),按整改批次收敛。
## 4. 故障隔离红线
1. **表唯一写者**。`INSERT/UPDATE/DELETE` 只能出现在 YAML `table_owners` 指定的包。跨表原子性由用例打开的 UnitOfWork 提供,不是每个模块各开事务。
2. **解包事务的包有名单**。只有登记过的属主可以从 `uow.TxScope` 取出 `pgx.Tx`。server 与 chat 不在此列。
3. **领域实现互不 import**。需要对方能力时定义端口,由 `internal/app` 注入。用子包绕开编译环但保留反向 import,视为未解耦。
4. **持锁期间不做外部 IO**。LLM、PI Runner、对象存储、HTTP、SSE、大文件复制放在提交之后,由 outbox 驱动。
5. **事件消费者 fail closed**。授权失败、环境不符、schema 未知 → quarantine,不当成瞬时错误重试。FAV 消费者保持禁用。
6. **HTTP 不修投影**。GET 不得写数据库或覆盖 head。修复是独立命令并写审计。
7. **单文件变更面**。新生产 Go 文件不超过 800 行。已有超过 1000 行的文件(见审计热点 5)只允许在对应整改批次里拆,不借无关功能 PR 顺手重排。
## 5. 新增功能怎么落
按顺序做。跳过门禁视为未完成,即使功能在本地可用。
### 5.1 新模块
1. 在 short-term 写明唯一写入所有权(表、文件或无)。
2. 在 `module-boundaries.yaml` 的 `imports` 里只放允许依赖的包。默认可依赖 config、稳定词汇、本模块 repository。不可依赖另一个领域实现。
3. 组合根负责构造。server 若需要该能力,只能通过已有用例端口。
4. FEATURE-MATRIX 加行,mutex 与 CURRENT 认领一致。
### 5.2 新表
1. Goose 迁移只 additive。
2. YAML `table_owners` 登记唯一写者。
3. 跨表不变量写在用例的一个 UnitOfWork 里,并写明锁顺序:project/workflow → 聚合根 → version/turn → task run → outbox。
4. 不允许 server handler 出现该表的 SQL。
### 5.3 新事件或新消费者
走 §3.2。消费者状态是 `off | shadow | live`,按环境 + 消费者配置,不靠单个进程的环境变量偷偷打开。shadow 只写对账,不写业务副作用。FAV 不在此流程里放量。
### 5.4 改已有模块
1. 先看 [耦合审计](../audit/2026-09-20-module-coupling-audit.md) 该包是否已有 P1/P2。有则优先缩小依赖,而不是再增加一个具体类型字段。
2. 影响面守则(partial-deferred §2.2)仍然有效:深模块、禁止新增跨模块 import、schema additive、一能力一 flag、事件双登记、改声明范围要留 before/after。
3. 例外若必须保留,写入 YAML `exceptions` 时带 owner、原因、范围、到期日和移除里程碑。禁止无到期日的例外。本轮不新增例外来掩盖热点 1–4。
## 6. 与现有文档的分工
| 问题 | 读这里 | 不要在本文件找 |
|------|--------|----------------|
| 为什么是模块化单体 | ADR | 实施波次 |
| 事务、锁、FAV、授权模型 | [架构设计](./2026-09-14-module-decoupling-architecture.md) | 今日残留热点 |
| 事件状态机与 Track E/S/P | [Phase 2 计划](../remediation/2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md) | 新批次 A–G |
| 改功能时如何收敛影响面 | [partial-deferred §2](../remediation/2026-09-16-partial-deferred-completion-plan.md) | 端口字段清单 |
| 今天还有哪些耦合 | [耦合审计](../audit/2026-09-20-module-coupling-audit.md) | 发布门禁 |
| 接下来哪一批先改 | [分批整改](../remediation/2026-09-20-module-decoupling-batch-plan.md) | 代码 |
## 7. 提交前自检
```bash
./scripts/arch-check.sh --mode block
go vet ./...
go test -short ./...
```
改了事件契约时,额外确认 `TestRegistryParityWithYAML` 在跑到的包里通过。文档-only 变更也跑上述命令,防止索引或生成物被误改。