核心问题

什么时候应该复制?什么时候应该抽函数、抽类型、抽模块、抽服务?

抽象不是一种美德。抽象是一种投资。

投资就有成本:

  • 学习成本
  • 命名成本
  • 维护成本
  • 适配成本
  • 错误抽象带来的迁移成本

所以问题不是“能不能抽象”,而是:

这个抽象现在是否已经值得?

先接受一个事实:复制不一定坏

很多工程师对重复代码过敏。

看到两段相似代码,就立刻想抽:

function processThing(type, payload) {
  if (type === "purchase") {
    // ...
  }

  if (type === "enrollment") {
    // ...
  }
}

但早期适度复制有一个好处:

它保留了不同概念各自演化的自由。

例如:

function createPurchase(...) {}
function createEnrollment(...) {}

它们现在可能有几行相似代码,但未来很可能走向不同方向。

如果过早抽象成:

function createCourseRelation(type, ...) {}

后续所有差异都会变成 if type === ...

所以口诀是:

重复比错误抽象便宜。

重复可以以后抽。错误抽象会改变系统形状,迁移更贵。

三次重复原则的正确用法

“重复三次再抽象”不是机械规则。

它真正的意思是:

第三次重复时,不是立刻抽,而是停下来观察变化轴。

问:

  1. 这三处代码真的在表达同一个概念吗?
  2. 它们未来会因为同一个原因变化吗?
  3. 抽象后调用者是否会更少知道细节?
  4. 抽象是否有一个领域里说得通的名字?

如果没有,就继续复制。

抽函数:当规则重复出现

适合抽函数的信号:

  1. 同一条业务规则出现多次。
  2. 条件判断开始变长。
  3. 调用者不应该知道判断细节。
  4. 这个规则有清楚名字。

坏代码:

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 {
  // ...
}

抽函数的目的不是减少行数,而是给规则一个名字。

抽类型:当概念有独立边界

适合抽类型的信号:

  1. 它有独立生命周期。
  2. 它有自己的不变量。
  3. 它会被多个规则引用。
  4. 它在业务语言里有稳定名字。

例如 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

抽模块:当一组规则需要共同边界

适合抽模块的信号:

  1. 多个函数围绕同一组数据和规则。
  2. 外部调用者不应该直接访问内部细节。
  3. 这组规则有清楚的所有权。
  4. 改动通常集中在这里。

例如课程访问逻辑可以成为一个模块:

course-access/
  grants.ts
  policy.ts
  queries.ts

对外只暴露:

canAccessCourse(accountId, courseId)
grantAccessFromPurchase(purchaseId)
revokeAccessFromRefund(refundId)

这样外部不需要知道访问权来自哪些来源,也不需要知道内部如何查询。

模块抽象的核心是:

把变化关在一个房间里。

抽服务:当边界不仅是代码边界

服务不是“更大的模块”。服务意味着更重的边界:

  • 独立部署
  • 独立数据所有权
  • 网络调用
  • 失败处理
  • 观测和运维
  • 版本兼容

所以不要因为代码多了就拆服务。

适合抽服务的信号:

  1. 有独立团队负责。
  2. 有独立扩缩容需求。
  3. 有清晰数据所有权。
  4. 有独立发布节奏。
  5. 模块之间通过稳定契约交互。
  6. 网络失败成本可以被系统承受。

如果只是为了“代码看起来更清楚”,先抽模块,不要急着抽微服务。

抽平台:当重复已经跨团队稳定出现

平台化是最高成本的抽象之一。

例如统一权限平台、统一工作流平台、统一消息平台、统一配置平台。

它们适合在以下条件成立时出现:

  1. 多个团队都在解决同类问题。
  2. 重复模式已经稳定。
  3. 平台团队有能力长期维护。
  4. 使用方愿意牺牲部分灵活性换取一致性。
  5. 平台边界和扩展点足够清楚。

过早平台化的常见问题是:

平台还没服务别人,就先要求别人服务平台。

如果业务团队为了接入平台,必须把自己的真实需求扭曲成平台支持的形状,平台就变成了组织级技术债。

抽象层级表

层级何时抽主要收益主要风险
函数规则重复、判断变长给规则命名抽得太碎
类型概念有生命周期和不变量稳定领域边界制造伪实体
模块一组规则共同变化隐藏内部细节边界切错
服务需要部署、数据、团队边界独立演化分布式复杂度
平台跨团队重复稳定规模化复用组织级僵化

好抽象的特征

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>

通常说明它合并了太多不同概念。

坏抽象的症状

  1. 调用前要读大量文档。
  2. 参数里有 typemodemetadataoptions 大杂烩。
  3. 新需求总是要绕过抽象。
  4. 抽象内部全是 if/else
  5. 业务词汇被框架词汇淹没。
  6. 删除抽象反而会让代码更清楚。

实用决策树

遇到重复时:

只是表面相似?
  -> 先复制

同一条规则重复?
  -> 抽函数

同一个概念有生命周期和不变量?
  -> 抽类型

一组规则共同变化,需要隐藏细节?
  -> 抽模块

需要独立部署、数据所有权或团队边界?
  -> 抽服务

多个团队长期重复同类能力?
  -> 考虑平台

小结

  1. 抽象是投资,不是美德。
  2. 重复不一定坏,错误抽象通常更贵。
  3. 第三次重复时先找变化轴,不是立刻抽公共代码。
  4. 抽函数是给规则命名。
  5. 抽类型是承认一个概念有独立边界。
  6. 抽模块是把变化关在一个房间里。
  7. 抽服务需要真实部署、数据或团队边界。
  8. 抽平台必须等跨团队重复模式稳定后再做。