在 .NET Framework / .NET 中使用代数数据类型 —— 用类型表达状态与结果的设计

· · .NET, .NET Framework, C#, F#, 代数数据类型, 可辨识联合, 领域建模, 存量资产利用

1. 首先要明确的一点

在编写 .NET 业务应用程序时,经常会看到这样的返回值或状态。

public class CreateUserResult
{
    public bool IsSuccess { get; set; }
    public User User { get; set; }
    public string ErrorCode { get; set; }
    public string ErrorMessage { get; set; }
}

乍看之下很容易理解,但这个类型能容纳许多「不该存在的状态」。

举例来说,下面这些值都可以被构造出来:

  • IsSuccess == true,但 User == null
  • IsSuccess == true,却设置了 ErrorCode
  • IsSuccess == false,却设置了 User
  • ErrorCode == "DuplicateEmail",但 ErrorMessage == null
  • 新增了错误码,但调用方的处理逻辑却没有更新

这种类型刚开始使用时确实方便,但随着规模增大,会给阅读者和维护者带来越来越大的负担。

这时应该考虑的思路,就是代数数据类型

「代数数据类型」这个名字听起来有点生硬,但在实务中,可以这样理解:

「这个值可能出现的形态是事先确定好的」这一点,不用注释或命名约定来表达,而是用类型来表达。

例如,用户创建的结果可以表示为下面几种情况中的恰好一种:

CreateUserResult =
  Created(User)
  或 DuplicateEmail(email)
  或 WeakPassword(reason)
  或 SystemFailure(message)

成功时会有 User。 邮箱重复时会有 email。 密码过弱时会有 reason。 系统错误时会有 message

每个用例只携带各自需要的数据。 成功与失败不会同时成立。 「成功却没有 User」这样的状态也无法被构造出来。

在 .NET 中,可以通过 F# 的可辨识联合、C# 的sealed 类层次结构record 层次结构类似 OneOf 的库,或者未来的C# union 类型来实现这一思路。

本文将整理在 .NET Framework 与现行 .NET 中使用代数数据类型的方法,以及它在实务中的优点与注意事项。

需要说明的是,本文中出现的代码已经作为一套可以构建、运行的示例(包括库、演示各种实现模式的 demo,以及验证 Match 的完备性、状态迁移和 DTO 转换的单元测试)发布在 GitHub 上。

dotnet-algebraic-data-types - komurasoft-blog-samples (GitHub)

2. 什么是代数数据类型

代数数据类型,英文为 Algebraic Data Type,简称 ADT。

粗略来说,ADT 是两种类型的组合:

  • 积类型:同时持有 A 和 B 的类型
  • 和类型:只能是 A 或 B 之一的类型

.NET 中的 class、struct、record,大多是作为「积类型」使用的。

public sealed class Address
{
    public string PostalCode { get; }
    public string Prefecture { get; }
    public string City { get; }
    public string Street { get; }

    public Address(string postalCode, string prefecture, string city, string street)
    {
        PostalCode = postalCode;
        Prefecture = prefecture;
        City = city;
        Street = street;
    }
}

其含义是这样的:

Address = PostalCode 且 Prefecture 且 City 且 Street

而和类型则是「多者之一」。

PaymentResult =
  Succeeded(receiptNo)
  或 InsufficientFunds(shortage)
  或 Rejected(reason)
  或 NetworkFailure(message)

其含义是这样的:

PaymentResult = Succeeded 或 InsufficientFunds 或 Rejected 或 NetworkFailure

用类型表达这个「或」,正是代数数据类型中在实务中最常用的部分。

在 F# 中,可以把它作为语言特性自然地写出来。

type PaymentResult =
    | Succeeded of receiptNo: string
    | InsufficientFunds of shortage: decimal
    | Rejected of reason: string
    | NetworkFailure of message: string

C# 长期以来并没有像 F# 那样的可辨识联合作为标准特性,因此 C# 一直用类层次结构或库来表达这种概念。

不过,这种思路本身在 C# 中完全可以使用。

重要的不是使用某种特定语法,而是这一点:

「从根本上避免构造出不合法的状态」

3. 为什么只靠 bool 或 enum 是不够的

在小规模处理中,boolenum 有时看起来也够用。

例如,下面这样的返回值:

public enum PaymentStatus
{
    Succeeded,
    InsufficientFunds,
    Rejected,
    NetworkFailure
}

public sealed class PaymentResponse
{
    public PaymentStatus Status { get; set; }
    public string ReceiptNo { get; set; }
    public decimal? Shortage { get; set; }
    public string Reason { get; set; }
    public string Message { get; set; }
}

但这种形式并没有用类型表达出 Status 与各属性之间的关系。

只有在 Status == Succeeded 时才需要 ReceiptNo。 只有在 Status == InsufficientFunds 时才需要 Shortage。 只有在 Status == Rejected 时才需要 Reason。 只有在 Status == NetworkFailure 时才需要 Message

这条规则存在于代码之外。

它依赖于注释、规格说明、测试、隐含的约定,以及实现者的记忆。

结果就是,这类防御性代码会不断增加。

if (response.Status == PaymentStatus.Succeeded)
{
    if (string.IsNullOrEmpty(response.ReceiptNo))
    {
        throw new InvalidOperationException("ReceiptNo is required.");
    }

    return response.ReceiptNo;
}

这种防御性代码在某些场合确实有必要,但很多时候本来是可以靠「类型设计」来避免的。

如果用代数数据类型来表达,就可以让每个用例只携带各自需要的数据。

Succeeded 持有 receiptNo
InsufficientFunds 持有 shortage
Rejected 持有 reason
NetworkFailure 持有 message

在这种设计中,无法构造出「是 Succeeded 却没有 receiptNo」的值。

也就是说,不是事后努力去检查状态,而是从一开始就避免构造出不合法的状态。

4. 在 .NET Framework 中也能使用的实现方式:sealed 类层次结构

在包含 .NET Framework 的既有系统中,最容易引入的方式是抽象基类 + 嵌套的 sealed 类 + Match 方法

它在较旧版本的 C# 中也很好用,也不需要任何特殊的运行时特性。

作为示例,来表达一下用户创建的结果。

