第二季第 3 篇。拆解对象:2026-06-02 同一天的三个 commit------一场"充血化"重构,一次把业务规则从代码里搬回领域模型的搬家。这篇不要求你懂 Go 或 DDD 术语,用到的概念当场讲。
先交代真实状态。2026-06-02 的 18:20、18:42、19:19,仓库里连续提交了三个 refactor commit:
| commit | 内容 |
|---|---|
0d70d4dd |
refactor(tool):领域规则下沉聚合/领域服务 |
b02da102 |
refactor(hook):领域规则下沉聚合/领域服务 |
355737eb |
refactor(audit):查询边界策略下沉领域 Filter |
三个 commit 干的是同一件事:把散落在"编排层"代码里的业务规则,搬回领域模型里。这三个 BC(限界上下文,可以理解成三个业务子域:工具、钩子、审计)的重构实录,就是这篇的全部素材。
先问一个问题:规则为什么会"散落"?要回答,得从代码组织方式讲起。
一、先弄懂三个词
领域模型:业务对象在代码里的样子。比如"一个工具"有名字、有超时时间、能不能被调用------这些字段和规则合在一起,构成"工具"这个领域模型。
贫血模型(anemic domain model) :一个只有"数据"、没有"行为"的模型。类/结构体里全是字段和 GETTER/SETTER(单纯的读和写),没有方法;业务规则(比如"超时 0 秒意味着不限制""被禁用的工具不能调用")不在模型里,而是写在模型外面的其他代码里。
充血模型(rich/aggregate model) :把字段和行为放一起。模型既保存数据,也提供表达业务规则的方法------"这个工具现在能不能被调用?"直接问模型自己,而不是让外面的人看字段自己判断。
打个比方:贫血模型是信息卡 (只写名字、身高、血型),规则放在别人手里;充血模型是有值班护士的病房------卡上的数据,加上"这个人此该不该吃素"这类判断,都由同一个实体回答。
为什么约定俗成后仍会有人反复搞混?因为"贫血"确实写起来快:业务规则很小时放在处理函数里最短。它的问题是规则会被复制、会互相打架、会漏------这正是本次重构想修的。
二、被搬走的规则长什么样:一次典型"搬家"
最能说明问题的,是 audit 这个 commit 里"查询边界"规则的搬家。重构前,代码是这样:
go
// server/internal/audit/application/query/list.go(重构前)
func (h *ListHandler) Handle(ctx context.Context, f domain.Filter) ([]*model.Entry, string, error) {
if f.Limit <= 0 || f.Limit > 200 {
f.Limit = 50
}
if f.Since.IsZero() {
f.Since = time.Now().Add(-30 * 24 * time.Hour) // 默认查 30 天
}
return h.q.List(ctx, f)
}
看懂了吗:"查询边界"这条业务规则 ------"单页最多 200 条,超出或没填就默认 50 条;没填起始时间,就默认回退 30 天"------写在了"处理查询"的代码里,用的是硬编码魔数 200、50、30 * 24 * time.Hour。这段代码在应用层(编排层)。
重构后,列表处理函数只剩一行:
go
func (h *ListHandler) Handle(ctx context.Context, f domain.Filter) ([]*model.Entry, string, error) {
// 查询边界规整(分页钳制 + 默认窗口)由领域策略 Filter.WithDefaults 决定
return h.q.List(ctx, f.WithDefaults(time.Now()))
}
真正干活的规则被搬进了新文件 server/internal/audit/domain/filter_rules.go:
go
const (
DefaultListLimit = 50 // 未指定/越界时的默认分页大小
MaxListLimit = 200 // 单页最大条数 · 防止无界扫描
DefaultListWindow = 30 * 24 * time.Hour // 未指定 Since 时的默认回看窗口
)
func (f Filter) WithDefaults(now time.Time) Filter {
out := f
if out.Limit <= 0 || out.Limit > MaxListLimit { out.Limit = DefaultListLimit }
if out.Since.IsZero() { out.Since = now.Add(-DefaultListWindow) }
return out
}
三个变化值得单独说:
- 魔数命名了 :
200→MaxListLimit、50→DefaultListLimit------以后再改"最大多少条",改的是常量,不是三处神奇数字。 - 规则归位:查询边界是"审计这个业务"的规则,不属于"怎么处理查询请求"。它现在住在 domain 包里,离业务本身更近。
- immutable(不可变) :
WithDefaults不改原值,返回一个新副本------调用方传入的 Filter 不被副作用污染。连now也是调用方传入的:注释写明"由调用方注入以保证可测/确定性"------测试时可以固定时间点,不必依赖真实时钟。
三、搬家清单:三个 BC 各搬了什么
这场重构不止 audit 一处,三个 commit 是三个 BC 各搬走一批规则。逐一看(全部真实,出自 commit 消息与代码)。
tool(工具 BC) ------ 一个 commit 0d70d4dd(+502/−144 行)搬走:
EnsureInvokable():**"被禁用的工具不可调用"**原先是if !tool.Enabled() { return errors.New("tool disabled") }写在调用入口里------现在变成工具聚合自己做决定,返回领域错误ErrToolDisabled。EffectiveTimeout():**"超时 0 表示不限时"**原先也是入口代码里的if tool.TimeoutMs() > 0 { context.WithTimeout(...) }。StartInvocation()/Complete():一次工具调用的记录 ------开始、结束、时延计算、结果截断(MaxResultBytes = 16384)、出错标记,原先全在调用流程里手动拼;现在"开始一次调用""完成一次调用"是值对象自己的行为。NewHTTPTool/ValidateHTTPEndpoint:**"HTTP 工具的危险等级=caution、端点校验"**原先是注册流程里的策略,现在变成领域工厂(专门负责"按规则造出对象"的构造函数)。
hook(钩子 BC) ------ 一个 commit b02da102(+159/−20 行)搬走:
Timeout():钩子执行的超时换算。DecisionOnError():"runner 报错时应该放行还是拒绝" (fail_open 的裁决)------原先if hk.FailOpen() { continue }写在 dispatch 流程里,搬后变成钩子自己的裁决方法,返回DecAllow/DecDeny。ExecutedEvent()/DeniedEvent():审计事件的塑形 ------"这次 hook 执行了什么决定、为什么拒绝",原先 dispatch 里手拼事件结构体,注意一个细节:全局钩子的 tenantID 是空的,事件必须记录实际触发租户------这类"谁"的记录规则,搬进了聚合后由方法自己填准。
audit(审计 BC) ------ 一个 commit 355737eb(+112/−12 行)搬走:
Filter.WithDefaults():第二节已详看,分页钳制 + 默认窗口。- 审计是"事件接收端",没有对外事件(D6 不适用),故"契约零变化"对它是 N/A------这一条也记在 commit 消息里,说明作者是逐项核对过的。
四、怎么证明"搬对了":三条铁证
搬东西最怕的不是搬错,是"搬完看起来一样,行为却悄悄变了"。这场重构用三重证据回答:
铁证一:出口契约零变化。 三个 commit 消息里都写了同一种句式:tool 是"对外 REST/gRPC 契约与事件类型零变化";hook 是"对外契约与事件类型零变化";audit 验证过无对外事件。规则在小区里搬家,住户体验(接口、事件)不变。
铁证二:规则行为有单测兜底。 重构时新补了 domain 单测------这正是重构的最佳副产品:规则一旦从编排层搬进模型,就可以直接对模型写测试,测试不再需要模拟整个调用链。真实代码里有:
go
TestEnsureInvokable_DisabledTool // 禁用工具 → 返回 ErrToolDisabled
TestEffectiveTimeout_ZeroAndSet // 0 → 不限时;30ms → 30ms
TestComplete_TruncatesOversizedResult // 超长结果带标记截断
最后一个值得一提:真实的截断不是裸切下 16384 字节 ,而是 MaxResultBytes + len("...[truncated]") ------超长结果切到 16384 字节后再补一个"...truncated"标记,让消费方明确知道"结果被截了,不是完整内容"。小而关键的判断。
铁证三:我复跑了一遍。 我在本机跑了这三个 BC 的短测试(go test ./internal/tool/... ./internal/hook/... ./internal/audit/...),31 个包全绿------当天 commit 消息里的"157+46(race) 全绿"等数字演变成了今天的"31 包 ok"。既然只读跑了、还绿着,就如实记在这里。
五、为什么规则会散落:一次真实的"病灶"解剖
贫血模型的规则为什么会被写在编排层?从这次重构看,不是设计者偷懒,是规则本身生于流程、长在流程。
以"工具超时"为例:一开始是"调用工具"这个流程里想到"得防超时",于是 if tool.TimeoutMs() > 0 写在调用句柄里。没人意识到------超时是个业务属性 (0 表示不限时),不是流程属性。规则在流程里住久了,就有了一个典型症状:它会出现在第二个流程里------工具调用场景两次、"签名"或"重试"想用却因为规则藏在编排层而找不到,于是复制一份、或只调一次、或干脆没加。规则散落,就是"第一次没归位"的复利。
本 commit 的修法是把规则降落到聚合:模型自带"你该多长超时",任何流程调用 EffectiveTimeout() 都是同一份答案,从此没有第二份。
六、这场重构是怎么被推进的:一次自主 loop 的三连击
最后补一个真实背景:三个 commit 不是人手刷出来的一气呵成------它们是一个自主 loop 按任务清单协议推进的 。docs/agent-loop/BACKLOG.md 如今仍赫然列着:
css
- [x] T1 · tool BC 充血化
- [x] T2 · hook BC 充血化
- [x] T3 · audit BC 充血化
紧跟上来的还有 T4(llm domain 评估)、T5--T10(前端 + 四个 SDK)。该文件 §0 记录了执行协议:每轮读最上面的未完成 [ ]、按【完成判据】逐条核对、任一不过就不勾、连续两轮无法推进就喊人工。这是一场被写进协议的重构------标题里的"实录",准确说是"三连协议下的实录":T1 先搬 tool,验证全绿;T2 照抄搬 hook;T3 照抄搬 audit。按协议走完一次,同一套动作复制到三个上下文。
小结:搬家是手段,不是目的
- 规则住对地方的价值,是"只写一次、大家同答"。 超时、禁用、边界------任何业务规则一旦被第二个流程需要,把它留在调用点就是负债。
- 重构的成败在"行为不变"与"契约不变"两把尺子。 三个 commit 都用同一句"契约零变化"+ 逐项单测来收口;想抄这种作业,先得把"出口契约"钉在 commit 消息里。
- 背后有个 loop 协议撑着:T1→T2→T3 三连顺序执行、完成判据卡死、不达标不勾。批量重构最大风险是一鼓作气把三个 BC 都改了却合不在一起------协议给它上了"一个过完再下一个"的闸门。