核心问题
系统里的部分能不能自然拼接?
优雅系统不只是模块边界清楚,还要能组合。
局部性回答:
一个变化应该放在哪里?
可组合性回答:
不同部分能否通过清楚契约拼出更大的行为?
例如课程平台里:
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)
每层测试不同承诺。
如果一个函数做太多事,测试只能变成庞大集成测试。
可组合性检查清单
看一个设计是否可组合,问:
- 每个部件是否有清楚职责?
- 输入输出是否明确?
- 是否有隐藏副作用?
- 小规则是否能组合成大规则?
- 小流程是否能组合成大流程?
- 组合点在哪里?是否清楚?
- 新增一种行为时,是扩展部件,还是改一坨大函数?
- 组合后是否仍然可解释、可测试、可观测?
小结
- 可组合性回答“部分能否自然拼接”。
- 可组合性不是插件化,也不是动态配置。
- 组合优于继承,因为现实常是关系网,不是继承树。
- 小接口更容易组合。
- Pipeline、Policy、Event 都是常见组合方式。
- 事件让组合更松,但让因果更远。
- 隐藏副作用会破坏组合。
- 可组合系统更容易分层测试。