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