Skip to content

子类型、类型字段还是组合:分类建模的判据 ​

工时系统原来只能往项目上登记工时,分客户项目和内部项目两种,用一个 type 字段区分。后来要支持登记休假和培训。有人建议加一个类型值,有人建议抽一个父类,有人建议把「能不能计费」「要不要预先分配人员」做成配置。三种方案都能跑通演示。

「要不要用继承」这个问题,问法本身就容易把人带偏:它让人先想代码结构,再回头找业务理由。更有用的问法是:这些东西在业务上是不是同一类东西?它们的差别是规则的差别还是取值的差别?差别会沿着哪个方向继续变化? 答案确定之后,子类型、类型字段、策略和组合各有自己的位置,存储也随之确定。

本文用工时登记目标这个例子走一遍判断过程,并在 JDK 25 与 PostgreSQL 18 上验证几种方案各自在哪里出错。实验见文末。

问题回答「是」时的做法(括号里是实测)差别只是取值不同?类型字段或属性;规则不同才分类型费率不同 → 字段;「登记前须分配」→ 规则父类型的操作,有子类型做不到?把操作留在具体类型上,不放进共同概念休假继承项目:统一 charge(2) 时第 2 个抛异常,第 1 个已计费分类封闭、由业务决定?sealed 接口 + 没有 default 的 switch 表达式新增 LEAVE:default 分支一处按内部、一处按客户;sealed 编译失败这个维度能独立于分类变化?属性或策略:是否需分配、每日上限的覆盖默认值改 8 → 7.5:复制的仍是 8,动态继承的变 7.5别的表要引用任意一种目标?共同父表分配身份,引用用外键(type, id) 引用:2 条悬空;父表外键拒绝悬空与错配,挡不住缺子行
图 1 · 实测(JDK 25.0.4.1、PostgreSQL 18.6):先找出共同概念「登记目标」,再逐条判断。差别只是取值时一个字段就够;父类型的操作不是所有子类型都能做,就不属于共同概念;封闭的分类用 sealed 类型让编译器列出漏处理的分支;能独立变化的维度不绑定到分类上;被引用的多态目标需要统一的身份,否则引用加不了外键。

一、先找出共同概念,再谈继承 ​

原来的模型里,工时只能挂在「项目」上。要登记休假,最省事的做法是把休假也当成一个项目:建一个叫「休假」的内部项目,所有人往里登记。这样做不用改模型,代价是「项目」这个词的含义悄悄变了:项目有预算、有客户、有成员分配,休假一样都没有,所有读到「项目」的代码都要开始猜这一条是不是「真的项目」。

更好的做法是先问:工时登记关心的到底是什么?答案是「一段工时花在了哪里」。客户项目、内部项目、休假、培训都是可登记工时的目标,这是它们的共同概念。给它起一个业务方认可的名字(WorkTarget、登记目标),工时就挂在这个概念上:

text
工时 ──→ 登记目标
            ├─ 客户项目(预算、客户、计费)
            ├─ 内部项目(预算)
            └─ 非项目活动(休假、培训)

共同概念有两个作用:它让工时的规则(每天不超过多少小时、按周提交)只写一次;它让「项目」重新只表示项目。新名字要和领域专家一起确定,并且放到它服务的那个模块里:登记目标属于工时模块,而不是项目模块。

但有了共同概念,并不意味着下面一定要用继承。

二、四个判据 ​

判据一:差别是规则还是取值? 客户项目和内部项目如果只是「费率」不同,那是一个字段的两个取值;如果客户项目要求「登记前必须被分配到项目上」、内部项目不要求,那是规则不同。只有取值不同,类型字段就够了;规则不同,才值得用不同的类型表达。这条判据在领域建模的分类一节有更多例子。

判据二:父类型的每个操作,子类型都能做吗? 这是可替换性的要求。休假如果继承了项目,就继承了「计费」:

text
对所有登记目标统一 charge(2):第 2 个目标抛出 UnsupportedOperationException(休假不能计费),第 1 个已经计费 2 小时

通用代码按父类型调用,子类拒绝了父类承诺的操作,而且是在做了一半之后才失败。这说明「计费」不属于共同概念,它只是客户项目的能力。把它留在客户项目上,共同概念只保留所有目标都成立的操作。可替换性的完整讨论见里氏替换原则。

判据三:类型集合由谁掌控? 登记目标的种类由业务决定,每增加一种都是一次需求变更,而且它们之间的差别要在很多地方处理(计费、报表、审批)。这种「封闭且很少增加」的集合,最怕的是加了一种之后有地方没处理:

text
新增类型 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 小时,租户还可以决定是否允许项目覆盖。这里有三个概念:默认值、覆盖值、是否允许覆盖。它们跟项目的分类无关,客户项目和内部项目都可能覆盖。

更关键的是覆盖的语义,它有两种:

text
租户把每日上限从 8 改为 7.5:创建时复制的项目 A 仍是 8;动态继承的项目 A 变成 7.5;覆盖为 10 的项目 B 仍是 10
租户随后关闭「允许项目覆盖」:项目 B 存着的覆盖值仍是 10,生效值变成 7.5
  • 创建时复制:项目创建时把默认值抄一份。之后租户怎么改,老项目都不受影响。适合「签合同时约定的条件」。
  • 动态继承:项目只存自己的覆盖值,没有覆盖就每次读取租户的当前值。租户一改,所有没覆盖的项目立刻跟着变。适合「公司统一的政策」。

同一组操作在两种语义下的生效值:

初始:租户上限 8租户改为 7.5再关闭「允许覆盖」项目 A创建时复制项目 A动态继承项目 B覆盖为 1088不受影响8不受影响87.5跟着变7.510107.5覆盖值仍存着 10生效值由一个 effective(tenant) 方法计算,不散落成 project.max != null ? … : tenant.max
图 2 · 实测(JDK 25.0.4.1):租户把每日上限从 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 小时登记的工时还有效吗?如果规则没有版本或生效日期,这个问题根本没法回答。规则本身也是会随时间变化的数据,见计划、发生与记录三个时间。

四、落到存储:跨类型的身份与引用 ​

模型确定了「工时引用登记目标」,存储就要让这条引用可靠。持久化与重建比较过三种继承映射的约束和查询;这里看一个它们都要面对的问题:别的表怎样引用一个多态的目标。

每个具体类型一张表、各自自增时:

text
每个具体类型一张表、各自自增:client_project (1, 客户项目 A),leave_activity (1, 年假):两个目标的 id 都是 1
工时表用 (target_type, target_id) 引用,无法声明外键:引用不存在的 LEAVE 99 成功;删除客户项目 1 也成功;现在有 2 条工时指向不存在的目标

target_id = 1 本身不说明是哪个目标,必须带上类型。更糟的是这种「多态引用」没法声明外键,因为外键只能指向一张表。悬空的引用、被删掉的目标,数据库都不会拦。

有一个共同父表分配身份时,引用就有了落点:

sql
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);
text
工时引用不存在的目标 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 接口新增子类型时的编译失败、违反可替换性的子类、默认值的复制与动态继承、分表各自自增与共同父表的身份和引用(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权