18.3 层超类型
为某一层的全部类型提供共同超类型,集中该层共享的基础行为。
学习目标
- 能识别同一层多个类型真正共享的身份、脏标记或版本机制,并说明为何不应共享业务行为
- 能编写 TypeScript 层超类型及子类,验证继承边界、状态更新和并发版本规则
- 能根据重复代码、类型边界和继承耦合的证据,判断何时采用或拒绝层超类型
为什么 18.3 层超类型值得单独学习
客户、订单和商品对象可能都要记录数据库身份、是否修改以及乐观锁版本。如果每个类各自实现这些机制,修复一个持久化缺陷就要同步修改多个类型;但如果把所有便利方法、领域规则和外部服务都塞进基类,基类又会成为不可审查的上帝对象。
↡为同一层多个类型提供共同基础机制的超类或共享抽象。 的边界是“同一层、同一责任、真正稳定的共同机制”。它可以集中 ID、脏标记和版本检查,却不应让 Customer、Order 和 Product 因为继承关系被迫拥有彼此无关的业务行为。
本章只覆盖 2024 年中文版公开目录中的 18.3 层超类型,依据作者图书页和公开模式目录独立重写。案例、实验、代码、练习和答案均为本课程原创,不复现原书正文、插图或代码。
先建立直觉:把重复机制放在正确楼层
先比较三个类的变化原因:身份字段由持久化层约束,脏标记由变更跟踪约束,而“客户是否可下单”“订单是否可取消”分别由领域规则约束。只有前两类机制具有跨多个类型的同一语义,才有资格进入共同超类型。
↡同一层内多个类型都需要、且含义和生命周期一致的基础行为。需要通过三个问题验证:所有子类是否都需要它?删除它会不会让每个子类重复相同代码?它是否不依赖某个具体业务概念?任一问题回答为否,都应考虑组合、接口或保持局部实现。
继承还带来↡子类被迫依赖超类状态、初始化顺序或方法契约,导致修改基类会影响全部子类的风险。。层超类型不是免费复用;团队必须记录基类变化影响的类型数量、子类是否能独立测试,以及是否需要把共性机制替换成组合。
目录单元到教学证据
18.3 层超类型
本章精确对应 manifest 单元 poeaa24-pattern-43-layer-supertype。案例用 Customer、Order 和 Product 表示同一领域层的三个类型,学习边界是把身份、脏标记和版本放入公共基础,同时让具体业务规则留在子类或领域服务。
完成本单元要交出三样证据:一张超类型与子类的责任图;一段带版本和脏标记的 TypeScript 代码;一个拒绝样本,证明业务便利方法进入基类后会造成继承耦合或语义污染。
专属案例:持久化实体的共同基础
订单系统的 Customer、Order 和 Product 都需要 id、version 与 isDirty。它们不应该都重新写 markDirty 和版本条件检查;但 Customer 的 canPlaceOrder、Order 的 cancel 和 Product 的 reserve 不共享同一语义,不能为了少写几行代码移动到基类。
评审卡记录如下:单元键为 poeaa24-pattern-43-layer-supertype;模式族为 base;采用条件是“同一层至少有多个类型拥有稳定、无业务特化的共同机制”;观测项包括重复实现数量、基类变化影响面、子类测试隔离和组合替代成本;拒绝条件是“基类需要知道具体业务类型,或子类只是为了继承便利方法而被迫接受无关状态”。
专属可视化实验:看共性停在哪里
先预测:markDirty 可以放进基类,但 cancel() 是否也应该放进去?观察图中的共同字段、继承关系和子类边界,再判断一个新类型是否真的属于同一层,而不是只因为它也有一个 ID。
代码实践:把基础机制限制在超类型
下面的代码只把身份、版本和脏标记放入 PersistentEntity。子类保留自己的业务行为,测试可以验证基类机制而不要求基类了解订单或客户。
abstract class PersistentEntity {
private dirty = false;
constructor(
public readonly id: string,
public readonly version: number,
) {}
protected markDirty(): void {
this.dirty = true;
}
isDirty(): boolean {
return this.dirty;
}
nextVersion(expected: number): number {
if (expected !== this.version) throw new Error("stale entity");
return this.version + 1;
}
}
class Customer extends PersistentEntity {
rename(name: string): void {
if (!name.trim()) throw new Error("name is required");
this.markDirty();
}
}
class Order extends PersistentEntity {
cancel(): void {
// 订单取消规则属于 Order,不属于所有持久化实体的共同机制。
this.markDirty();
}
}这个边界有四个可验收点:基类不导入 Customer 或 Order;共同状态可被所有子类复用;业务方法仍位于具体类型;旧版本写入会失败。若有人把 sendEmail 或 calculateDiscount 加到 PersistentEntity,应先要求说明它为何对每个子类都有同一语义。
选择与拒绝矩阵
| 评审问题 | 采用层超类型的证据 | 应拒绝或换方案的信号 |
|---|---|---|
| 范围 | 多个同层类型拥有同一基础机制 | 子类来自不同层或变化原因不同 |
| 语义 | ID、版本和脏标记含义完全一致 | 同名字段在不同类型有不同生命周期 |
| 耦合 | 基类不认识具体业务类型 | 基类依赖 Customer、Order 等细节 |
| 测试 | 子类可独立测试,基类可单独验证 | 修改基类需要重跑所有业务场景 |
| 替代 | 继承表达稳定的 is-a 关系 | 只是为了复用一两个便利函数 |
常见误区
本章小结
- 层超类型集中同一层多个类型真正共享的基础机制,而不是所有可复用代码。
- ID、版本和脏标记可以共享;订单取消、客户授权等业务规则必须留在具体类型。
- 继承耦合、基类变化影响面和子类测试隔离是采用前必须观察的证据。
- 不能证明共同语义时,应拒绝继承,改用组合、接口或局部实现。
可验证练习
练习
问题 1:判断提升边界。 Customer、Order 和 Product 都有 id 与 version,但只有 Order 有 cancel()。哪些内容可以放进超类型,为什么?
问题 2:找出继承耦合。 基类新增 sendEmail() 后,所有实体测试都要配置邮件客户端。请说明问题及一个替代方案。
问题 3:决定是否采用。 两个类只有一个相同的 toLabel() 函数,且未来变化方向不同。应采用层超类型吗?请给出理由。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 层超类型
为同一层多个类型提供共同基础机制的超类或共享抽象。
- 共同机制
同一层内多个类型都需要、且含义和生命周期一致的基础行为。
- 继承耦合
子类被迫依赖超类状态、初始化或方法契约,基类变化会扩散到子类的风险。
- 脏标记
表示对象自读取或持久化后发生过修改、需要进一步处理的状态。
- 变化轴
会推动代码变化的独立原因,例如持久化机制与订单业务规则的变化。
前后导航
参考资料
- Martin Fowler 作者图书页:核对全书主题、章节范围和模式参考结构。
- Martin Fowler 模式目录:核对层超类型模式的公开定位与模式族。
- Pearson 出版社页面:交叉核对英文版出版信息与目录范围。