Imported from hydrz/dev-skills (
skills/engineering/codebase-design/SKILL.md). Install upstream withnpx skills add hydrz/dev-skills --skill codebase-design. Copyright stays with the author.
代码库设计
设计深模块(deep module):用简单的接口提供丰富的行为,把接口放在合适的接缝处,并能通过这个接口进行测试。设计或重构代码时,都使用这套术语和原则。目标是让调用方获得杠杆,让维护者获得局部性,让代码便于测试。
术语
讨论模块设计时,一致地使用下面这些术语。组件、服务、API、边界等工程词按各自原义使用,不作为这些术语的同义替换;需要表达本节概念时,使用本节的词。
模块(module):任何具有接口和实现的代码单元。尺度刻意不设限:函数、类、包,或跨越多层的一段能力。组件、服务、单元是更具体的工程概念,只在符合其原义时使用。
接口(interface):调用方正确使用模块所需知道的一切。除了类型签名,还包括不变量、顺序约束、错误模式、必需配置和性能特征。API 和函数签名只覆盖类型层面的表面,讨论完整的使用约定时说“接口”。
实现(implementation):模块内部的代码主体。它与适配器不同:一个模块可以是很薄的适配器配上复杂的实现(例如 Postgres 仓储),也可以是复杂的适配器配上简单的实现(例如内存假实现)。讨论接缝时说“适配器”,其他时候说“实现”。
深度(depth):接口带来的杠杆,即调用方(或测试)每理解一单位接口能使用多少行为。用简单的接口提供大量行为,模块就深;接口几乎和实现一样复杂,模块就浅。
接缝(seam,来自 Michael Feathers):无需修改该处代码就能替换行为的位置,也就是模块接口所在的位置。接缝放在哪里是一个独立的设计决策,与接缝后面放什么不同。“边界”在 DDD 中容易让人联想到限界上下文,所以指代这个位置时说“接缝”。
适配器(adapter):在接缝处实现接口的具体代码。它描述的是角色(填补哪个位置),不描述内容(内部有什么)。
杠杆(leverage):调用方从深度中获得的好处。每理解一单位接口,就能使用更多能力。一份实现,可以同时服务 N 个调用点和 M 个测试。
局部性(locality):维护者从深度中获得的好处。变更、bug、知识和验证集中在一处,而不是分散在各个调用方。修复一次,所有调用方都受益。
深模块与浅模块
深模块:接口简单,实现丰富。
┌─────────────────────┐
│ 小接口 │ ← 方法少、参数简单
├─────────────────────┤
│ │
│ 丰富的实现 │ ← 复杂逻辑在内部完成
│ │
└─────────────────────┘
浅模块:接口复杂,实现单薄(应避免)。
┌─────────────────────────────────┐
│ 大接口 │ ← 方法多、参数复杂
├─────────────────────────────────┤
│ 单薄的实现 │ ← 只是转发
└─────────────────────────────────┘
设计接口时思考:
- 能减少方法数量吗?
- 能简化参数吗?
- 能把更多复杂性放到内部处理吗?
原则
- 深度是接口的属性,不是实现的属性。 深模块内部可以由小的、可 mock、可替换的部分组成,只是这些部分不属于接口。模块可以有内部接缝(实现私有,供自身测试使用),也可以有位于接口处的外部接缝。
- 删除测试。 设想删除这个模块:如果复杂性随之消失,说明它只是在转发;如果复杂性会在 N 个调用方处重新出现,说明它确实承担了价值。
- 接口就是测试面。 调用方和测试通过同一个接缝使用模块。如果你想绕过接口进行测试,通常说明模块的结构不对。
- 只有一个适配器时,接缝只是假设;有两个适配器时,接缝才真实存在。 只有接缝两侧确实有会变化的实现时,才引入接缝。
- 小文件、单一职责。 agent 能同时放进上下文的代码,它推理得最好;文件聚焦时,编辑也更可靠。文件不断变大,通常说明它承担了太多职责。把一起变化的代码放在一起,按职责拆分,而不是按技术分层拆分。
为可测性设计
好的接口让测试自然容易编写:
-
接收依赖,而不是在内部创建。
// 容易测试 function processOrder(order, paymentGateway) {} // 难以测试 function processOrder(order) { const gateway = new StripeGateway(); } -
返回结果,而不是产生副作用。
// 容易测试 function calculateDiscount(cart): Discount {} // 难以测试 function applyDiscount(cart): void { cart.total -= discount; } -
接口面尽量小。 方法越少,需要的测试越少;参数越少,测试准备越简单。
关系
- 一个模块恰好有一个接口(它呈现给调用方和测试的使用面)。
- 深度是模块的属性,通过它的接口衡量。
- 接缝是模块的接口所在的位置。
- 适配器位于接缝处,实现接口。
- 深度为调用方带来杠杆,为维护者带来局部性。
不采用的定义
- 把深度定义为实现行数与接口行数之比(Ousterhout):这会鼓励往实现里堆砌代码。本 skill 采用“深度即杠杆”。
- 把“接口”等同于 TypeScript 的
interface关键字或类的公有方法:范围太窄。这里的接口包括调用方必须知道的每一个事实。 - 用“边界”指代接缝:在 DDD 语境中容易与限界上下文混淆。指代替换行为的位置时说接缝,指代使用约定时说接口。
延伸阅读
- 根据依赖类型加深一组浅模块:见 DEEPENING.md,包括依赖分类、接缝使用规则,以及用新测试替换旧测试的策略。
- 探索备选接口:见 DESIGN-IT-TWICE.md,并行派出子代理,用几种差异明显的方式设计接口,再从深度、局部性和接缝位置进行比较。