public abstract class CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed class Created : CreateUserResult
    {
        internal Created(User user)
        {
            if (user == null) throw new ArgumentNullException(nameof(user));
            User = user;
        }

        public User User { get; }
    }

    public sealed class DuplicateEmail : CreateUserResult
    {
        internal DuplicateEmail(string email)
        {
            if (email == null) throw new ArgumentNullException(nameof(email));
            Email = email;
        }

        public string Email { get; }
    }

    public sealed class WeakPassword : CreateUserResult
    {
        internal WeakPassword(string reason)
        {
            if (reason == null) throw new ArgumentNullException(nameof(reason));
            Reason = reason;
        }

        public string Reason { get; }
    }

    public sealed class SystemFailure : CreateUserResult
    {
        internal SystemFailure(string message)
        {
            if (message == null) throw new ArgumentNullException(nameof(message));
            Message = message;
        }

        public string Message { get; }
    }

    public static CreateUserResult Ok(User user)
        => new Created(user);

    public static CreateUserResult EmailAlreadyUsed(string email)
        => new DuplicateEmail(email);

    public static CreateUserResult PasswordIsWeak(string reason)
        => new WeakPassword(reason);

    public static CreateUserResult Failed(string message)
        => new SystemFailure(message);

    public T Match<T>(
        Func<Created, T> created,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<WeakPassword, T> weakPassword,
        Func<SystemFailure, T> systemFailure)
    {
        if (created == null) throw new ArgumentNullException(nameof(created));
        if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
        if (weakPassword == null) throw new ArgumentNullException(nameof(weakPassword));
        if (systemFailure == null) throw new ArgumentNullException(nameof(systemFailure));

        var c = this as Created;
        if (c != null) return created(c);

        var d = this as DuplicateEmail;
        if (d != null) return duplicateEmail(d);

        var w = this as WeakPassword;
        if (w != null) return weakPassword(w);

        var f = this as SystemFailure;
        if (f != null) return systemFailure(f);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

调用方可以这样写:

CreateUserResult result = service.CreateUser(command);

string message = result.Match(
    created => "用户创建成功: " + created.User.Id,
    duplicate => "该邮箱地址已被使用: " + duplicate.Email,
    weak => "密码强度不足: " + weak.Reason,
    failure => "用户创建失败: " + failure.Message);

这种形式的优点是,无论是 .NET Framework 还是现行 .NET 都可以使用。

CreatedDuplicateEmailWeakPasswordSystemFailure 都是 CreateUserResult,但各自携带的数据不同。

只有 Created 携带 User。 只有 DuplicateEmail 携带 Email。 只有 WeakPassword 携带 Reason。 只有 SystemFailure 携带 Message

无法构造出同时表示成功与失败的值。

此外,只要让调用方使用 Match,就可以强制要求处理所有用例。

例如,假设新增了一个叫 TemporaryBlocked 的用例。

public sealed class TemporaryBlocked : CreateUserResult
{
    internal TemporaryBlocked(DateTimeOffset until)
    {
        Until = until;
    }

    public DateTimeOffset Until { get; }
}

这时,也要在 Match 方法的参数中加入 Func<TemporaryBlocked, T>

于是,已有的 result.Match(...) 调用就会产生编译错误。这是一个好的错误。因为它能在编译期发现「新增了用例,但调用方没有相应更新」这个问题。

5. 用 private 构造函数构成封闭集合

在 C# 中表达和类型时,重要的一点是尽可能让用例集合是封闭的。

如果把基类的构造函数设为 protected,就会留下被外部继承的空间。

public abstract class PaymentResult
{
    protected PaymentResult()
    {
    }
}

这种形式下,其他程序集或其他位置也可以构造出这样的类型:

public sealed class UnknownPaymentResult : PaymentResult
{
}

于是,PaymentResult 的用例集合就无法保持封闭。

原本想表达「这个类型只能是 Succeeded / InsufficientFunds / Rejected / NetworkFailure 之一」,结果却多出了其他用例。

在包含 .NET Framework 的场景中,一个现实可行的对策是:把基类的构造函数设为 private,并将各个用例类型定义为基类的嵌套类型。

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    public sealed class Succeeded : PaymentResult
    {
        internal Succeeded(string receiptNo)
        {
            ReceiptNo = receiptNo;
        }

        public string ReceiptNo { get; }
    }

    public sealed class InsufficientFunds : PaymentResult
    {
        internal InsufficientFunds(decimal shortage)
        {
            Shortage = shortage;
        }

        public decimal Shortage { get; }
    }

    public static PaymentResult Success(string receiptNo)
        => new Succeeded(receiptNo);

    public static PaymentResult Insufficient(decimal shortage)
        => new InsufficientFunds(shortage);
}

嵌套类型可以访问外层类型的 private 成员。 因此,只有嵌套的用例类型才能够继承 PaymentResult

使用这种模式,在 C# 中也可以构造出接近「封闭用例集合」的效果。

不过,C# 编译器并不会像 F# 那样为你做完整的穷尽性检查。

因此,在 C# 中使用这种模式时,最好尽量不要把 switch 分散在各处,而是将处理集中到 Match 方法中。

6. 在现行 .NET 中可以用 record 层次结构更简洁地表达

如果可以以 .NET 5 及以后版本为前提,使用 C# 的 record,可以把以数据为中心的用例类型写得相当简短。

public abstract record CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed record Created(User User) : CreateUserResult;
    public sealed record DuplicateEmail(string Email) : CreateUserResult;
    public sealed record WeakPassword(string Reason) : CreateUserResult;
    public sealed record SystemFailure(string Message) : CreateUserResult;
}

调用方可以使用模式匹配和 switch 表达式。

static string ToMessage(CreateUserResult result)
{
    return result switch
    {
        CreateUserResult.Created { User: var user }
            => $"用户创建成功: {user.Id}",

        CreateUserResult.DuplicateEmail { Email: var email }
            => $"该邮箱地址已被使用: {email}",

        CreateUserResult.WeakPassword { Reason: var reason }
            => $"密码强度不足: {reason}",

        CreateUserResult.SystemFailure { Message: var message }
            => $"用户创建失败: {message}",

        _ => throw new InvalidOperationException("未知的结果。")
    };
}

这种写法很有 C# 的风格,也很易读。

但另一方面,也有需要注意的地方。

record 层次结构在减少值比较和显示相关的模板代码方面很方便。 但不应该认为它在「封闭用例集合」这一点上,能和前一章介绍的「普通 class + private 构造函数 + 嵌套 sealed 用例」具有同等强度。

尤其是对于非 sealed 的 record class,还会涉及复制构造函数等 record 特有的生成成员。 如果目的是「绝对不希望被外部派生」「希望严格封闭用例集合」,选择前一章的 class 层次结构,或者 F# 的可辨识联合,或者经过实践验证的 union / source generator 类系库,会更稳妥。

另外,在这个 switch 表达式中加入 _,看起来像是可以接收未知的派生类型。 但如果设计目标是把用例集合当作封闭集合来处理,_ 本来就应该是「不会到达」的分支。

在 C# 中,至少在较旧的稳定版本范围内,无法期待像 F# 的可辨识联合那样严格的穷尽性检查。 因此,在 C# 中使用 record 层次结构时,也建议倾向于下面两种做法之一:

  • 提供 Match 方法,强制调用方处理所有用例
  • switch 局部化,不要分散在各处

例如,也可以给 record 层次结构加上 Match

public abstract record CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed record Created(User User) : CreateUserResult;
    public sealed record DuplicateEmail(string Email) : CreateUserResult;
    public sealed record WeakPassword(string Reason) : CreateUserResult;
    public sealed record SystemFailure(string Message) : CreateUserResult;

    public T Match<T>(
        Func<Created, T> created,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<WeakPassword, T> weakPassword,
        Func<SystemFailure, T> systemFailure)
    {
        return this switch
        {
            Created x => created(x),
            DuplicateEmail x => duplicateEmail(x),
            WeakPassword x => weakPassword(x),
            SystemFailure x => systemFailure(x),
            _ => throw new InvalidOperationException("未知的结果。")
        };
    }
}

这样一来,调用方就可以始终意识到所有用例来进行处理。

var message = result.Match(
    created => $"创建成功: {created.User.Id}",
    duplicate => $"邮箱重复: {duplicate.Email}",
    weak => $"密码强度不足: {weak.Reason}",
    failure => $"处理失败: {failure.Message}");

使用 record 的好处是,可以减少值比较、显示、复制相关的模板代码。 不过,在同时面向 .NET Framework 的共享库中,与勉强使用 record 或 init-only 属性相比,用普通 class 编写有时反而更容易处理。

与「使用新语法」相比,更应该优先考虑「把想要表达的状态封闭在类型中」。

7. 使用 F# 的可辨识联合

在 .NET 中,最能自然地处理代数数据类型的语言是 F#。

F# 把可辨识联合作为一种语言特性提供。

type CreateUserResult =
    | Created of user: User
    | DuplicateEmail of email: string
    | WeakPassword of reason: string
    | SystemFailure of message: string

调用方的写法也很自然。

let toMessage result =
    match result with
    | Created user -> $"用户创建成功: {user.Id}"
    | DuplicateEmail email -> $"该邮箱地址已被使用: {email}"
    | WeakPassword reason -> $"密码强度不足: {reason}"
    | SystemFailure message -> $"用户创建失败: {message}"

F# 的好处是,用例的枚举和模式匹配都集成在语言层面。

新增用例时,更容易发现 match 一侧遗漏的处理。 另外,像 Option<'T> 这样表示「值是否存在」的类型,也可以作为可辨识联合自然地使用。

