← 返回博客

基于 ADT :现代 Java 如何通过类型系统保障领域建模的安全

引言

为什么我写这玩意呢?其实是我最近写一个工具时,突然的有感而发。对于主力编写 Java 的我们,已经习惯了用满篇的 ifswitchtry-catch 来控制业务流程,但是随着现代 Java 的演进,Java 的类型系统引入了许多函数式编程(FP)中的优秀特性,我们已经可以借助一些特性,用类型系统更优雅的来保障业务安全了。

今天,我想依托于现代 Java 的新特性,和大家分享一些新的写法:基于 ADT(代数数据类型)、密封类(Sealed Classes)和模式匹配(Pattern Matching),在类型系统的层面,彻底保障领域建模的安全。

核心目标就是 非法的业务状态在编译期提前报错

什么是 ADT(代数数据类型)?

ADT(Algebraic Data Types)是函数式编程范式中的一个核心概念。它主要由两种类型组成:

1. 积类型 (Product Type)

积类型代表的是“组合”(AND 的关系)。Java 中最常见的 ClassRecord 就是典型的积类型。它的内部由多个属性构成,它的状态空间是各个属性状态的乘积(笛卡尔积)。

2. 和类型 (Sum Type)

和类型代表的是“互斥”(OR 的关系)。 它表示一个类型 只能是 几种特定形态中的某一种。在大部分 OOP 语言设计之初,真正的“和类型”是不存在的,都是在语言演进中逐步实现的。

Java 中最接近的工具是 Enum,但是枚举有几个无法克服的缺陷:

  1. 枚举的本质是类,其内部成员是对象。
  2. 它无法为不同的状态携带不同的属性。
  3. 对于 Java 现有实现,无法通过类型区分同一个枚举中的不同实现。

那么,Java 中的和类型是如何实现的?

现代 Java 中的“和类型”实现

现代 Java 逐步演进中,吸收了 FP 很多优点与特性,完美实现了和类型的基本要求:

  1. Java 17 密封类 (Sealed Classes): 对现有继承体系进行严格限制。对类型拓展进行强限制,为后续的模式匹配进行基础支撑。
  2. Java 21完善模式匹配 (Pattern Matching for switch): 编译器可以基于密封类进行穷尽检测,使其对于业务的抽象能力,更进一步。

“有穷尽”就是和类型的基本特征。

当你用 switch 对某个密封接口进行匹配时,如果不写 default 或漏掉了某个子类,代码将直接无法编译通过,这把原本在运行时才会暴露的一些逻辑错误或异常,提前暴露至编译期。

如何应用?(实战场景)

场景一:业务状态判断

传统开发中,我们常用枚举或布尔值表示状态,但是如果状态本身需要携带数据,那处理起来就很麻烦,过去都是把所有字段都塞进一个大 Class 里,然后通过约定的状态字段来判断哪些字段有效,但是这样极容易出现一个问题,对象状态与对象数据不一致,而 ADT 解决方案就很简单,为每个状态定义类型,A 类型必然只有 A属性

Java

// 使用 sealed 定义穷尽的业务状态
public sealed interface PaymentStatus{
    record Pending() implements PaymentStatus {}
    record Success(String transactionId) implements PaymentStatus {}
    record Failed(String errorCode, String reason) implements PaymentStatus {}
}

// 业务处理逻辑
// 当遗漏某个业务状态时,会直接报错
public String processPayment(PaymentStatus status) {
    return switch (status) {
        case PaymentStatus.Pending _ -> "支付处理中,请稍后...";
        case PaymentStatus.Success(var transactionId) -> "支付成功,流水号:" + transactionId;
        case PaymentStatus.Failed(var errorCode, _) when "402".equals(errorCode) -> "支付失败 码值为 402";
        case PaymentStatus.Failed(var errorCode, var reason) -> "支付失败,原因:" + reason + ",错误码:" + errorCode;
    };
}

优势: 领域模型变得异常干净。状态与该状态专属的数据强绑定,你永远不可能在一个 Pending 状态中取出一个错误的 transactionId

场景二:状态机实现

复杂的业务通常需要状态机来进行数据与状态的解耦,而传统的状态机依赖于庞大的框架或一堆难以维护的 if-else。使用 ADT,我们可以写出极其优雅且类型安全的状态机。

