把规则搬回家:三个 BC 的贫血→充血重构实录(第103篇)

第二季第 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 天"------写在了"处理查询"的代码里,用的是硬编码魔数 2005030 * 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
}

三个变化值得单独说:

  1. 魔数命名了200MaxListLimit50DefaultListLimit------以后再改"最大多少条",改的是常量,不是三处神奇数字。
  2. 规则归位:查询边界是"审计这个业务"的规则,不属于"怎么处理查询请求"。它现在住在 domain 包里,离业务本身更近。
  3. 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。按协议走完一次,同一套动作复制到三个上下文。

小结:搬家是手段,不是目的

  1. 规则住对地方的价值,是"只写一次、大家同答"。 超时、禁用、边界------任何业务规则一旦被第二个流程需要,把它留在调用点就是负债。
  2. 重构的成败在"行为不变"与"契约不变"两把尺子。 三个 commit 都用同一句"契约零变化"+ 逐项单测来收口;想抄这种作业,先得把"出口契约"钉在 commit 消息里。
  3. 背后有个 loop 协议撑着:T1→T2→T3 三连顺序执行、完成判据卡死、不达标不勾。批量重构最大风险是一鼓作气把三个 BC 都改了却合不在一起------协议给它上了"一个过完再下一个"的闸门。
相关推荐
kakawzw1 小时前
Netty源码笔记
java·服务器·后端
明月_清风1 小时前
算法时间复杂度:给小白的一堂"算快慢"课
后端·算法
Nturmoils1 小时前
内网穿透原来这么简单:Natapp 从注册到公网访问完整教程
后端
寻求出路的程序媛1 小时前
分布式 & 高性能 & 高可用 体系、学习重点、面试点
分布式·后端·面试·性能优化
SimonKing1 小时前
GenOffice上手指南:免费替代Word+PPT+Excel的AI办公神器
java·后端·程序员
掘金者阿豪1 小时前
Let‘s Encrypt 证书到底会不会自动续期?从一次服务器迁移后的证书排查说起
后端
Zane19942 小时前
手写"判断key存不存在"的模板代码太烦?一文讲透 collections 四件套
后端·python
joinwell522 小时前
Agent 中断后,原任务如何安全接管?从恢复标记到效果事实
人工智能·后端·架构
sp422 小时前
羽量级的 Java Bean 实体校验器
后端