同一个人,几种角色:大实体、接口还是角色对象
培训平台上线时只有学员。后来学员可以申请成为讲师,于是用户表加了
is_instructor、bio、hourly_fee三列。再后来有了助教、企业管理员、审核员,用户表长到四十多列,每个字段旁边都要注释「只对 XX 有效」。
「一个人在不同场景下扮演不同角色」是业务里最常见的建模问题之一:同一个人可以是学员也是讲师,同一家公司可以是客户也是供应商,同一个员工可以是申请人也是审批人。它有三种常见的表达方式,各自在不同的地方出问题。
本文用一个培训平台的例子比较这三种方式:角色撤销之后剩下什么、接口能不能限制调用方、角色的历史在哪里、每个角色的规则放在哪里。实验在 JDK 25 上运行,见文末。
一、先问角色和人是什么关系
动手之前,用几个业务问题确认角色的性质:
| 问题 | 答案的含义 |
|---|---|
| 一个人能同时有几种角色? | 能同时有多种,就不能用一个「类型」字段区分人 |
| 角色能撤销、能再次获得吗? | 能,角色就有自己的生命周期,不等于人的生命周期 |
| 需要知道「某天他是不是讲师」吗? | 需要,角色要带有效期,撤销不是覆盖 |
| 不同角色的规则不同吗? | 讲师要有课时费,学员要有水平等级:规则属于角色,不属于人 |
| 不同角色由不同的模块使用吗? | 课程发布只关心讲师能力,选课只关心学员能力 |
如果答案都是「否」,比如一个用户永远只有一种身份、不撤销、没有专属规则,那么一个类型字段就够了,下面的讨论都不必要。
二、一个大实体:数据和规则都散开了
class Member {
String name; boolean instructor; boolean learner;
String bio; Integer hourlyFee; // 只对讲师有意义
String level; // 只对学员有意义
}大实体撤销讲师:instructor=false,但 bio=十年后端、hourlyFee=500 仍在;按「有课时费」统计讲师会把她算进去
只是学员的 bob:讲师字段有 2 个为 null;「讲师必须有课时费」只能写成 if (instructor && hourlyFee == null),在每个修改入口重复
撤销后再问「alice 9 月份是不是讲师」:只有当前的布尔值 false,历史没有了三个问题都来自同一个原因:角色没有成为一个概念。角色的数据挂在人身上,撤销角色只改了标志,数据留了下来;角色的规则只能写成「如果有这个标志,那么……」,散落在每个修改它的地方;角色的时间维度根本无处存放。
大实体的优点是简单,查询一个人的所有信息只需要一行。角色少、几乎不变时,它是合理的起点。信号是:开始出现「只对 XX 有效」的字段注释,开始有人问「他什么时候成为讲师的」。
三、一个类实现多个接口:视图不是边界
第二种做法用接口区分能力:
interface Learner { void enroll(String course); }
interface Instructor { void publish(String course); }
class Person implements Learner, Instructor { ... public void grantAdmin() { ... } }选课模块只拿到 Learner,发布模块只拿到 Instructor,看起来每个模块只能用自己的那部分能力。这是接口隔离原则的典型用法,它让依赖变窄、让测试替身更好写。但它不是权限隔离:
把 carol 作为 Learner 传给只该用学员能力的代码:它 instanceof 转回 Person 并调用 grantAdmin(),admin=true;接口是视图,不是权限边界
Learner 与 Instructor 是同一个对象:true;两个角色共享一份状态和一个生命周期,撤销讲师角色只能靠再加一个标志接口只是编译期的视图,对象本身拥有全部能力。只要调用方能看到实现类,一次类型转换就能拿到全部能力。要真正限制能力,需要让调用方看不到实现:实现类放在不导出的包里(JPMS 模块),或者传给它一个只包装了部分能力的独立对象。
另一个问题是生命周期:两个接口背后是同一个对象,「撤销讲师角色」没有对应的操作,只能退回到加一个标志。所以接口适合表达「同一个对象在不同调用方眼里的不同用途」,不适合表达「一个人获得和失去的角色」。接口隔离与权限的区别,在设计原则里也有讨论。
四、参与方加角色对象:角色成为概念
第三种做法把人和角色分开:人(参与方)只保留身份信息,每个角色是一个单独的对象,有自己的有效期、字段和规则。
record Party(String id, String name) { }
sealed interface Role permits InstructorRole, LearnerRole { Party party(); LocalDate from(); Optional<LocalDate> to(); }
record InstructorRole(Party party, LocalDate from, Optional<LocalDate> to, int hourlyFee) implements Role {
InstructorRole { if (hourlyFee <= 0) throw new IllegalArgumentException("讲师课时费必须为正:" + hourlyFee); }
}alice 在 9 月 15 日是讲师:true;11 月 15 日:false;讲师角色共 2 段,课时费分别是 500、600
讲师的规则放在讲师角色自己的构造器里,创建课时费为 0 的讲师角色:被拒绝(讲师课时费必须为正:0);学员角色不需要这条检查撤销讲师是给角色设置结束日期,数据和历史都保留;再次成为讲师是一个新的角色对象,可以有不同的课时费;讲师的规则写在讲师角色里,学员完全不受影响。每个模块只拿它关心的角色对象:发布课程的模块拿到 InstructorRole,它根本没有办法触及管理员能力,因为那是另一个对象。
代价是:
- 查询变复杂:「列出所有当前讲师」要按角色类型和有效期查询。
- 身份要统一:所有角色都指向同一个参与方,参与方的身份由谁分配、跨角色的引用怎样落库,和子类型、类型字段还是组合里的共同父表是同一个问题。
- 容易过度抽象:把人和组织都抽象成「参与方」、把所有关系都抽象成「责任」,能表达一切,也让每个简单需求都要穿过几层。只有当不同的参与方确实共享身份规则和角色时才值得这样做。组织之间的上下级关系怎样建模,见组织结构怎样建模。
五、怎样选
| 情况 | 选择 |
|---|---|
| 一种身份、不撤销、没有专属规则 | 类型字段 |
| 同一个对象被不同调用方以不同用途使用 | 接口(视图),不当权限边界 |
| 角色可以叠加、撤销、再获得,有专属数据和规则,需要历史 | 角色对象,带有效期 |
| 不同角色属于不同的限界上下文 | 各上下文有自己的模型,只共享参与方的 id |
最后一行值得单独说:讲师在课程上下文里有课时费和简介,在结算上下文里有银行账户和税务信息。把这些都放进一个 InstructorRole,又会长成一个大实体。按上下文分开时,每个上下文只保留它需要的那部分,用参与方的 id 关联。上下文之间怎样翻译同一个名词,见上下文集成。
六、从大实体演进到角色对象
已有的大实体不必一次改完:
- 先把一个角色的字段和规则提取成角色对象,大实体上保留一个只读的委托(
member.instructorRole()),新代码只用角色对象。 - 数据迁移:为当前
is_instructor = true的人创建一段从「已知最早日期」开始的讲师角色;没有历史的,开始日期要标明来自迁移。 - 把撤销、重新获得改为操作角色对象的有效期;删除旧标志。
- 每提取一个角色,跑一次字符化测试,确认统计、权限检查的结果没有变化。
七、检查清单
- 先用业务问题确认角色能否叠加、撤销、有历史、有专属规则。
- 角色的数据和规则放在角色里,不放在人身上加标志。
- 接口用来收窄依赖,不当作权限边界;需要隔离能力时让调用方看不到实现。
- 角色带有效期,撤销是设置结束日期。
- 不同上下文的「同一个角色」各自建模,只共享参与方 id。
- 不为了将来可能的角色提前建通用参与方模型。
配套实验
- codesphere-labs/ddd/roles-of-a-party:JDK 25.0.4.1:大实体撤销角色后的遗留数据与历史、接口视图被强制转换、角色对象的有效期与各自的规则(验证记录)
参考资料
- Martin Fowler,Dealing with Roles
- Martin Fowler,《Analysis Patterns: Reusable Object Models》,Accountability 一章(参与方)
- JEP 409:Sealed Classes
- Java 语言规范:Modules(导出与可访问性)