练习目标

把一个混乱的 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. 真实项目要小步迁移,不要大爆炸重写。