核心问题

软件系统不是先写代码,而是先定义世界。

软件系统可以被理解为:

对现实世界的一套压缩模型。

编程是把这套模型变成可执行规则。架构则是在决定:哪些东西应该存在,哪些东西不该存在,以及它们之间应该如何相互作用。

命名就是本体论

当我们写:

class User {
  id: string
  email: string
  name: string
}

我们并不是随便建了一个类,而是在回答一组本体论问题:

  1. 什么东西在这个系统里算作一个 User
  2. UserAccount 是同一个东西吗?
  3. 一个没有邮箱的人能不能是 User
  4. 被封禁的人还是不是 User
  5. 机器人账号、企业账号、游客账号算不算 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
}

这里的关键是:身份往往不是本体,而是关系。

以事实建模,而不是以印象建模

日常语言里的名词,不能直接搬进代码。要先问:

它在系统里靠什么事实成立?

例如,“学生”这个词很含糊:

  1. 买了课但还没开始,是不是学生?
  2. 企业分配了课程但员工没打开,是不是学生?
  3. 课程过期了,还是不是学生?
  4. 已经学完的人,还是不是学生?
  5. 退款之后,还算不算学生?

这些问题说明 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>
}

这看起来通用,但很容易变成坏抽象。因为 PurchaseEnrollmentCourseAssignmentOrganizationMembership 的生命周期、规则、不变量都不同。

一旦代码开始变成这样,抽象就已经失控:

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),
  ]
}

这里没有把 PurchaseSubscriptionCourseAssignment 合并。我们只是抽象了它们共同产生的效果:课程访问权。

重要原则:

不要急着抽象事物本身。优先抽象共同的行为、结果或协议。

四层抽象模型

第 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。因为真正统一的是访问权效果,不是所有关系本身。

小结

  1. 身份不是本体,身份常常是关系。
  2. 日常名词不能直接进代码,要找到它背后的事实。
  3. 不要存结论,优先存事实。
  4. 抽象应该停在稳定边界,而不是想象中的未来。
  5. 领域概念要晚抽象,重复机制可以早一点抽象。