核心问题
什么时候应该复制?什么时候应该抽函数、抽类型、抽模块、抽服务?
抽象不是一种美德。抽象是一种投资。
投资就有成本:
- 学习成本
- 命名成本
- 维护成本
- 适配成本
- 错误抽象带来的迁移成本
所以问题不是“能不能抽象”,而是:
这个抽象现在是否已经值得?
先接受一个事实:复制不一定坏
很多工程师对重复代码过敏。
看到两段相似代码,就立刻想抽:
function processThing(type, payload) {
if (type === "purchase") {
// ...
}
if (type === "enrollment") {
// ...
}
}
但早期适度复制有一个好处:
它保留了不同概念各自演化的自由。
例如:
function createPurchase(...) {}
function createEnrollment(...) {}
它们现在可能有几行相似代码,但未来很可能走向不同方向。
如果过早抽象成:
function createCourseRelation(type, ...) {}
后续所有差异都会变成 if type === ...。
所以口诀是:
重复比错误抽象便宜。
重复可以以后抽。错误抽象会改变系统形状,迁移更贵。
三次重复原则的正确用法
“重复三次再抽象”不是机械规则。
它真正的意思是:
第三次重复时,不是立刻抽,而是停下来观察变化轴。
问:
- 这三处代码真的在表达同一个概念吗?
- 它们未来会因为同一个原因变化吗?
- 抽象后调用者是否会更少知道细节?
- 抽象是否有一个领域里说得通的名字?
如果没有,就继续复制。
抽函数:当规则重复出现
适合抽函数的信号:
- 同一条业务规则出现多次。
- 条件判断开始变长。
- 调用者不应该知道判断细节。
- 这个规则有清楚名字。
坏代码:
if (
order.status === "paid" &&
!order.refundedAt &&
order.paidAt > course.publishAt
) {
// grant access
}
如果这段判断出现多次,应该抽:
function isPurchaseEligibleForAccess(order: Order, course: Course): boolean {
return (
order.status === "paid" &&
!order.refundedAt &&
order.paidAt > course.publishAt
)
}
更业务化一点:
function canGrantCourseAccessFromPurchase(
purchase: Purchase,
course: Course
): boolean {
// ...
}
抽函数的目的不是减少行数,而是给规则一个名字。
抽类型:当概念有独立边界
适合抽类型的信号:
- 它有独立生命周期。
- 它有自己的不变量。
- 它会被多个规则引用。
- 它在业务语言里有稳定名字。
例如 CourseAccessGrant 值得成为类型:
type CourseAccessGrant = {
accountId: string
courseId: string
validFrom: Date
validUntil?: Date
source: "purchase" | "subscription" | "organization_assignment" | "coupon"
}
因为它表达一个稳定概念:
某账号在某个时间范围内拥有某课程访问权。
但 UniversalCourseRelationship 就很可疑:
type UniversalCourseRelationship = {
accountId: string
courseId: string
relationshipType: string
metadata: Record<string, unknown>
}
它没有稳定不变量,只是把很多差异藏进 metadata。
抽模块:当一组规则需要共同边界
适合抽模块的信号:
- 多个函数围绕同一组数据和规则。
- 外部调用者不应该直接访问内部细节。
- 这组规则有清楚的所有权。
- 改动通常集中在这里。
例如课程访问逻辑可以成为一个模块:
course-access/
grants.ts
policy.ts
queries.ts
对外只暴露:
canAccessCourse(accountId, courseId)
grantAccessFromPurchase(purchaseId)
revokeAccessFromRefund(refundId)
这样外部不需要知道访问权来自哪些来源,也不需要知道内部如何查询。
模块抽象的核心是:
把变化关在一个房间里。
抽服务:当边界不仅是代码边界
服务不是“更大的模块”。服务意味着更重的边界:
- 独立部署
- 独立数据所有权
- 网络调用
- 失败处理
- 观测和运维
- 版本兼容
所以不要因为代码多了就拆服务。
适合抽服务的信号:
- 有独立团队负责。
- 有独立扩缩容需求。
- 有清晰数据所有权。
- 有独立发布节奏。
- 模块之间通过稳定契约交互。
- 网络失败成本可以被系统承受。
如果只是为了“代码看起来更清楚”,先抽模块,不要急着抽微服务。
抽平台:当重复已经跨团队稳定出现
平台化是最高成本的抽象之一。
例如统一权限平台、统一工作流平台、统一消息平台、统一配置平台。
它们适合在以下条件成立时出现:
- 多个团队都在解决同类问题。
- 重复模式已经稳定。
- 平台团队有能力长期维护。
- 使用方愿意牺牲部分灵活性换取一致性。
- 平台边界和扩展点足够清楚。
过早平台化的常见问题是:
平台还没服务别人,就先要求别人服务平台。
如果业务团队为了接入平台,必须把自己的真实需求扭曲成平台支持的形状,平台就变成了组织级技术债。
抽象层级表
| 层级 | 何时抽 | 主要收益 | 主要风险 |
|---|---|---|---|
| 函数 | 规则重复、判断变长 | 给规则命名 | 抽得太碎 |
| 类型 | 概念有生命周期和不变量 | 稳定领域边界 | 制造伪实体 |
| 模块 | 一组规则共同变化 | 隐藏内部细节 | 边界切错 |
| 服务 | 需要部署、数据、团队边界 | 独立演化 | 分布式复杂度 |
| 平台 | 跨团队重复稳定 | 规模化复用 | 组织级僵化 |
好抽象的特征
1. 名字自然
好抽象通常能被业务人员或产品人员理解。
CourseAccessGrant
Enrollment
OrganizationMembership
坏抽象常常只有工程师觉得懂:
AbstractRelationContext
UniversalActorBinding
GenericResourceLink
2. 调用者知道得更少
好抽象:
canAccessCourse(accountId, courseId)
坏抽象:
accessResolver.resolve({
subjectType: "account",
relationType: "course",
mode: "effective",
context: { includeEnterprise: true }
})
3. 变化被局部化
如果新增一种访问来源,只需要改 course-access 模块,而不是全系统搜索判断逻辑,这就是好抽象。
4. 不需要大量可选字段
如果一个抽象类型充满:
amount?: number
expiresAt?: Date
organizationId?: string
permissions?: string[]
metadata?: Record<string, unknown>
通常说明它合并了太多不同概念。
坏抽象的症状
- 调用前要读大量文档。
- 参数里有
type、mode、metadata、options大杂烩。 - 新需求总是要绕过抽象。
- 抽象内部全是
if/else。 - 业务词汇被框架词汇淹没。
- 删除抽象反而会让代码更清楚。
实用决策树
遇到重复时:
只是表面相似?
-> 先复制
同一条规则重复?
-> 抽函数
同一个概念有生命周期和不变量?
-> 抽类型
一组规则共同变化,需要隐藏细节?
-> 抽模块
需要独立部署、数据所有权或团队边界?
-> 抽服务
多个团队长期重复同类能力?
-> 考虑平台
小结
- 抽象是投资,不是美德。
- 重复不一定坏,错误抽象通常更贵。
- 第三次重复时先找变化轴,不是立刻抽公共代码。
- 抽函数是给规则命名。
- 抽类型是承认一个概念有独立边界。
- 抽模块是把变化关在一个房间里。
- 抽服务需要真实部署、数据或团队边界。
- 抽平台必须等跨团队重复模式稳定后再做。