练习目标
把一个混乱的 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 不是少建类型,而是少建没有必要的类型。
重构路线
真实项目里不要一次性大爆炸重写。
可以这样迁移:
- 先把
User改名为Account,暴露语义问题。 - 把登录方式拆成
Credential。 - 把组织关系从
companyId拆成OrganizationMembership。 - 把
isCustomer改成查询函数。 - 把购买和分配拆成事实表。
- 把课程访问判断集中到
canAccessCourse。 - 当访问来源超过两三种时,引入
CourseAccessGrant。 - 把散落权限判断集中到
canEditCourse等规则函数。
每一步都应该保持系统可运行。
小结
- 混乱的
User通常不是一个实体,而是未命名概念的堆叠。 - 重构第一步不是抽象,而是识别概念。
- Identity、Credential、Membership、Purchase、Access、Enrollment 应该分开。
- 派生结论不要急着存成字段。
- 抽象共同效果,而不是合并不同事实。
- 权限应该集中成规则函数。
- 真实项目要小步迁移,不要大爆炸重写。