public sealed interface OrderEvent{
    record Pay(String txId) implements OrderEvent {}
    record Ship(String expressNo) implements OrderEvent {}
    record Cancel(String reason) implements OrderEvent {}
}


public PaymentStatus transition(PaymentStatus currentStatus, OrderEvent event) {
    return switch (currentStatus) {
        case Pending() -> switch (event) {
            case OrderEvent.Pay(var txId) -> new Success(txId);
            case OrderEvent.Cancel(var reason) -> new Failed("CANC", reason);
            case OrderEvent.Ship _ -> throw new IllegalStateException("未支付不能发货");
        };
        case Success s -> switch (event) {
            case Ship _ -> currentStatus;
            default -> throw new IllegalStateException("已支付状态不支持此操作");
        };
        case Failed _ -> currentStatus;
    };
}

优势: 所有的状态和事件组合一目了然。以后每增加一种新状态,编译器会立刻报错提示你去完善相应的 switch 分支,彻底杜绝“改漏了”的风险。

场景三:内部错误处理(Result 模式)

日常我们使用的异常从性质上来讲,我们可以分为两种:业务异常、程序异常。对于部分程序异常与业务异常,不应尝试 new Exception() ,在 Java 内部,异常的性能损耗是在初始化异常(fillInStackTrace())时发生的,而不是 throw 时发生,因此对于预料中的程序异常,我们应该构建正常对象来代替部分程序异常。同时,业务异常应该属于业务处理逻辑的一部分,而不是作为一个统一异常来处理,即消耗资源,又无法使调用方明白内部的可能性。因此可以借鉴 Rust 的 Result 类型,定义报错为类型,而不是中断,使调用方必须处理该类型报错。

public sealed interface Result<T, E> {
    record Ok<T, E>(T data) implements Result<T, E> {}
    record Err<T, E>(E error) implements Result<T, E> {}
}

// 扣减余额的服务
public Result deductBalance(String userId, double amount) {
    if (amount < 0) {
        return new Result.Err<>("扣减金额不能为负数");
    }
    return new Result.Ok<>("tx_99872");
}

// 调用方
public void handleRequest() {
    Result result = deductBalance("user_123", 100.0);
    // 调用方必须明确处理 Ok 和 Err 两种情况
    switch (result) {
        case Result.Ok(var txId) -> System.out.println("扣款成功: " + txId);
        case Result.Err(var error) -> System.out.println("业务异常: " + error);
    }
}

优势: 把“隐式”的异常变成了“显式”的返回值。通过编译期的校验,迫使开发人员在编写代码时直面所有可能出现的业务分支。

场景四:复杂消息与事件路由

在以消息驱动的系统中,我们经常需要在入站端点接收多种消息或事件,然后将其分发到不同的处理逻辑中。传统做法通常是依赖一个类型字段,配合一大堆 if-else 来实现路径的选择。而在 ADT 构建的系统中,我们可以通过定义事件的类型,来使其通过模式匹配进行路径选择处理。

public sealed interface UserEvent {
    record UserCreated(String userId, String email) implements UserEvent {}
    record UserUpdated(String userId, String newPhone) implements UserEvent {}
    record UserDeleted(String userId, String reason) implements UserEvent {}
}

// 消息监听器
public void onMessageReceived(UserEvent event) {
    // 编译器保障你处理了所有可能接收到的事件类型
    switch (event) {
        case UserEvent.UserCreated( _, String email) -> sendWelcomeEmail(email);
        case UserEvent.UserUpdated updated -> syncToDataWarehouse(updated);
        case UserEvent.UserDeleted(var userId, var reason) -> archiveUserData( userId, reason);
    }
}

核心优势: 实现了真正的“类型驱动分发”。新增事件时,编译器会强迫你去找到所有消费该事件的 switch 代码块并添加处理逻辑,避免了传统事件驱动架构中“发了事件但没人处理”的问题。

结语

从单一范式 OOP 走向多范式支持,这是 Java 现代化演进的趋势。Java 也已经不再是那个只能靠设计模式堆砌的“啰嗦”语言了。通过密封类模式匹配,我们完全可以将许多曾经在运行时才能发现的领域逻辑错误,提前在编译期直接暴露。

希望这些现代 Java 的特性,能成为大家日常应对复杂业务建模时的一把利器。要拥抱变化,别做一个只会 1.8 的老古董!!!