如何通过Sealed Classes构建领域驱动设计中的受限代数数据类型?

2026-04-29 09:142阅读0评论SEO基础
  • 内容介绍
  • 相关推荐

本文共计848个文字,预计阅读时间需要4分钟。

如何通过Sealed Classes构建领域驱动设计中的受限代数数据类型?

Java 的密封类(`sealed`)配合 `record`,为数据类型(ADT)的量身定制提供了原生支持。它不依赖于接口模拟或抽象类,也不依赖手写的子类模板,而是通过编译器强制保证:

典型场景比如支付方式、订单状态、解析结果这类“有限且封闭”的业务概念——它们天然不是开放扩展的,而是有明确、固定种类的。这时候用 sealed interface 定义契约,用 record 实现具体变体,语义清晰、不可变、无冗余代码。

必须满足的三个语法硬约束

漏掉任意一条,编译直接报错,不是运行时问题:

  • sealed 接口或类必须显式带 permits 子句(除非所有子类与它在同一源文件中,此时可省略)
  • 每个被 permits 列出的实现类/子类,必须声明为 finalsealednon-sealed
  • record 实现密封接口时,必须加 final(哪怕不写,record 默认就是 final,但显式写上更安全、可读性更强)

switch 表达式能做穷尽检查的关键条件

只有当所有分支都覆盖了 permits 列出的类型,且这些类型本身是 final(或至少没有额外子类),编译器才允许你省略 default 分支。否则会报错:the switch expression does not cover all possible values

示例:

String process(PaymentMethod method) { return switch (method) { case CreditCard c -> "Card: " + c.number().substring(12); case BankTransfer b -> "Bank: " + b.accountNumber(); case DigitalWallet w -> "Wallet: " + w.provider(); // 没有 default —— 因为 PaymentMethod 是 sealed interface, // 且 CreditCard / BankTransfer / DigitalWallet 全是 final record }; }

如果其中某个实现类是 non-sealed class,或者漏写了 case,编译就会失败。

模块和包位置容易被忽略的限制

密封类/接口与其 permits 列出的子类/实现类,**必须在同一个模块中**;如果用的是无模块项目(即默认模块),则它们必须在**同一编译单元内可见**——通常意味着:要么同在一个 .java 文件,要么在同一个模块路径下且相互可访问。

常见踩坑点:

  • sealed interface PaymentMethod 放在 domain 包,却把 CreditCard record 放在 infra 包 → 编译失败
  • 用了多模块 Maven 项目,但没在 module-info.java 中导出密封接口所在的包,或没对实现模块添加 requires → 子类无法被识别为合法许可类型
  • 子类用了 protected 构造器或包私有访问控制,导致密封类无法“看到”其可实例化性 → 编译报 class is not accessible

最稳妥的做法:把密封接口和它的所有 record 实现放在同一个文件里,尤其是初期建模阶段。等结构稳定后再按需拆分,并同步处理模块声明。

本文共计848个文字,预计阅读时间需要4分钟。

如何通过Sealed Classes构建领域驱动设计中的受限代数数据类型?

Java 的密封类(`sealed`)配合 `record`,为数据类型(ADT)的量身定制提供了原生支持。它不依赖于接口模拟或抽象类,也不依赖手写的子类模板,而是通过编译器强制保证:

典型场景比如支付方式、订单状态、解析结果这类“有限且封闭”的业务概念——它们天然不是开放扩展的,而是有明确、固定种类的。这时候用 sealed interface 定义契约,用 record 实现具体变体,语义清晰、不可变、无冗余代码。

必须满足的三个语法硬约束

漏掉任意一条,编译直接报错,不是运行时问题:

  • sealed 接口或类必须显式带 permits 子句(除非所有子类与它在同一源文件中,此时可省略)
  • 每个被 permits 列出的实现类/子类,必须声明为 finalsealednon-sealed
  • record 实现密封接口时,必须加 final(哪怕不写,record 默认就是 final,但显式写上更安全、可读性更强)

switch 表达式能做穷尽检查的关键条件

只有当所有分支都覆盖了 permits 列出的类型,且这些类型本身是 final(或至少没有额外子类),编译器才允许你省略 default 分支。否则会报错:the switch expression does not cover all possible values

示例:

String process(PaymentMethod method) { return switch (method) { case CreditCard c -> "Card: " + c.number().substring(12); case BankTransfer b -> "Bank: " + b.accountNumber(); case DigitalWallet w -> "Wallet: " + w.provider(); // 没有 default —— 因为 PaymentMethod 是 sealed interface, // 且 CreditCard / BankTransfer / DigitalWallet 全是 final record }; }

如果其中某个实现类是 non-sealed class,或者漏写了 case,编译就会失败。

模块和包位置容易被忽略的限制

密封类/接口与其 permits 列出的子类/实现类,**必须在同一个模块中**;如果用的是无模块项目(即默认模块),则它们必须在**同一编译单元内可见**——通常意味着:要么同在一个 .java 文件,要么在同一个模块路径下且相互可访问。

常见踩坑点:

  • sealed interface PaymentMethod 放在 domain 包,却把 CreditCard record 放在 infra 包 → 编译失败
  • 用了多模块 Maven 项目,但没在 module-info.java 中导出密封接口所在的包,或没对实现模块添加 requires → 子类无法被识别为合法许可类型
  • 子类用了 protected 构造器或包私有访问控制,导致密封类无法“看到”其可实例化性 → 编译报 class is not accessible

最稳妥的做法:把密封接口和它的所有 record 实现放在同一个文件里,尤其是初期建模阶段。等结构稳定后再按需拆分,并同步处理模块声明。