核心问题

系统里的部分能不能自然拼接?

优雅系统不只是模块边界清楚,还要能组合。

局部性回答:

一个变化应该放在哪里?

可组合性回答:

不同部分能否通过清楚契约拼出更大的行为?

例如课程平台里:

Purchase
CourseAccessGrant
AccessPolicy
OutboxEvent
Notification
AuditLog

这些概念如果边界清楚,就可以组合出:

  • 购买后发放访问权
  • 退款后撤销访问权
  • 企业分配课程
  • 发送购买邮件
  • 记录审计
  • 客服解释访问状态

核心句:

好模块像积木,坏模块像胶水。

可组合性不是插件化

很多人一听组合,就想到:

插件系统
低代码平台
规则引擎
通用 workflow engine

这不一定是可组合性。

可组合性不是把所有东西做成动态配置。

它更基本:

每个部件职责清楚、输入输出明确、没有隐藏副作用,因此能被可靠拼接。

例如:

canAccessCourse(accountId, courseId)
grantAccessFromPurchase(purchaseId)
enqueueNotification(notification)
recordAuditLog(entry)

这些函数不需要是插件,也能组合。

组合优于继承

经典原则:

Favor composition over inheritance.

继承容易把概念绑死。

例如:

class User {}
class Student extends User {}
class Instructor extends User {}
class Admin extends User {}

很快遇到问题:

  • 一个讲师也可能是学生
  • 一个管理员也可能买课
  • 一个企业成员也可能是讲师

继承把身份变成单一树形结构,但现实是多关系组合。

更好的方式:

Account
Enrollment
InstructorProfile
AdminAssignment
OrganizationMembership

一个账号通过多个关系组合出不同能力。

核心句:

现实世界常常是关系网,不是继承树。

小接口更容易组合

可组合模块通常有小接口。

坏接口:

processCourseLifecycleAndNotifyAndAudit(input)

它把太多事情绑在一起。

更可组合:

publishCourse(courseId)
enqueueCoursePublishedNotification(courseId)
recordAuditLog(...)

或者用应用服务编排:

async function publishCourseCommand(actorId, courseId) {
  await coursePolicy.assertCanPublish(actorId, courseId)
  const course = await courses.publish(courseId)
  await outbox.enqueue("course.published", { courseId })
  await audit.record(actorId, "course.publish", courseId)
  return course
}

这里每个部件职责清楚,组合发生在更高层。

Pipeline Composition

有些流程适合 pipeline。

例如上传课程视频:

validate upload
  -> store original
  -> scan file
  -> transcode
  -> generate subtitles
  -> publish asset
  -> notify instructor

每一步都有明确输入输出。

Pipeline 的美感在于:

  • 步骤顺序清楚
  • 每一步可以测试
  • 失败可以定位
  • 可以重试某一步
  • 可以插入新步骤

但 pipeline 也要避免过度抽象。

如果流程只有两步,不必上 workflow engine。

Policy Composition

权限和规则也可以组合。

例如:

function canPublishCourse(actorId, courseId) {
  return all([
    isCourseOwner(actorId, courseId),
    isCourseInDraftStatus(courseId),
    hasCompletedInstructorVerification(actorId),
  ])
}

或者:

function canAccessCourse(accountId, courseId) {
  return any([
    hasValidPurchaseGrant(accountId, courseId),
    hasActiveSubscriptionGrant(accountId, courseId),
    hasOrganizationAssignmentGrant(accountId, courseId),
  ])
}

这种组合的好处是:

  • 规则可读
  • 单个规则可测试
  • 新规则有自然位置
  • 拒绝原因可以聚合

注意:组合规则时要保留可解释性。

不要把规则组合成没人看得懂的布尔迷宫。

Event Composition

事件可以让模块松耦合组合。

例如:

course.purchase.completed

多个下游可以响应:

  • 发送邮件
  • 更新推荐特征
  • 记录审计
  • 更新分析报表
  • 通知企业管理员

购买流程不必直接知道所有副作用。

但事件组合也有风险:

  • 调用链不明显
  • 失败路径分散
  • 顺序难保证
  • 调试困难
  • 重复消费需要幂等

所以事件要有:

  • 稳定 schema
  • 幂等处理
  • trace id
  • outbox
  • 监控和重试

核心句:

事件让组合更松,但也让因果更远。

Hidden Side Effects 破坏组合

可组合性的敌人是隐藏副作用。

例如:

canAccessCourse(accountId, courseId)

如果它内部偷偷:

  • 写 audit log
  • 刷新缓存
  • 修改 lastSeenAt
  • 创建 access grant
  • 发送通知

调用者就无法安全组合它。

判断函数应该尽量无副作用。

如果需要记录访问尝试,可以在更高层显式做:

const decision = await canAccessCourse(accountId, courseId)
await auditAccessDecision(accountId, courseId, decision)

核心句:

隐藏副作用会让模块像胶水一样粘住。

Composition and Testing

可组合系统更容易测试。

单个部件测试:

hasValidPurchaseGrant(accountId, courseId)

组合规则测试:

canAccessCourse(accountId, courseId)

流程编排测试:

handlePaymentSucceeded(paymentId)

每层测试不同承诺。

如果一个函数做太多事,测试只能变成庞大集成测试。

可组合性检查清单

看一个设计是否可组合,问:

  1. 每个部件是否有清楚职责?
  2. 输入输出是否明确?
  3. 是否有隐藏副作用?
  4. 小规则是否能组合成大规则?
  5. 小流程是否能组合成大流程?
  6. 组合点在哪里?是否清楚?
  7. 新增一种行为时,是扩展部件,还是改一坨大函数?
  8. 组合后是否仍然可解释、可测试、可观测?

小结

  1. 可组合性回答“部分能否自然拼接”。
  2. 可组合性不是插件化,也不是动态配置。
  3. 组合优于继承,因为现实常是关系网,不是继承树。
  4. 小接口更容易组合。
  5. Pipeline、Policy、Event 都是常见组合方式。
  6. 事件让组合更松,但让因果更远。
  7. 隐藏副作用会破坏组合。
  8. 可组合系统更容易分层测试。