let tryFindUser id : User option =
    // 找到则返回 Some user,未找到则返回 None
    failwith "sample"

通过返回 option 而不是返回 null,「可能不存在」这一点就会体现在类型上。

由于 F# 的可辨识联合会被编译为 .NET 类型,因此无论是面向 .NET Framework 的 F# 项目,还是面向现行 .NET 的 F# 项目,都可以使用。

不过,从 C# 直接处理 F# 的可辨识联合时,有时不如在 F# 内部使用那样自然。

因此,下面这种分工比较现实:

  • 在 F# 内部的领域逻辑中,积极使用 F# 的可辨识联合
  • 在频繁被 C# 调用的公开 API 中,转换为 C# 也容易处理的 DTO 或 class 层次结构
  • 在边界处,按照 JSON 或数据库的需要映射为另一种表现形式

如果能做到「领域内部使用强类型,外部边界使用容易处理的类型」这样的分离,即便 F# 和 C# 混用,也会更容易使用。

8. 使用类似 OneOf 的库

如果想在 C# 中轻松地表达和类型,OneOf 这类库也是一个选项。

例如,可以把返回值表达成这样:

using OneOf;

public sealed class DuplicateEmail
{
    public DuplicateEmail(string email)
    {
        Email = email;
    }

    public string Email { get; }
}

public sealed class WeakPassword
{
    public WeakPassword(string reason)
    {
        Reason = reason;
    }

    public string Reason { get; }
}

public OneOf<User, DuplicateEmail, WeakPassword> CreateUser(CreateUserCommand command)
{
    if (EmailExists(command.Email))
    {
        return new DuplicateEmail(command.Email);
    }

    if (!IsStrongPassword(command.Password))
    {
        return new WeakPassword("请设置 12 位以上的密码。");
    }

    return CreateUserCore(command);
}

调用方可以用 Match 处理:

var result = service.CreateUser(command);

var message = result.Match(
    user => $"创建成功: {user.Id}",
    duplicate => $"邮箱重复: {duplicate.Email}",
    weak => $"密码强度不足: {weak.Reason}");

OneOf<User, DuplicateEmail, WeakPassword> 表示的含义是「这个值是 UserDuplicateEmailWeakPassword 三者之一」。

这种方式的优点是,不需要专门创建基类,就能作为局部返回值方便地使用。

尤其适合在应用服务层或用例层表达这样的返回值。

用户创建结果 = User 或 DuplicateEmail 或 WeakPassword
商品获取结果 = Product 或 NotFound 或 AccessDenied
支付结果 = Receipt 或 InsufficientFunds 或 PaymentRejected

另一方面,也有需要注意的地方。

如果把 OneOf<A, B, C> 这样的类型原样暴露在公开 API 中,领域上的名称会变得不够明确。

例如,下面这两个方法,仅从类型参数来看是相似的结构。

OneOf<User, NotFound, AccessDenied> GetUser(...)
OneOf<Order, NotFound, AccessDenied> GetOrder(...)

在小范围内很方便,但如果想让领域上的含义更明确,创建专门的类型会更易读。

public abstract class GetUserResult
{
    // Found / NotFound / AccessDenied
}

大致的取舍标准如下:

  • 局部的返回值,用 OneOf 很方便
  • 在领域中反复出现的概念,创建专门的类型
  • 重视公开 API 的稳定性时,使用带有类型名的结果类型

另外,由于 OneOf 支持包括 .NET Framework 和 .NET Standard 的广泛目标框架,因此对于现有的 .NET Framework 资产来说,也是一个容易引入的选项。

9. 使用 Source Generator 类系库

在现行 .NET 中,也有一些库利用 Source Generator 来生成类似可辨识联合的类型。

例如,只需添加一个特性,就可以生成 SwitchMap、校验、序列化联动等代码。

大致形式如下:

[Union]
public partial record Result<T>
{
    public sealed record Success(T Value) : Result<T>;
    public sealed record Failure(string Error) : Result<T>;
}

这类库可以减少手写 MatchSwitch 的模板代码。 其中一些还能与 Analyzer 结合,对遗漏的处理发出警告。

不过,在包含 .NET Framework 的既有系统中使用时,需要确认以下几点:

  • 目标 TFM 是否支持 .NET Framework
  • 是否具备使用 Source Generator 所需的 SDK / Visual Studio / MSBuild 环境
  • 在 CI 环境中能否得到相同的生成结果
  • 生成的代码能否调试
  • 应用程序边界处的 JSON / 数据库 / OpenAPI 联动是否符合预期

尤其是在较旧的 .NET Framework 项目中,以 Source Generator 为前提的包有时无法直接使用。

如果希望更强地支持 .NET Framework,最初从手写的 class 层次结构或 OneOf 开始会更安全。

10. 关于 C# 15 的 union 类型

截至 2026 年 6 月,C# 15 的 union 类型作为预览特性已经出现。

按照预览版的方向,可以声明「这个类型是指定类型中的某一个」。

public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);

public union Pet(Cat, Dog, Bird);

调用方通过模式匹配来处理各个用例。

static string Describe(Pet pet)
{
    return pet switch
    {
        Cat cat => $"Cat: {cat.Name}",
        Dog dog => $"Dog: {dog.Name}",
        Bird bird => $"Bird: {bird.Name}",
        Pet { Value: null } => "Unknown pet"
    };
}

如果这个特性稳定下来,C# 也能更自然地处理「封闭的类型集合」和「穷尽的模式匹配」了。

在预览阶段,如果生成的类型是 struct,像 default(Pet) 这样内部 Valuenull 的值也是可能传入的。 在公开方法中接收 union 值时,需要对这类默认值也做防御性处理。

不过,预览特性在正式投入生产代码之前应该谨慎评估。

语言规范、IDE 支持、运行时侧的辅助类型、Analyzer、序列化器联动等,在正式发布之前都可能发生变化。

因此,目前实务上比较现实的定位是这样的:

  • 在新的验证或技术调研中,尝试 C# union 是有价值的
  • 在需要长期维护的生产代码中,使用 F# DU、class / record 层次结构、OneOf、Source Generator 等稳定的选项
  • 为了将来更容易迁移到 C# union,先把返回值或状态整理成「多者之一」的类型

也就是说,即使不等待 C# union,从今天起也可以进行 ADT 式的设计。

反而,现在就整理好 ResultOption、状态类型、领域事件类型等,将来向语言特性迁移时会更容易。

11. Option 类型:用「不存在」代替 null

代数数据类型的代表例子就是 Option<T>

Option<T> 表示以下两者之一:

Some(value)
None

在 C# 中,经常用 null 表示「不存在」,但 null 存在一个问题:从类型上很难看出来。

User user = repository.FindById(id);

// 调用方必须记住 user 是否可能为 null
Console.WriteLine(user.Name);

如果改用 Option<User>,「可能找不到」这一点就会体现在类型上。

下面展示一个即使在 .NET Framework 中也能使用的简单实现。

public abstract class Option<T>
{
    private Option()
    {
    }

    public sealed class Some : Option<T>
    {
        internal Some(T value)
        {
            Value = value;
        }

        public T Value { get; }
    }

    public sealed class None : Option<T>
    {
        internal None()
        {
        }
    }

    private static readonly None NoneValue = new None();

    public static Option<T> Of(T value)
    {
        if (object.Equals(value, null))
        {
            return NoneValue;
        }

        return new Some(value);
    }

    public static Option<T> Empty()
    {
        return NoneValue;
    }

    public TResult Match<TResult>(Func<T, TResult> some, Func<TResult> none)
    {
        if (some == null) throw new ArgumentNullException(nameof(some));
        if (none == null) throw new ArgumentNullException(nameof(none));

        var s = this as Some;
        if (s != null) return some(s.Value);

        return none();
    }
}

调用方是这样的:

Option<User> user = repository.FindById(id);

