练习目标

把一个混乱的 User 模型,重构成边界清楚的领域模型。

这个练习会用到前面所有原则:

  • 命名即本体论
  • 身份不是本体
  • 不要存结论,优先存事实
  • 抽象停在变化边界
  • 权限、角色、所有权、访问权分开
  • 好名字让错误设计更难发生

起点:一个混乱模型

假设在线课程平台早期有这样一个模型:

type User = {
  id: string
  email?: string
  phone?: string
  name?: string

  role: "student" | "instructor" | "admin" | "enterprise_admin"
  status: "active" | "inactive" | "deleted"

  companyId?: string
  isCustomer: boolean
  isInstructor: boolean
  isAdmin: boolean
  isActive: boolean

  courseIds: string[]
  purchasedCourseIds: string[]
  assignedCourseIds: string[]
  completedCourseIds: string[]

  subscriptionStatus?: "trial" | "active" | "expired" | "canceled"
  permissions?: string[]
  metadata?: Record<string, unknown>
}

它的问题不是“字段多”,而是多个概念被压进了一个名字。

第一步:识别混在一起的概念

这个 User 里至少混了这些东西:

字段实际概念
email, phone登录凭证
name人或账号资料
role上下文角色,被错误做成全局字段
status, isActive账号状态或其他状态,语义含糊
companyId组织成员关系
isCustomer派生结论
isInstructor讲师资料或授权
isAdmin权限分配
courseIds含义不明:能看、买过、学过、收藏过?
purchasedCourseIds购买事实
assignedCourseIds企业分配事实
completedCourseIds完课事实
subscriptionStatus订阅生命周期
permissions动作授权
metadata未命名的未来复杂性

这一步的目标是看见:

User 不是一个实体,而是很多未命名概念的堆叠。

第二步:拆 Identity

先处理“你是谁”和“你怎么登录”。

type Account = {
  id: string
  status: AccountStatus
  createdAt: Date
}

type AccountStatus = "active" | "suspended" | "deleted"

type Credential = {
  id: string
  accountId: string
  kind: "email" | "phone" | "oauth"
  identifier: string
  verifiedAt?: Date
}

type Profile = {
  accountId: string
  displayName: string
}

这里我们没有一定要建 Person,因为课程平台早期可能只需要账号资料。

如果未来出现实名认证、一个人多个账号、家庭成员账号,再考虑引入 Person。

第三步:拆 Organization Membership

不要把 companyId 放在账号上。

因为一个账号可能属于多个组织,也可能在不同组织有不同角色。

type Organization = {
  id: string
  name: string
}

type OrganizationMembership = {
  accountId: string
  organizationId: string
  role: "owner" | "manager" | "member"
  status: "active" | "invited" | "removed"
  joinedAt?: Date
}

角色被绑定到组织上下文里。

第四步:拆 Instructor

isInstructor 太粗。

如果讲师有简介、资质、结算、课程管理,那么它值得成为一个 profile。

type InstructorProfile = {
  accountId: string
  bio: string
  status: "pending_review" | "active" | "suspended"
  createdAt: Date
}

如果只是临时授予某门课的协作权限,则应该是课程上下文里的 staff assignment:

type CourseStaffAssignment = {
  accountId: string
  courseId: string
  role: "primary_instructor" | "assistant" | "reviewer"
}

第五步:拆购买、分配、订阅

不要存:

purchasedCourseIds: string[]
assignedCourseIds: string[]

改成事实:

type Purchase = {
  id: string
  accountId: string
  courseId: string
  paymentId: string
  purchasedAt: Date
}

type CourseAssignment = {
  id: string
  organizationId: string
  accountId: string
  courseId: string
  assignedBy: string
  assignedAt: Date
}

type Subscription = {
  id: string
  accountId: string
  status: "trialing" | "active" | "past_due" | "canceled" | "expired"
  currentPeriodEndsAt: Date
}

这些是不同事实和状态,不能用一个 courseIds 混掉。

第六步:拆访问权

购买、订阅、企业分配都可能产生课程访问权。

这时可以抽象它们共同的效果:

type CourseAccessGrant = {
  id: string
  accountId: string
  courseId: string
  source:
    | "purchase"
    | "subscription"
    | "organization_assignment"
    | "coupon"
  sourceId: string
  validFrom: Date
  validUntil?: Date
  revokedAt?: Date
}

注意,这里抽象的是“访问权效果”,不是把购买、订阅、分配合并成万能关系。

调用方只问:

canAccessCourse(accountId, courseId)

不要到处写:

