子类型、类型字段还是组合:分类建模的判据
工时系统原来只能往项目上登记工时,分客户项目和内部项目两种,用一个
type字段区分。后来要支持登记休假和培训。有人建议加一个类型值,有人建议抽一个父类,有人建议把「能不能计费」「要不要预先分配人员」做成配置。三种方案都能跑通演示。
「要不要用继承」这个问题,问法本身就容易把人带偏:它让人先想代码结构,再回头找业务理由。更有用的问法是:这些东西在业务上是不是同一类东西?它们的差别是规则的差别还是取值的差别?差别会沿着哪个方向继续变化? 答案确定之后,子类型、类型字段、策略和组合各有自己的位置,存储也随之确定。
本文用工时登记目标这个例子走一遍判断过程,并在 JDK 25 与 PostgreSQL 18 上验证几种方案各自在哪里出错。实验见文末。
一、先找出共同概念,再谈继承
原来的模型里,工时只能挂在「项目」上。要登记休假,最省事的做法是把休假也当成一个项目:建一个叫「休假」的内部项目,所有人往里登记。这样做不用改模型,代价是「项目」这个词的含义悄悄变了:项目有预算、有客户、有成员分配,休假一样都没有,所有读到「项目」的代码都要开始猜这一条是不是「真的项目」。
更好的做法是先问:工时登记关心的到底是什么?答案是「一段工时花在了哪里」。客户项目、内部项目、休假、培训都是可登记工时的目标,这是它们的共同概念。给它起一个业务方认可的名字(WorkTarget、登记目标),工时就挂在这个概念上:
工时 ──→ 登记目标
├─ 客户项目(预算、客户、计费)
├─ 内部项目(预算)
└─ 非项目活动(休假、培训)共同概念有两个作用:它让工时的规则(每天不超过多少小时、按周提交)只写一次;它让「项目」重新只表示项目。新名字要和领域专家一起确定,并且放到它服务的那个模块里:登记目标属于工时模块,而不是项目模块。
但有了共同概念,并不意味着下面一定要用继承。
二、四个判据
判据一:差别是规则还是取值? 客户项目和内部项目如果只是「费率」不同,那是一个字段的两个取值;如果客户项目要求「登记前必须被分配到项目上」、内部项目不要求,那是规则不同。只有取值不同,类型字段就够了;规则不同,才值得用不同的类型表达。这条判据在领域建模的分类一节有更多例子。
判据二:父类型的每个操作,子类型都能做吗? 这是可替换性的要求。休假如果继承了项目,就继承了「计费」:
对所有登记目标统一 charge(2):第 2 个目标抛出 UnsupportedOperationException(休假不能计费),第 1 个已经计费 2 小时通用代码按父类型调用,子类拒绝了父类承诺的操作,而且是在做了一半之后才失败。这说明「计费」不属于共同概念,它只是客户项目的能力。把它留在客户项目上,共同概念只保留所有目标都成立的操作。可替换性的完整讨论见里氏替换原则。
判据三:类型集合由谁掌控? 登记目标的种类由业务决定,每增加一种都是一次需求变更,而且它们之间的差别要在很多地方处理(计费、报表、审批)。这种「封闭且很少增加」的集合,最怕的是加了一种之后有地方没处理:
新增类型 LEAVE(休假)后没有改任何分支:时薪 0,可计费 true:一处按内部项目处理,另一处按客户项目处理
加入 LeaveActivity、不改 Billing:编译失败,第 6 行:the switch expression does not cover all possible input values类型字段配上带 default 的分支,新增类型值时每个分支都「接住」了它,只是两处接住的方式正好相反,没有任何报错。sealed 接口配上没有 default 的 switch 表达式,新增子类型时编译器列出每一处要补的分支。所以在 Java 里,封闭的分类用 sealed 类型表达,比字符串或枚举加分散的 if/switch 更安全。反过来,如果种类由外部扩展(插件、租户自定义),行为就应该放进各自的实现里,调用方只依赖接口。两种写法的对比见封装、组合与多态。
判据四:有没有第二个变化维度? 这是最容易被忽略的一条。「客户项目要分配人员、内部项目不用」是现在的情况,如果把「是否需要分配」绑定到子类型上,下一个需求「这个内部项目是保密的,也要先分配」就无处安放,只能再加一个子类。两个独立的二值维度用继承表达需要 4 个类,三个就是 8 个;用组合表达只是几个属性或策略对象。
判断一个维度是否独立,就问:它能不能在不改变分类的情况下变化?「是否需要分配」可以对任何一种项目单独设置,它就是独立的,应该是属性或策略,而不是分类。
三、默认值与覆盖值是另一个维度
工时系统里常见的一类需求:租户(公司)设定每日最多登记 8 小时,个别项目可以覆盖为 10 小时,租户还可以决定是否允许项目覆盖。这里有三个概念:默认值、覆盖值、是否允许覆盖。它们跟项目的分类无关,客户项目和内部项目都可能覆盖。
更关键的是覆盖的语义,它有两种:
租户把每日上限从 8 改为 7.5:创建时复制的项目 A 仍是 8;动态继承的项目 A 变成 7.5;覆盖为 10 的项目 B 仍是 10
租户随后关闭「允许项目覆盖」:项目 B 存着的覆盖值仍是 10,生效值变成 7.5- 创建时复制:项目创建时把默认值抄一份。之后租户怎么改,老项目都不受影响。适合「签合同时约定的条件」。
- 动态继承:项目只存自己的覆盖值,没有覆盖就每次读取租户的当前值。租户一改,所有没覆盖的项目立刻跟着变。适合「公司统一的政策」。
同一组操作在两种语义下的生效值:
两种语义都对,选错了的后果是业务事故:本来应该「改了政策所有项目都跟着变」,结果老项目还是旧值;或者反过来,修改默认值意外改变了几百个在途项目。所以模型里要把选择写出来:生效值是怎样算出来的,一个方法 effective(tenant) 比散落在各处的 project.max != null ? project.max : tenant.max 好得多。
关闭「允许覆盖」之后还有一个问题:项目 B 之前按 10 小时登记的工时还有效吗?如果规则没有版本或生效日期,这个问题根本没法回答。规则本身也是会随时间变化的数据,见计划、发生与记录三个时间。
四、落到存储:跨类型的身份与引用
模型确定了「工时引用登记目标」,存储就要让这条引用可靠。持久化与重建比较过三种继承映射的约束和查询;这里看一个它们都要面对的问题:别的表怎样引用一个多态的目标。
每个具体类型一张表、各自自增时:
每个具体类型一张表、各自自增:client_project (1, 客户项目 A),leave_activity (1, 年假):两个目标的 id 都是 1
工时表用 (target_type, target_id) 引用,无法声明外键:引用不存在的 LEAVE 99 成功;删除客户项目 1 也成功;现在有 2 条工时指向不存在的目标target_id = 1 本身不说明是哪个目标,必须带上类型。更糟的是这种「多态引用」没法声明外键,因为外键只能指向一张表。悬空的引用、被删掉的目标,数据库都不会拦。
有一个共同父表分配身份时,引用就有了落点:
CREATE TABLE work_target (id SERIAL PRIMARY KEY, kind TEXT NOT NULL CHECK (kind IN ('CLIENT', 'LEAVE')), UNIQUE (id, kind));
CREATE TABLE client_project (
id INT PRIMARY KEY, kind TEXT NOT NULL DEFAULT 'CLIENT' CHECK (kind = 'CLIENT'), name TEXT,
FOREIGN KEY (id, kind) REFERENCES work_target (id, kind));
CREATE TABLE time_entry (id SERIAL PRIMARY KEY, target_id INT NOT NULL REFERENCES work_target (id), hours INT);工时引用不存在的目标 99:被拒绝(insert or update on table "t2_entry" violates foreign key constraint "t2_entry_target_id_fkey")
删除仍被工时引用的目标 1:被拒绝(update or delete on table "work_target" violates foreign key constraint "t2_entry_target_id_fkey" on table "t2_entry")
把 LEAVE 目标 2 同时登记成客户项目:被拒绝(insert or update on table "t2_client" violates foreign key constraint "t2_client_id_kind_fkey")
只插入父表一行 (3, CLIENT)、不插子表:成功;父表里没有子类行的目标 1 个:「恰好有一个子类行」外键表达不了子表用 (id, kind) 复合外键引用父表,再把自己的 kind 固定成常量,就挡住了「一个目标同时是两种类型」。剩下「每个父行恰好有一个子类行」,外键表达不了,要靠仓储在一个事务里同时写父子两行,或者用延迟检查的约束触发器。
共同父表不一定要对应模型里的父类。即使代码里用的是 sealed 接口加 record、没有任何继承,只要别的表需要引用「任意一种登记目标」,就值得有一个分配身份的父表。概念上统一的东西,存储上需要一个统一的身份;身份不统一,引用就只能靠约定。
五、新增一种子类型要验证什么
方案选定之后,后续每加一种登记目标,检查的不只是「加一个类」:
- 分支:编译器能列出的(sealed + switch 表达式)交给编译器;不能列出的(字符串类型字段、配置里的类型名、报表 SQL 里的
CASE)要搜索并逐个确认。 - 身份:新类型的 id 由谁分配,能不能被现有的引用指向。
- 存储:单表要不要加列和
CHECK,父子表要不要加子表,具体类表要不要修改所有多态查询。 - 契约:API 和事件里的类型值,下游是否认识新值;未知值是报错还是忽略。
六、检查清单
- 先找共同概念并命名,不借用旧概念(把休假当成项目)。
- 只有规则不同才分类型;取值不同用字段。
- 父类型的每个操作对所有子类型都成立;做不到的操作留在具体类型上。
- 封闭的分类用 sealed 类型和没有
default的 switch 表达式;开放的分类把行为放进实现。 - 能独立变化的维度用属性或策略,不绑定到分类上。
- 默认值与覆盖值写明是复制还是动态继承,生效值由一个方法计算;规则有版本或生效日期。
- 被引用的多态目标有统一的身份;不用无法加外键的 (type, id) 引用。
配套实验
- codesphere-labs/ddd/subtype-or-type-field:JDK 25.0.4.1、PostgreSQL 18.6:类型字段与 default 分支、sealed 接口新增子类型时的编译失败、违反可替换性的子类、默认值的复制与动态继承、分表各自自增与共同父表的身份和引用(验证记录)
参考资料
- Martin Fowler,《Analysis Patterns: Reusable Object Models》,Accountability 一章(参与方、组织层级与知识层)
- Martin Fowler,Class Table Inheritance、Concrete Table Inheritance
- JEP 409:Sealed Classes、JEP 441:Pattern Matching for switch
- PostgreSQL 18 文档:Foreign Keys