string displayName = user.Match(
    some: u => u.Name,
    none: () => "访客");

不需要完全消除 null。 在 .NET 现有的 API、数据库、JSON 中,null 依然会出现。

但在领域逻辑内部,很多场合下 Option<T>null 更能表达清楚意图。

尤其是下面这类方法,很适合用 Option<T>

Option<User> TryFindUser(UserId id);
Option<Customer> FindCustomerByEmail(Email email);
Option<Discount> GetApplicableDiscount(Order order);

不只是在方法名上加 Try,重点在于让返回值的类型本身也表达出「可能不存在」。

12. Result 类型:用类型返回预期内的失败

另一个常用的类型是 Result<TSuccess, TError>

它表示以下两者之一:

Success(value)
Failure(error)

异常适合用于预期外的失败,或者不想纳入常规控制流的失败。 而业务上经常发生的失败,用类型返回有时反而更易读。

例如,在登录处理中,下面这些失败都是预期内的:

  • 用户不存在
  • 密码错误
  • 账户被锁定
  • 需要多因素认证

如果只用异常来表达这些情况,调用方就要在 catch 中编写业务分支。

try
{
    var session = auth.Login(userName, password);
    return Ok(session);
}
catch (InvalidPasswordException)
{
    return Unauthorized();
}
catch (AccountLockedException)
{
    return Forbid();
}

用异常来写也能运行,但业务上的分支容易被淹没在异常处理中。

如果用 ADT 的思路来表达,会是这样:

public abstract class LoginResult
{
    private LoginResult()
    {
    }

    public sealed class Succeeded : LoginResult
    {
        internal Succeeded(Session session)
        {
            Session = session;
        }

        public Session Session { get; }
    }

    public sealed class InvalidPassword : LoginResult
    {
        internal InvalidPassword()
        {
        }
    }

    public sealed class AccountLocked : LoginResult
    {
        internal AccountLocked(DateTimeOffset until)
        {
            Until = until;
        }

        public DateTimeOffset Until { get; }
    }

    public sealed class MfaRequired : LoginResult
    {
        internal MfaRequired(string challengeId)
        {
            ChallengeId = challengeId;
        }

        public string ChallengeId { get; }
    }

    public static LoginResult Success(Session session)
        => new Succeeded(session);

    public static LoginResult WrongPassword()
        => new InvalidPassword();

    public static LoginResult Locked(DateTimeOffset until)
        => new AccountLocked(until);

    public static LoginResult RequireMfa(string challengeId)
        => new MfaRequired(challengeId);

