关于 codebase-design
这项软件设计技能指导开发者构建接口精简、内部行为丰富的深模块。它提供统一术语和设计原则,以改善调用方的能力、维护局部性和可测试性。
主要功能
- 01深模块设计指导
- 02统一接口与接缝术语
- 03选择适配器和端口的原则
- 04通过依赖注入提升可测试性
- 05Go 测试中的 testify 断言指南
适用场景
- 01围绕清晰模块重构代码
- 02评估接口复杂度
- 03判断接缝是否需要端口
- 04围绕真实依赖设计测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ lwlee2608/ agent-skills --skill codebase-design安装后在 Claude Code 中输入/codebase-design确认已加载。
附带文件DEEPENING.mdDESIGN-IT-TWICE.md
详细介绍
代码库设计
设计深模块:在简洁的接口后封装大量行为,放在清晰的接缝处,并且可通过该接口进行测试。设计或重构代码时,始终使用这套语言和这些原则。目标是为调用方提供杠杆效应,为维护者提升局部性,并让所有人都能进行测试。
术语表
请准确使用以下术语:不要替换成“组件”“服务”“API”或“边界”。统一用语正是关键所在。
模块:任何具有接口和实现的东西。刻意不限定规模:可以是函数、类、包或跨层切片。避免使用:单元、组件、服务。
接口:调用方必须了解的所有信息,才能正确使用模块:包括类型签名,也包括不变量、顺序约束、错误模式、必需配置和性能特征。避免使用:API、签名(范围太窄,它们仅指类型层面的表面)。
实现:模块内部的内容,即它的代码主体。它不同于适配器:一个东西可以是实现很大的小型适配器(一个 Postgres 仓库),也可以是实现很小的大型适配器(一个内存中的伪实现)。当讨论接缝时使用“适配器”;其他情况使用“实现”。
深度:接口所带来的杠杆效应。调用方(或测试)每了解一单位接口,就能运用多少行为。大量行为隐藏在简洁接口后面的模块是深模块;接口几乎和实现一样复杂的模块是浅模块。
接缝 (Michael Feathers):一个可以在不修改该位置的情况下改变行为的地方;也就是模块接口所在的位置。接缝放在哪里本身就是一项设计决策,与接缝后放什么内容是两回事。避免使用:边界(这个词被 DDD 的限界上下文占用了)。
适配器:在接缝处满足接口的具体事物。描述的是角色(它填补什么位置),而非实质(它内部是什么)。
端口:专门用于在接缝处替换适配器的接口(Ports & Adapters)。端口是一个接缝,再加上至少有两个适配器的预期,通常分别用于生产环境和测试。端口可以是语言层面的 interface,但并非每个接缝都需要端口。
杠杆效应:调用方从深度中获得的收益。每了解一单位接口,就能获得更多能力。一个实现能在 N 个调用点和 M 个测试中发挥作用。
局部性:维护者从深度中获得的收益。变更、缺陷、知识和验证集中在一处,而不是散布到各个调用方。修复一次,处处修复。
深模块与浅模块
深模块 = 简洁的接口 + 大量实现:
┌──────────────────────┐
│ Small Interface │ ← Few methods, simple params
├──────────────────────┤
│ │
│ Deep Implementation │ ← Complex logic hidden
│ │
└──────────────────────┘
浅模块 = 复杂的接口 + 少量实现(应避免):
┌─────────────────────────────────┐
│ Large Interface │ ← Many methods, complex params
├─────────────────────────────────┤
│ Thin Implementation │ ← Just passes through
└─────────────────────────────────┘
设计接口时,请自问:
- 能减少方法数量吗?
- 能简化参数吗?
- 能把更多复杂性隐藏在内部吗?
原则
- 深度是接口的属性,而非实现的属性。 深模块内部可以由小型、可模拟、可替换的部分组成;这些部分只是不属于接口。模块可以有内部接缝(仅对其实现私有,由自身测试使用),也可以在接口处有外部接缝。
- 删除测试。 想象删除该模块。如果复杂性随之消失,它只是一个传递层。如果复杂性又出现在 N 个调用方中,它就发挥了应有的价值。
- 接口就是测试面。 调用方和测试都经过同一个接缝。如果你想越过接口进行测试,这个模块的形状可能就不合适。
- 一个适配器意味着接缝仅存在于设想中;两个适配器才意味着接缝真实存在。 除非接缝两侧确实存在变化,否则不要引入接缝。只有当测试中无法运行真实依赖时,测试替身才算第二个适配器。
可测试性
可测试性是结构带来的结果,而非独立目标。两条规则可以实现这一点:
- 接收依赖,而不是自行创建。 自行构造依赖的模块会将依赖固定下来,所有调用方都无法更改。将依赖作为参数传入。
- 返回结果,而不是修改状态。 报告计算结果的模块会把是否采取行动的决定留给调用方。
不要仅仅为了模拟而声明接口。“我需要模拟这个”并不是定义端口的理由:
Can the real dependency run inside the test?
yes -> use it. No interface.
(pure logic, in-memory state, temp files,
Postgres/Redis/Kafka in a container)
no -> port + two adapters, production and test.
(third-party APIs, another team's service
across a network seam)
注入依赖才能让模块可测试。在依赖之上声明接口是另一项独立决定,只有选择“否”的情况才值得这么做。模拟本可以实际运行的内容,只能证明代码会调用你让它调用的东西:它无法发现错误的查询、错误的端点或已经偏离的架构,而这些才是真正会发生的问题。
断言(Go)
使用 testify,不要手写 if got != want { t.Fatalf(...) }。当测试无法继续时(设置、错误),使用 require.*;可以继续时,使用 assert.*,这样一次运行就能报告所有失败。先写期望值:assert.Equal(t, want, got)。
关系
- 一个 模块 恰好有一个 接口(它向调用方和测试呈现的表面)。
- 深度是 模块 的属性,以其 接口 为参照衡量。
- 接缝是 模块 的 接口 所在之处。
- 适配器位于 接缝,并满足 接口。
- 深度为调用方带来 杠杆效应,为维护者带来 局部性。
不采用的表述
- 将深度视为实现代码行数与接口代码行数之比(Ousterhout):这会鼓励人为扩充实现。我们采用将深度视为杠杆效应的定义。
- 将“接口”视为语言关键字(Go 或 TypeScript 的
interface)或类的公共方法:范围太窄;这里的接口包括调用方必须了解的所有信息。具体的 Go 结构体也已有接口。只有为 端口 才声明 Go 的interface;只有一个适配器时,它只会增加表面,而不会增加深度。 - “边界”:这个词与 DDD 中的限界上下文含义重叠。请说 接缝 或 接口。
- 将可模拟性作为声明接口的理由:这会让每个依赖看起来都需要一个端口。先问问真实依赖能否在测试中运行;类别请参见 DEEPENING.md。
深入探讨
- 在给定依赖项的情况下深化一个集群,请参见 DEEPENING.md:依赖类别、接缝规范,以及替换而非叠加。
- 探索不同的接口方案,请参见 DESIGN-IT-TWICE.md:启动多个并行子 Agent,以截然不同的方式设计接口,然后从深度、局部性和接缝位置方面进行比较。
致谢
改编自 mattpocock/skills。