核心问题
软件系统不是先写代码,而是先定义世界。
软件系统可以被理解为:
对现实世界的一套压缩模型。
编程是把这套模型变成可执行规则。架构则是在决定:哪些东西应该存在,哪些东西不该存在,以及它们之间应该如何相互作用。
命名就是本体论
当我们写:
class User {
id: string
email: string
name: string
}
我们并不是随便建了一个类,而是在回答一组本体论问题:
- 什么东西在这个系统里算作一个
User? User和Account是同一个东西吗?- 一个没有邮箱的人能不能是
User? - 被封禁的人还是不是
User? - 机器人账号、企业账号、游客账号算不算
User?
如果这些问题没有想清楚,代码会把不同概念塞进同一个名字里。
坏味道通常长这样:
type User = {
id: string
email?: string
phone?: string
role?: string
companyId?: string
isGuest?: boolean
isBot?: boolean
isDeleted?: boolean
isSuspended?: boolean
}
这不是一个清晰的 User,而是一个把太多概念混在一起的容器。
更清楚的切法可能是:
type Person = {
id: string
name: string
}
type Account = {
id: string
ownerId: string
status: "active" | "suspended" | "deleted"
}
type Credential = {
accountId: string
kind: "email" | "phone" | "oauth"
identifier: string
}
这里的变化不是为了让代码显得复杂,而是让边界更清楚:
Person是现实中的人。Account是系统里的登录身份。Credential是证明你能登录的凭证。- 被封禁的是
Account,不是Person。 - 换邮箱影响的是
Credential,不是人的存在。
一句话:
好的命名不是让变量更好看,而是让错误的设计更难发生。
身份不是人本身
在线课程平台里,一个人可能同时是:
- 平台注册者
- 买课的人
- 正在学习某门课的人
- 讲师
- 后台管理员
- 企业客户邀请来的员工
因此,不能简单地让 User.role 承载一切。
坏模型:
type User = {
id: string
role: "student" | "instructor" | "admin"
}
这个模型暗含一个错误假设:
一个人只有一种身份。
但现实里,一个人可以既是讲师,又买了别人的课;既是企业员工,又是某门课的学习者;既是管理员,也可能有自己的学习记录。
更好的思考方式是:
不要问“这个人是什么角色”,要问“这个人在什么关系里承担什么身份”。
例如:
type Account = {
id: string
status: "active" | "suspended"
}
type LearnerProfile = {
accountId: string
}
type InstructorProfile = {
accountId: string
bio: string
}
type AdminAssignment = {
accountId: string
permissions: string[]
}
type OrganizationMembership = {
accountId: string
organizationId: string
role: "owner" | "manager" | "employee"
}
type Enrollment = {
accountId: string
courseId: string
status: "active" | "completed" | "expired"
}
type Purchase = {
accountId: string
courseId: string
paidAt: Date
}
这里的关键是:身份往往不是本体,而是关系。
以事实建模,而不是以印象建模
日常语言里的名词,不能直接搬进代码。要先问:
它在系统里靠什么事实成立?
例如,“学生”这个词很含糊:
- 买了课但还没开始,是不是学生?
- 企业分配了课程但员工没打开,是不是学生?
- 课程过期了,还是不是学生?
- 已经学完的人,还是不是学生?
- 退款之后,还算不算学生?
这些问题说明 Student 这个名字没有稳定边界。
更清楚的模型是:
type Purchase = {
accountId: string
courseId: string
paidAt: Date
}
type Enrollment = {
accountId: string
courseId: string
status: "active" | "completed" | "expired"
}
type CourseAssignment = {
accountId: string
courseId: string
organizationId: string
assignedAt: Date
}
type CourseFavorite = {
accountId: string
courseId: string
createdAt: Date
}
这里没有急着定义 Student,而是存储具体事实:
- 买课是
Purchase - 正在学习是
Enrollment - 企业分配是
CourseAssignment - 收藏是
CourseFavorite
原则:
不要把结论当事实存。优先存事实,让结论从事实推导出来。
例如,Customer 很多时候不应该是一个直接存储的字段:
user.isCustomer = true
因为它会引出一致性问题:退款、订阅过期、企业合同终止、支付失败时,什么时候把它改回 false?
更好的做法是存事实:
type Order = {
id: string
accountId: string
status: "pending" | "paid" | "refunded" | "canceled"
}
type Subscription = {
id: string
accountId: string
status: "trialing" | "active" | "past_due" | "canceled"
}
然后用事实推导结论:
function isPayingCustomer(accountId: string): boolean {
return hasActiveSubscription(accountId) || hasPaidOrder(accountId)
}
抽象应该到哪一步为止
抽象不是越高越好。抽象应该停在变化的边界那里。
核心判断:
抽象应该覆盖已经稳定重复的结构,而不是覆盖想象中的未来。
例如,不要急着把所有关系抽成一个万能 Relationship:
type Relationship = {
sourceId: string
targetId: string
type: string
status: string
metadata: Record<string, unknown>
}
这看起来通用,但很容易变成坏抽象。因为 Purchase、Enrollment、CourseAssignment 和 OrganizationMembership 的生命周期、规则、不变量都不同。
一旦代码开始变成这样,抽象就已经失控:
if (relationship.type === "purchase") {
// payment logic
}
if (relationship.type === "enrollment") {
// learning logic
}
if (relationship.metadata.assignmentDeadline) {
// maybe enterprise assignment?
}
这不是抽象,而是把类型系统和领域模型换成字符串和猜测。
判断抽象是否应该停止的三个问题
1. 它们是否有相同的生命周期?
Purchase 的生命周期:
pending -> paid -> refunded / canceled
Enrollment 的生命周期:
active -> completed / expired
如果生命周期不同,就不要强行合并。
2. 它们是否有相同的不变量?
不变量是永远必须成立的规则。
例如:
- 一个
Purchase必须有金额、支付状态、购买时间。 - 一个
Enrollment必须有课程、学习者、学习状态。 - 一个
AdminAssignment必须有权限范围。
如果不变量不同,抽象会制造大量可选字段:
amount?: number
paidAt?: Date
completedAt?: Date
permissions?: string[]
organizationId?: string
一个类型里出现大量 ?,通常说明几个不同概念被硬塞进了同一个盒子。
3. 它们是否会被同一组操作处理?
如果多个事实会产生同一种效果,可以抽象效果,而不是抽象事实本身。
例如课程访问权可能来自购买、订阅、企业分配、兑换码或活动赠送。
可以抽象:
interface CourseAccessGrant {
accountId: string
courseId: string
validUntil?: Date
}
然后从不同事实推导:
function getCourseAccessGrants(accountId: string): CourseAccessGrant[] {
return [
...grantsFromPurchases(accountId),
...grantsFromSubscriptions(accountId),
...grantsFromOrganizationAssignments(accountId),
]
}
这里没有把 Purchase、Subscription、CourseAssignment 合并。我们只是抽象了它们共同产生的效果:课程访问权。
重要原则:
不要急着抽象事物本身。优先抽象共同的行为、结果或协议。
四层抽象模型
第 1 层:事实层
先把真实发生的事情建清楚:
Purchase
Enrollment
CourseAssignment
Subscription
OrganizationMembership
这里宁可具体,不要过早通用。
第 2 层:规则层
把业务规则放进清楚的函数或服务:
canAccessCourse(accountId, courseId)
canPublishCourse(accountId)
canManageOrganization(accountId, organizationId)
第 3 层:效果层
当多个事实产生同一种效果时,抽象效果:
CourseAccessGrant
PermissionGrant
NotificationRecipient
第 4 层:机制层
当实现机制重复时,再抽象基础设施:
Repository
EventBus
StateMachine
PolicyEvaluator
很多过度设计来自直接从事实层跳到机制层:
BaseEntity
AbstractUserRole
GenericRelationshipManager
UniversalPermissionEngine
这些名字听起来很架构,但如果需求还没长出来,抽象就已经先老了。
实用口诀
三次重复之前,先复制。
三次重复之后,找变化轴。
找不到变化轴,不要抽象。
找到了变化轴,只抽那一轴。
变化轴就是未来最可能变化的维度。
如果课程访问权未来会来自很多来源,可以抽象 CourseAccessGrant。
但不要因此抽象成 UniversalUserCourseRelation。因为真正统一的是访问权效果,不是所有关系本身。
小结
- 身份不是本体,身份常常是关系。
- 日常名词不能直接进代码,要找到它背后的事实。
- 不要存结论,优先存事实。
- 抽象应该停在稳定边界,而不是想象中的未来。
- 领域概念要晚抽象,重复机制可以早一点抽象。