    public T Match<T>(
        Func<Succeeded, T> succeeded,
        Func<InvalidPassword, T> invalidPassword,
        Func<AccountLocked, T> accountLocked,
        Func<MfaRequired, T> mfaRequired)
    {
        if (succeeded == null) throw new ArgumentNullException(nameof(succeeded));
        if (invalidPassword == null) throw new ArgumentNullException(nameof(invalidPassword));
        if (accountLocked == null) throw new ArgumentNullException(nameof(accountLocked));
        if (mfaRequired == null) throw new ArgumentNullException(nameof(mfaRequired));

        var s = this as Succeeded;
        if (s != null) return succeeded(s);

        var i = this as InvalidPassword;
        if (i != null) return invalidPassword(i);

        var l = this as AccountLocked;
        if (l != null) return accountLocked(l);

        var m = this as MfaRequired;
        if (m != null) return mfaRequired(m);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

这样一来,调用方就可以一边看着「登录处理可能产生的结果」一边实现代码。

var result = auth.Login(userName, password);

return result.Match(
    succeeded => Ok(succeeded.Session),
    invalidPassword => Unauthorized(),
    accountLocked => StatusCode(423),
    mfaRequired => Accepted(new { mfaRequired.ChallengeId }));

重点并不是要彻底放弃异常。

预期内的业务分支用 Result,预期外的异常情况用异常 —— 这样分工。

仅凭这一点,应用服务层和 API 层的可读性就能有明显提升。

13. 用类型表达状态迁移

ADT 不仅适合表达返回值,也很适合表达状态。

例如,考虑订单的状态。

public enum OrderStatus
{
    Draft,
    Submitted,
    Paid,
    Shipped,
    Cancelled
}

只用 enum,很难表达每个状态所需的数据。

  • Draft 需要创建者
  • Submitted 需要提交时间
  • Paid 需要支付编号
  • Shipped 需要配送编号
  • Cancelled 需要取消原因

如果想用 OrderStatus 加另外的属性来表达,可为空的属性又会增多。

public sealed class Order
{
    public OrderStatus Status { get; set; }
    public DateTimeOffset? SubmittedAt { get; set; }
    public string PaymentNo { get; set; }
    public string TrackingNo { get; set; }
    public string CancelReason { get; set; }
}

在这种设计下,能构造出「Status == Draft 却设置了 TrackingNo」这样的状态。

如果用 ADT 的思路来表达,就把状态本身做成类型。

public abstract class OrderState
{
    private OrderState()
    {
    }

    public sealed class Draft : OrderState
    {
        internal Draft(UserId createdBy)
        {
            CreatedBy = createdBy;
        }

        public UserId CreatedBy { get; }
    }

    public sealed class Submitted : OrderState
    {
        internal Submitted(DateTimeOffset submittedAt)
        {
            SubmittedAt = submittedAt;
        }

        public DateTimeOffset SubmittedAt { get; }
    }

    public sealed class Paid : OrderState
    {
        internal Paid(string paymentNo)
        {
            PaymentNo = paymentNo;
        }

        public string PaymentNo { get; }
    }

    public sealed class Shipped : OrderState
    {
        internal Shipped(string trackingNo)
        {
            TrackingNo = trackingNo;
        }

        public string TrackingNo { get; }
    }

    public sealed class Cancelled : OrderState
    {
        internal Cancelled(string reason)
        {
            Reason = reason;
        }

        public string Reason { get; }
    }
}

订单持有一个 OrderState

public sealed class Order
{
    public OrderId Id { get; }
    public OrderState State { get; private set; }

    public Order(OrderId id, UserId createdBy)
    {
        Id = id;
        State = new OrderState.Draft(createdBy);
    }
}

进一步地,把状态迁移封装到方法内部。

public void Submit(IClock clock)
{
    if (!(State is OrderState.Draft))
    {
        throw new InvalidOperationException("只有草稿状态的订单才能提交。");
    }

    State = new OrderState.Submitted(clock.Now);
}

public void MarkAsPaid(string paymentNo)
{
    if (!(State is OrderState.Submitted))
    {
        throw new InvalidOperationException("只有已提交的订单才能标记为已支付。");
    }

    State = new OrderState.Paid(paymentNo);
}

这样一来,每个状态各自的数据和状态迁移的规则就都变得清晰易读。

当然,在持久化时,有时也会拆分成 OrderStatus 和辅助列来保存。

即便如此,在领域内部仍可以当作 OrderState 来处理,在与数据库的边界处再进行转换即可。

数据库中的表示
  status = "Paid"
  payment_no = "PAY-001"

领域内部的表示
  OrderState.Paid("PAY-001")

没有必要为了迁就数据库结构而削弱领域模型。

14. 在 API 边界处转换为 DTO

ADT 风格的类型在领域内部非常方便。

但另一方面,在 JSON API、数据库、消息队列、OpenAPI、外部联动中,需要稍加注意。

例如,假设把下面这个 ADT 原样输出为 JSON。

public abstract record PaymentResult
{
    public sealed record Succeeded(string ReceiptNo) : PaymentResult;
    public sealed record Rejected(string Reason) : PaymentResult;
    public sealed record NetworkFailure(string Message) : PaymentResult;
}

作为 JSON,你可能希望是这样的形式:

{
  "type": "succeeded",
  "receiptNo": "R-001"
}

如果是失败,则是这样的形式:

{
  "type": "rejected",
  "reason": "card_expired"
}

这里的 type 是 JSON 一侧的判别标记。

领域中的 ADT 和 JSON 的表示很相似,但并不是同一个东西。

因此,在外部边界处转换为 DTO 的设计更安全。

public sealed class PaymentResultDto
{
    public string Type { get; set; }
    public string ReceiptNo { get; set; }
    public string Reason { get; set; }
    public string Message { get; set; }
}

在转换处理中,针对 ADT 的每个用例生成对应的 DTO。

public static PaymentResultDto ToDto(PaymentResult result)
{
    return result switch
    {
        PaymentResult.Succeeded x => new PaymentResultDto
        {
            Type = "succeeded",
            ReceiptNo = x.ReceiptNo
        },

        PaymentResult.Rejected x => new PaymentResultDto
        {
            Type = "rejected",
            Reason = x.Reason
        },

        PaymentResult.NetworkFailure x => new PaymentResultDto
        {
            Type = "network_failure",
            Message = x.Message
        },

        _ => throw new InvalidOperationException("未知的支付结果。")
    };
}

当然,也可以使用 System.Text.Json 的多态序列化或自定义转换器。

但在需要长期维护的 API 中,不要让 JSON 的形态与领域类型的内部结构紧密耦合,往往更安全。

推荐的做法是这样分离:

领域内部
  PaymentResult.Succeeded
  PaymentResult.Rejected
  PaymentResult.NetworkFailure

API 边界
  PaymentResultDto
  type: "succeeded" | "rejected" | "network_failure"

让领域类型专注于表达业务,让外部表示通过 DTO 保持稳定。

做好这种分离后,即使改进领域内部实现,也更容易保持 API 的兼容性。

15. 好处一:更难构造出不合法的状态

ADT 最大的好处就是更难构造出不合法的状态。

例如,下面这种类型,很容易构造出不合法的组合。

public sealed class Reservation
{
    public bool IsCancelled { get; set; }
    public DateTimeOffset? CancelledAt { get; set; }
    public string CancelReason { get; set; }
    public DateTimeOffset? ConfirmedAt { get; set; }
}

这个类型能构造出下面这些状态:

  • 没有取消,却设置了 CancelledAt
  • 已取消,却没有 CancelReason
  • 已取消,却设置了 ConfirmedAt
  • 未确定,却有确定时间

如果用 ADT 的思路来表达,就可以按状态区分各自需要的数据。

public abstract class ReservationState
{
    private ReservationState()
    {
    }

    public sealed class Requested : ReservationState
    {
        internal Requested(DateTimeOffset requestedAt)
        {
            RequestedAt = requestedAt;
        }

        public DateTimeOffset RequestedAt { get; }
    }

    public sealed class Confirmed : ReservationState
    {
        internal Confirmed(DateTimeOffset confirmedAt)
        {
            ConfirmedAt = confirmedAt;
        }

        public DateTimeOffset ConfirmedAt { get; }
    }

    public sealed class Cancelled : ReservationState
    {
        internal Cancelled(DateTimeOffset cancelledAt, string reason)
        {
            CancelledAt = cancelledAt;
            Reason = reason;
        }

        public DateTimeOffset CancelledAt { get; }
        public string Reason { get; }
    }
}

这样一来,只有已取消状态才会持有取消时间和原因。

不是事后检查不合法的组合,而是在设计阶段就减少了这种可能性。

从测试的角度来看,这一点意义也很大。

bool 与可为空属性一旦增多,组合数量就会爆炸式增长。 一旦改成 ADT,需要测试的用例就会被整理成「已定义的用例」。

16. 好处二:能让调用方意识到遗漏的处理

ADT 会向调用方展示「这个值可能有哪些用例」。

例如,看到下面的返回值,调用方就能明白需要处理 FoundNotFoundForbidden

public abstract class GetDocumentResult
{
    private GetDocumentResult()
    {
    }

    public sealed class Found : GetDocumentResult
    {
        internal Found(Document document)
        {
            Document = document;
        }

        public Document Document { get; }
    }

    public sealed class NotFound : GetDocumentResult
    {
        internal NotFound(DocumentId id)
        {
            Id = id;
        }

        public DocumentId Id { get; }
    }

    public sealed class Forbidden : GetDocumentResult
    {
        internal Forbidden(UserId userId)
        {
            UserId = userId;
        }

        public UserId UserId { get; }
    }

    public static GetDocumentResult DocumentFound(Document document)
        => new Found(document);

    public static GetDocumentResult DocumentNotFound(DocumentId id)
        => new NotFound(id);

    public static GetDocumentResult AccessForbidden(UserId userId)
        => new Forbidden(userId);

    public T Match<T>(
        Func<Found, T> found,
        Func<NotFound, T> notFound,
        Func<Forbidden, T> forbidden)
    {
        if (found == null) throw new ArgumentNullException(nameof(found));
        if (notFound == null) throw new ArgumentNullException(nameof(notFound));
        if (forbidden == null) throw new ArgumentNullException(nameof(forbidden));

        var f = this as Found;
        if (f != null) return found(f);

        var n = this as NotFound;
        if (n != null) return notFound(n);

        var d = this as Forbidden;
        if (d != null) return forbidden(d);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

如果只是返回 null,就无法分辨「不存在」「没有权限」「获取处理失败」这三者中到底是哪一种。

如果只用异常,也很难分清哪些异常是业务上预期会出现的。

如果表示为 GetDocumentResult,方法签名本身就成为了一份规格说明。

GetDocumentResult GetDocument(UserId userId, DocumentId documentId);

这个方法不只是返回文档。

它拥有一份 API 契约:返回「找到了」「未找到」「没有权限」三者之一。

再加上使用 Match,就更容易发现遗漏的处理。

return result.Match(
    found => Ok(found.Document),
    notFound => NotFound(),
    forbidden => Forbid());

新增用例时,只要 Match 的参数增多,就能在编译期更容易地发现调用方遗漏的更新。

在长期维护中,这一点的效果非常显著。

17. 好处三:领域术语会保留在代码中

如果只用 boolintstringnull 来表达状态,业务上的含义就会从代码中消失。

return false;

这个 false 意味着什么呢?

  • 没找到
  • 输入不合法
  • 没有权限
  • 外部服务宕机了
  • 已经处理过了

如果调用方不了解上下文,就无法知道答案。

使用 ADT,业务上的用语就会作为类型保留下来。

return GetDocumentResult.DocumentNotFound(documentId);
return GetDocumentResult.AccessForbidden(userId);
return SubmitOrderResult.AlreadySubmitted(orderId);
return SubmitOrderResult.CreditLimitExceeded(limit);

这个差异很大。

无论是在代码评审、日志还是测试中,都能看到领域术语。

例如,测试的命名也会变得自然。

[Fact]
public void 对已提交的订单再次提交时应返回AlreadySubmitted()
{
    var result = service.Submit(orderId);

    Assert.IsType<SubmitOrderResult.AlreadySubmitted>(result);
}

这不仅仅是一种实现技巧,更是一种把业务规格保留在代码中的方法。

18. 好处四:可以减少过度使用异常

.NET 的异常机制很强大。

但如果把业务上经常发生的分支也做成异常,处理逻辑的可读性有时会变差。

例如,考虑库存分配。

库存不足,从系统层面来说并不是异常情况。 这是业务上正常会发生的结果。

public abstract class ReserveStockResult
{
    private ReserveStockResult()
    {
    }

    public sealed class Reserved : ReserveStockResult
    {
        internal Reserved(ReservationId reservationId)
        {
            ReservationId = reservationId;
        }

        public ReservationId ReservationId { get; }
    }

    public sealed class OutOfStock : ReserveStockResult
    {
        internal OutOfStock(Sku sku, int requested, int available)
        {
            Sku = sku;
            Requested = requested;
            Available = available;
        }

        public Sku Sku { get; }
        public int Requested { get; }
        public int Available { get; }
    }

    public T Match<T>(
        Func<Reserved, T> reserved,
        Func<OutOfStock, T> outOfStock)
    {
        if (reserved == null) throw new ArgumentNullException(nameof(reserved));
        if (outOfStock == null) throw new ArgumentNullException(nameof(outOfStock));

        var r = this as Reserved;
        if (r != null) return reserved(r);

        var o = this as OutOfStock;
        if (o != null) return outOfStock(o);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

用这种方式表达,库存不足就成为了一个叫 OutOfStock 的普通结果。

var result = stock.Reserve(sku, quantity);

return result.Match(
    reserved => Ok(reserved.ReservationId),
    outOfStock => Conflict(new
    {
        sku = outOfStock.Sku.Value,
        requested = outOfStock.Requested,
        available = outOfStock.Available
    }));

而对于数据库连接中断、配置文件损坏、出现意料之外的不一致等情况,用异常表示即可。

作为判断标准,下面这种划分方式在实务中比较可行:

调用方应该作为正常分支来处理的情况
  => 用 Result / ADT 返回

通常处理无法恢复的情况
  => 做成异常

采用这种分工,可以避免 try-catch 变成业务分支的替代品。

19. 好处五:更容易编写测试

使用 ADT,需要测试的用例会变得明确。

例如,假设有下面这个结果类型。

SubmitOrderResult =
  Submitted(orderId)
  或 AlreadySubmitted(orderId)
  或 InvalidOrder(reason)
  或 CreditLimitExceeded(limit)

这种情况下,测试会自然地按用例划分。

订单正常时返回 Submitted
已经提交过时返回 AlreadySubmitted
订单不合法时返回 InvalidOrder
超出授信额度时返回 CreditLimitExceeded

如果用可为空属性的组合来表示状态,测试一侧也必须理解「哪种组合才是有效的」。

而如果是 ADT,用例本身就成为了测试的关注点。

而且,测试数据也更容易构造。

var result = SubmitOrderResult.CreditLimitExceeded(limit);

这一行代码就能构造出一个具有「超出授信额度」含义的数据。

比起把 StatusErrorCodeMessageLimit 组合出一个看起来像那么回事的对象,这样做的意图要明确得多。

20. 面向 .NET Framework 的引入方针

要在 .NET Framework 的既有系统中引入 ADT 风格的设计时,不宜一下子做出大幅改动。

建议先从返回值开始。 在既有代码中寻找类似下面这些情况:

  • bool TryXxx(...),但也需要失败原因
  • 返回 null,但找不到的原因有多种
  • enum Status 与一组可为空的辅助属性在不断增加
  • 用异常表达业务上的分支
  • ErrorCode 的字符串比较正在扩散

这类地方,ADT 化的效果比较明显。

接下来,创建专门的结果类型。

public abstract class RegisterMemberResult
{
    private RegisterMemberResult()
    {
    }

    public sealed class Registered : RegisterMemberResult
    {
        internal Registered(MemberId memberId)
        {
            MemberId = memberId;
        }

        public MemberId MemberId { get; }
    }

    public sealed class DuplicateEmail : RegisterMemberResult
    {
        internal DuplicateEmail(string email)
        {
            Email = email;
        }

        public string Email { get; }
    }

    public sealed class InvalidInvitationCode : RegisterMemberResult
    {
        internal InvalidInvitationCode(string code)
        {
            Code = code;
        }

        public string Code { get; }
    }

    public T Match<T>(
        Func<Registered, T> registered,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<InvalidInvitationCode, T> invalidInvitationCode)
    {
        if (registered == null) throw new ArgumentNullException(nameof(registered));
        if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
        if (invalidInvitationCode == null) throw new ArgumentNullException(nameof(invalidInvitationCode));

        var r = this as Registered;
        if (r != null) return registered(r);

        var d = this as DuplicateEmail;
        if (d != null) return duplicateEmail(d);

        var i = this as InvalidInvitationCode;
        if (i != null) return invalidInvitationCode(i);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

然后,在现有的 API 边界处,立刻转换为 DTO 或旧格式。

var result = service.Register(command);

return result.Match(
    registered => new RegisterMemberResponse
    {
        Success = true,
        MemberId = registered.MemberId.Value
    },
    duplicate => new RegisterMemberResponse
    {
        Success = false,
        ErrorCode = "DuplicateEmail",
        ErrorMessage = duplicate.Email + " 已被使用。"
    },
    invalidCode => new RegisterMemberResponse
    {
        Success = false,
        ErrorCode = "InvalidInvitationCode",
        ErrorMessage = "邀请码无效。"
    });

即使暂时不改变外部接口,也可以先让内部逻辑变得更健壮。

这在既有系统中非常重要。

外部 API 或界面的需要
  维持现有的响应格式

内部领域逻辑
  用 ADT 风格的类型来安全处理

哪怕只在边界处做转换,内部的分支逻辑也能得到相当程度的整理。

21. 用 .NET Standard 构建共享库

对于需要同时被 .NET Framework 和现行 .NET 使用的库,可以选择使用 .NET Standard。

尤其是如果重视广泛的兼容性,.NET Standard 2.0 就是一个现实的候选方案。

例如,可以把领域模型或结果类型放在这样的库结构中。

MyApp.Domain
  TargetFramework: netstandard2.0

MyApp.LegacyWeb
  TargetFramework: net472
  引用 MyApp.Domain

MyApp.Api
  TargetFramework: net8.0
  引用 MyApp.Domain

采用这种结构,旧的 .NET Framework 应用程序和新的 .NET 应用程序之间就更容易共享相同的领域类型。

不过,以 .NET Standard 2.0 为目标时,要避免过度依赖较新的 C# / .NET API。

例如,下面这些设计在共享库中最好避免:

  • 强烈依赖 recordinit
  • 直接使用 .NET 6 及以后的 API
  • 大范围公开以 Source Generator 为前提的代码
  • 在领域层引入 ASP.NET Core 特有的类型

在共享库中,以简单的 class、值对象、ADT 风格的结果类型为核心,会更容易长期使用。

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    // 用普通的 class 表示,无论 .NET Framework 还是 .NET 都容易使用
}

在专门面向新版 .NET 的应用层,则可以使用 record 或 switch 表达式。

共享领域层
  即使在旧环境中也能读懂的普通类型

新的应用层
  充分利用 record / 模式匹配 / minimal API 等

做好这种分离,就能更容易兼顾既有资产和新的开发工作。

22. 应该把多少东西做成 ADT

ADT 很方便,但不是什么都适合做成 ADT。

适合的是用例集合在业务上基本封闭的东西,例如下面这些:

  • 处理结果
  • 输入校验结果
  • 订单状态
  • 支付结果
  • 认证结果
  • 外部服务调用结果
  • 领域事件
  • 命令的种类
  • 界面状态

相反,也有需要注意的情况。

  • 用例会因插件而从外部增加的东西
  • 用例会因用户自定义而增加的东西
  • 作为数据库主数据、在运行中会增加的东西
  • 以继承扩展为前提的框架联动类型
  • 单纯的 CRUD DTO

如果用例会从外部不断增加,接口或普通的继承层次结构会比封闭的 ADT 更合适。

例如,如果报表输出的格式会通过插件不断增加,下面这种设计更自然。

public interface IReportExporter
{
    string FormatName { get; }
    void Export(Report report, Stream output);
}

在这种情况下,如果做成 PdfExporter | ExcelExporter | CsvExporter 这样封闭的和类型,外部扩展就会变得困难。

ADT 是一种在「封闭的世界」中很强大的设计。

业务上是否真的是封闭的? 将来是否有可能从外部增加?

判断清楚这一点很重要。

23. 与 enum 的取舍

enum 本身并不是坏东西。

当每个用例都不需要携带额外数据、只需要作为简单标签就足够时,enum 很合适。例如下面这些:

public enum Gender
{
    Unknown,
    Male,
    Female,
    Other
}

或者像日志级别这样的东西。

public enum LogLevel
{
    Trace,
    Debug,
    Information,
    Warning,
    Error,
    Critical
}

而如果每个用例需要携带的数据不同,就可以考虑 ADT 风格的类型。

PaymentStatus enum
  Succeeded
  Rejected
  Failed

PaymentResult ADT
  Succeeded(receiptNo)
  Rejected(reason)
  Failed(message)

判断标准很简单:

只需要区分用例本身
  => enum

每个用例携带的数据不同
  => ADT

每个用例的行为或约束不同
  => ADT 或 class 层次结构

当「enum + 一组可为空属性」开始增多时,就是该转向 ADT 的信号。

24. 与 bool 的取舍

bool 本身也不是坏东西。

如果真的只用 yes / no 就能表达完整的含义,bool 就足够了。

bool IsEnabled { get; }
bool IsDeleted { get; }

但如果失败原因有多种,bool 就会显得力不从心。

bool TryCreateUser(CreateUserCommand command);

这个方法在失败时无法说明原因。

也可以用 out 参数来补充。

bool TryCreateUser(CreateUserCommand command, out User user, out string errorCode);

但这样会逐渐变得复杂。

这种情况下,改成结果类型会更易读。

CreateUserResult CreateUser(CreateUserCommand command);

调用方也不只能区分成功或失败,还能把失败的种类作为类型来处理。

return result.Match(
    created => Ok(created.User),
    duplicate => Conflict(),
    weak => BadRequest(),
    failure => StatusCode(500));

判断标准如下:

真正是两种选择,且不需要附加信息
  => bool

两种选择,但需要成功值或失败原因
  => Result

三种以上选择,或每个用例携带的数据不同
  => ADT

25. 继承与 ADT 的区别

在 C# 中创建 ADT 风格的类型时,从外观上看会很接近普通的继承。

public abstract class PaymentResult
{
}

public sealed class Succeeded : PaymentResult
{
}

public sealed class Rejected : PaymentResult
{
}

不过,二者的目的略有不同。

普通的面向对象继承,常用于替换行为。

public abstract class Shape
{
    public abstract double Area();
}

public sealed class Circle : Shape
{
    public override double Area() => ...;
}

而 ADT 风格的继承,是用来表达「可能出现的数据形态」。

public abstract class PaymentResult
{
    public sealed class Succeeded : PaymentResult
    {
        public string ReceiptNo { get; }
    }

    public sealed class Rejected : PaymentResult
    {
        public string Reason { get; }
    }
}

这不是哪种做法绝对正确的问题。

如果想把处理逻辑放在各个用例一侧,普通的多态更合适。

public abstract class Notification
{
    public abstract void Send();
}

如果想让调用方在看到所有用例的基础上进行分支处理,ADT + 模式匹配 / Match 更合适。

return notification.Match(
    email => SendEmail(email),
    sms => SendSms(sms),
    push => SendPush(push));

在业务应用程序中,「返回值或状态用 ADT,行为替换用接口」这样的分工方式比较清晰易懂。

26. 不要让模式匹配过度分散

一旦开始使用 ADT,就会有到处编写 switchMatch 的冲动。

但是,如果同一种分支散布在多个地方,新增用例时需要修改的地方就会增多。

例如,假设在很多地方对 PaymentResult 做了 switch

API 响应转换
日志输出
界面消息生成
指标记录
审计日志生成

新增用例时,就需要修改所有这些 switch

这有时是难以避免的,但尽可能把分支的职责集中起来,可以让维护更轻松。

public static class PaymentResultMapper
{
    public static PaymentResultDto ToDto(PaymentResult result)
    {
        return result.Match(
            succeeded => ...,
            rejected => ...,
            failure => ...);
    }

    public static string ToLogMessage(PaymentResult result)
    {
        return result.Match(
            succeeded => ...,
            rejected => ...,
            failure => ...);
    }
}

另外,有时不做分支、而是让用例自身携带处理逻辑,反而更好。

public abstract class PaymentResult
{
    public abstract bool IsSuccess { get; }
}

不过,如果让用例一侧携带过多处理逻辑,领域类型就会开始了解 API 或 UI 方面的需要。

下面这类处理,很多情况下最好不要直接放进领域类型中。

  • 转换为 HTTP 状态码
  • 转换为 JSON DTO
  • 界面显示用的消息
  • 日志的格式
  • OpenAPI 用的表示

让领域类型表达业务含义。 让边界转换放在 Mapper 中。

意识到这种分离,能让 ADT 在长期维护中更容易使用。

27. 命名方式

对于 ADT 风格的类型,命名很重要。

如果只用 ResultErrorResponse 这类通用名称,含义就会变得单薄。

常用的命名方式有下面这些:

CreateUserResult
RegisterMemberResult
SubmitOrderResult
ReserveStockResult
PaymentResult
LoginResult
GetDocumentResult
OrderState
ReservationState

用例的名称,应该贴近业务用语。

Created
DuplicateEmail
WeakPassword
SystemFailure
AlreadySubmitted
CreditLimitExceeded
OutOfStock
MfaRequired
AccountLocked

只用 Error1Error2Failed 这样的名称,调用方很难理解其含义。

另外,用例所携带的数据也应尽量使用业务上的类型。

public sealed class CreditLimitExceeded : SubmitOrderResult
{
    public Money Limit { get; }
    public Money RequestedAmount { get; }
}

即使继续使用 decimalstring 也能运行,但如果和 MoneyEmailUserIdOrderId 这类值对象组合,意图会更加明确。

ADT 与值对象的搭配非常契合。

值对象
  表达单个值的含义与约束

ADT
  表达多种可能出现的形态

把这两者组合起来,就更容易把业务规则封闭在类型之中。

28. 注意版本管理

由于 ADT 会明确展示用例集合,新增用例会对调用方产生影响。

这既是优点,也是需要注意的地方。

对于内部代码,新增用例时出现编译错误是应该欢迎的事,因为它能帮你发现遗漏的处理。

但另一方面,对于作为 NuGet 包或公开 API 提供给外部的类型,新增用例有时几乎等同于破坏性变更。

例如,假设库的使用者已经这样写好了全部用例的处理:

var text = result.Match(
    success => ...,
    validationError => ...,
    permissionDenied => ...);

如果库一侧新增了 RateLimited 用例,并相应修改了 Match 的签名,使用者的代码就会产生编译错误。

这在安全性上是好事,但在公开 API 的兼容性上会产生影响。

因此,对于公开库,需要这样考虑:

  • 如果允许新增用例,就把它当作破坏性变更来提升版本号
  • 如果想允许外部使用者做类似 default 的处理,就不要用封闭的 ADT,改用别的设计
  • 在内部领域中保持严格,在外部 API 中用 DTO 与带版本号的契约

在业务应用程序内部,新增用例时出现编译错误反而是值得欢迎的。

而在公开 API 中,需要把兼容性设计也一并考虑进去。

29. 关于性能

ADT 风格的设计,为了表达能力,有时会增加对象的数量。

在 .NET Framework 中使用 class 层次结构时,每个用例都会生成一个对象。

return PaymentResult.Success(receiptNo);

在普通的业务应用程序中,这大多不会成为大问题。

但在下面这些场景中需要留意:

  • 高频调用的底层处理
  • 处理大量事件的流式处理
  • 游戏或实时处理
  • 希望极度减少内存分配的处理
  • 需要在巨大集合中大量存放 ADT 的处理

在性能重要的场合,有几种可选方案:

  • 使用基于 struct 的 Result 类型
  • 考虑 F# 的 struct discriminated union
  • 用 Source Generator 抑制内存分配
  • 在热路径中使用 enum + 专用字段,在边界处再转换为 ADT
  • 先测量,再优化

不需要从一开始就过度优化。

在很多业务系统中,ADT 带来的设计清晰度,比起些许的对象创建成本,往往更有价值。

不过,在性能要求严格的地方,应该把设计与测量结合起来考虑。

30. 既有代码的重构示例

最后,来看一个把常见既有代码改造成 ADT 风格的流程。

原始代码是这样的:

public bool TryReserveStock(string sku, int quantity, out string errorCode)
{
    errorCode = null;

    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        errorCode = "SKU_NOT_FOUND";
        return false;
    }

    if (stock.Available < quantity)
    {
        errorCode = "OUT_OF_STOCK";
        return false;
    }

    stock.Reserve(quantity);
    return true;
}

在这段代码中,失败原因用 string 表示。 调用方需要对字符串做比较。

string errorCode;
if (!service.TryReserveStock(sku, quantity, out errorCode))
{
    if (errorCode == "SKU_NOT_FOUND")
    {
        ...
    }
    else if (errorCode == "OUT_OF_STOCK")
    {
        ...
    }
}

把它改成结果类型:

public abstract class ReserveStockResult
{
    private ReserveStockResult()
    {
    }

    public sealed class Reserved : ReserveStockResult
    {
        internal Reserved(ReservationId reservationId)
        {
            ReservationId = reservationId;
        }

        public ReservationId ReservationId { get; }
    }

    public sealed class SkuNotFound : ReserveStockResult
    {
        internal SkuNotFound(Sku sku)
        {
            Sku = sku;
        }

        public Sku Sku { get; }
    }

    public sealed class OutOfStock : ReserveStockResult
    {
        internal OutOfStock(Sku sku, int requested, int available)
        {
            Sku = sku;
            Requested = requested;
            Available = available;
        }

        public Sku Sku { get; }
        public int Requested { get; }
        public int Available { get; }
    }

    public static ReserveStockResult Success(ReservationId reservationId)
        => new Reserved(reservationId);

    public static ReserveStockResult NotFound(Sku sku)
        => new SkuNotFound(sku);

    public static ReserveStockResult NotEnough(Sku sku, int requested, int available)
        => new OutOfStock(sku, requested, available);

    public T Match<T>(
        Func<Reserved, T> reserved,
        Func<SkuNotFound, T> skuNotFound,
        Func<OutOfStock, T> outOfStock)
    {
        var r = this as Reserved;
        if (r != null) return reserved(r);

        var n = this as SkuNotFound;
        if (n != null) return skuNotFound(n);

        var o = this as OutOfStock;
        if (o != null) return outOfStock(o);

        throw new InvalidOperationException("未知的库存分配结果。") ;
    }
}

服务方法会变成这样:

public ReserveStockResult ReserveStock(Sku sku, int quantity)
{
    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        return ReserveStockResult.NotFound(sku);
    }

    if (stock.Available < quantity)
    {
        return ReserveStockResult.NotEnough(sku, quantity, stock.Available);
    }

    var reservationId = stock.Reserve(quantity);
    return ReserveStockResult.Success(reservationId);
}

调用方就可以不再依赖字符串比较。

var result = service.ReserveStock(sku, quantity);

return result.Match(
    reserved => Ok(new { reserved.ReservationId }),
    notFound => NotFound(new { sku = notFound.Sku.Value }),
    outOfStock => Conflict(new
    {
        sku = outOfStock.Sku.Value,
        requested = outOfStock.Requested,
        available = outOfStock.Available
    }));

这次重构的关键在于,即使不改变外部行为,也能把内部的含义转移到类型中。

先强化返回值。 再让调用方转向使用 Match。 最后逐步减少字符串错误码和可为空的辅助属性。

按照这个顺序,即使在既有系统中也可以分阶段引入。

31. 引入时的检查清单

创建 ADT 风格的类型时,可以确认以下几点:

这个类型是否表达「多者之一」
用例的集合在业务上是否封闭
每个用例需要的数据是否不同
bool / enum / null / string error code 是否已经让含义变得混乱
是否希望让调用方意识到所有用例的处理
是否会影响公开 API 的兼容性
是否有与 JSON / 数据库 / 界面 DTO 之间的转换方针
如果也要面向 .NET Framework,普通的 class 是否足够
如果只面向现行 .NET,是否值得使用 record 或 Source Generator

实现方针可以这样选择:

F# 项目
  使用 F# 的可辨识联合

.NET Framework 的 C#
  abstract class + private constructor + nested sealed classes + Match

.NET 5 及以后的 C#
  abstract record + sealed record cases + pattern matching

局部的返回值
  类似 OneOf 的库

在现行 .NET 中想减少模板代码
  Source Generator 类系库

面向未来的验证
  C# 15 union preview

无论选择哪种方式,目标都是相同的。

把原本靠注释来维护的规则,改成靠类型来维护。

这就是使用 ADT 最大的意义。

32. 总结

代数数据类型并不是函数式语言的专属工具。

即使在 .NET Framework 的 C# 中,只用抽象类和 sealed 类,也能充分实用地使用它。 在现行 .NET 的 C# 中,用 record 和模式匹配可以写得更简洁。 在 F# 中,可以直接把可辨识联合当作语言特性来使用。 借助库,即使在 C# 中也能轻松处理 OneOfResult

重要的不是语法,而是设计思路。

对于原本用 boolnullenum + 可为空属性string ErrorCode 表达的东西,可以重新审视,问自己下面这些问题:

这个值究竟是哪些用例中的哪一个
每个用例分别需要什么数据
在每个用例之外,不应该存在哪些数据
希望调用方必须处理什么

按照这些问题来设计类型,就能减少不合法的状态、让分支逻辑更清晰、让业务用语保留在代码中。

对于既有系统,建议先从返回值开始。

TryXxxnullErrorCode、用异常表达业务分支的地方,逐步替换为专门的结果类型。

仅凭这一点,代码的可读性和安全性就会有很大的改善。

参考

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

什么是代数数据类型(ADT)?
代数数据类型是积类型(同时持有 A 和 B 的类型)与和类型(只能是 A 或 B 之一的类型)的组合。在实务中的感觉是:「这个值可能出现的形态是事先确定好的」,用类型而不是注释或命名约定来表达这一点。最常用的是和类型,例如把支付结果表示为「Succeeded(receiptNo)、Rejected(reason)、NetworkFailure(message) 三者之一」,从根本上避免构造出不合法的状态。
在 C# 中要如何实现可辨识联合(和类型)?
在包含 .NET Framework 的既有系统中,最容易引入的方式是「带 private 构造函数的抽象基类 + 嵌套的 sealed 类 + Match 方法」的模式。由于只有嵌套类型才能继承基类,因此可以构造出一个封闭的用例集合。如果面向 .NET 5 及以后的版本,用 abstract record 与 sealed record 构成的层次结构可以写得更简洁。对于局部的返回值,OneOf 之类的库也是一个选项;在 F# 中则可以把可辨识联合作为语言特性自然地使用。
什么时候应该使用代数数据类型,而不是 enum 或 bool?
如果只需要区分用例本身,enum 就足够;如果真的只有两种选择且不需要附加信息,bool 就足够。但如果每个用例需要携带的数据不同(例如成功时需要 receiptNo,余额不足时需要 shortage),ADT 更合适。当 enum 与一组可为空的属性组合开始增多,或者开始用字符串错误码来比较失败原因时,就是该转向 ADT 的信号。不过,如果用例是由插件等外部机制不断增加的设计,接口会比封闭的 ADT 更合适。
业务上的失败应该用异常还是 Result 类型来表示?
在实务中,比较合理的分工是:调用方应当作为正常分支来处理的、预期内的失败(库存不足、邮箱重复、密码错误等)用 Result / ADT 返回;而通常处理无法恢复的、预期外的异常(数据库连接中断、配置文件损坏等)用异常表示。如果把业务上经常发生的分支也做成异常,try-catch 就会变成业务分支的替代品,导致处理逻辑变得难以理清。仅凭这一分工,应用服务层与 API 层的可读性就能有明显提升。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表