hasPurchase(accountId, courseId) ||
hasActiveSubscription(accountId) ||
hasOrganizationAssignment(accountId, courseId)

第七步:拆学习过程

completedCourseIds 也不够好。

学习过程有自己的生命周期:

type Enrollment = {
  id: string
  accountId: string
  courseId: string
  status: "active" | "completed" | "expired"
  enrolledAt: Date
  completedAt?: Date
}

type LessonProgress = {
  accountId: string
  courseId: string
  lessonId: string
  status: "not_started" | "in_progress" | "completed"
  completedAt?: Date
}

是否完成课程,可以从 Enrollment.status 或课程完成事实推导。

如果查询频繁,可以存进投影:

type CourseProgressSummary = {
  accountId: string
  courseId: string
  completedLessons: number
  totalLessons: number
  progressPercent: number
  updatedAt: Date
}

但它必须能从 LessonProgress 重建。

第八步:拆权限

不要把权限写成:

user.permissions = ["edit_course"]

因为权限通常依赖上下文。

更好的方式是用规则函数:

canEditCourse(accountId, courseId)
canPublishCourse(accountId, courseId)
canAssignCourse(accountId, organizationId, courseId)
canInviteOrganizationMember(accountId, organizationId)

这些函数内部可以组合事实:

  • 是否是平台管理员
  • 是否是课程 owner
  • 是否是课程 staff
  • 是否是组织 manager
  • 课程是否属于该组织

调用者不应该自己拼规则。

第九步:最终模型草图

type Account = {
  id: string
  status: AccountStatus
  createdAt: Date
}

type Credential = {
  id: string
  accountId: string
  kind: "email" | "phone" | "oauth"
  identifier: string
  verifiedAt?: Date
}

type Profile = {
  accountId: string
  displayName: string
}

type OrganizationMembership = {
  accountId: string
  organizationId: string
  role: "owner" | "manager" | "member"
  status: "active" | "invited" | "removed"
}

type InstructorProfile = {
  accountId: string
  bio: string
  status: "pending_review" | "active" | "suspended"
}

type CourseStaffAssignment = {
  accountId: string
  courseId: string
  role: "primary_instructor" | "assistant" | "reviewer"
}

type Purchase = {
  id: string
  accountId: string
  courseId: string
  paymentId: string
  purchasedAt: Date
}

type Subscription = {
  id: string
  accountId: string
  status: SubscriptionStatus
  currentPeriodEndsAt: Date
}

type CourseAssignment = {
  id: string
  organizationId: string
  accountId: string
  courseId: string
  assignedBy: string
  assignedAt: Date
}

type CourseAccessGrant = {
  id: string
  accountId: string
  courseId: string
  source: CourseAccessSource
  sourceId: string
  validFrom: Date
  validUntil?: Date
  revokedAt?: Date
}

type Enrollment = {
  id: string
  accountId: string
  courseId: string
  status: EnrollmentStatus
  enrolledAt: Date
  completedAt?: Date
}

这是不是过度设计?

不一定。

判断标准不是类型数量,而是概念是否真实存在。

如果当前产品只是一个极简单课程站,可能可以先保留:

Account
Course
Purchase
Enrollment

不必马上引入:

OrganizationMembership
CourseStaffAssignment
CourseAccessGrant

但一旦企业分配、订阅访问、兑换码访问都出现,CourseAccessGrant 就不是过度设计,而是稳定效果抽象。

KISS 不是少建类型,而是少建没有必要的类型。

重构路线

真实项目里不要一次性大爆炸重写。

可以这样迁移:

  1. 先把 User 改名为 Account,暴露语义问题。
  2. 把登录方式拆成 Credential。
  3. 把组织关系从 companyId 拆成 OrganizationMembership。
  4. 把 isCustomer 改成查询函数。
  5. 把购买和分配拆成事实表。
  6. 把课程访问判断集中到 canAccessCourse。
  7. 当访问来源超过两三种时,引入 CourseAccessGrant。
  8. 把散落权限判断集中到 canEditCourse 等规则函数。

每一步都应该保持系统可运行。

小结

  1. 混乱的 User 通常不是一个实体,而是未命名概念的堆叠。
  2. 重构第一步不是抽象,而是识别概念。
  3. Identity、Credential、Membership、Purchase、Access、Enrollment 应该分开。
  4. 派生结论不要急着存成字段。
  5. 抽象共同效果,而不是合并不同事实。
  6. 权限应该集中成规则函数。
  7. 真实项目要小步迁移,不要大爆炸重写。