在 .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 重複時會有 email。 密碼太弱時會有 reason。 系統錯誤時會有 message

每個案例只持有各自需要的資料。 成功與失敗不會同時成立。 也無法建立出「成功卻沒有 User」這種狀態。

在 .NET 中,F# 可以用判別共用體(discriminated union),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 是兩種型別的組合。

  • 直積型別(product type):同時擁有 A 與 B 的型別
  • 直和型別(sum type):是 A 或 B 其中之一的型別

.NET 的類別、結構、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# 那樣做完整的窮盡性檢查(exhaustiveness check)。

因此,在 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 或 DB 的需求對應成別的表示方式

如果能做到「領域內部用強型別,外部邊界用容易處理的型別」這種分離,即使 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 / DB / 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 加上其他屬性來表達,又會增加 nullable 屬性。

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 處理,只在與 DB 的邊界上做轉換即可。

DB 上的表示
  status = "Paid"
  payment_no = "PAY-001"

領域內部的表示
  OrderState.Paid("PAY-001")

不需要為了配合 DB schema 而讓領域模型變弱。

14. 在 API 邊界轉換成 DTO

ADT 式的型別,在領域內部使用起來相當方便。

另一方面,在 JSON API、DB、訊息佇列、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. 優點 1:更難建立出不正確的狀態

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 與 nullable 屬性一旦增加,組合數就會爆炸性增長。 改用 ADT 之後,該測試的案例就會整理成「已定義的案例」。

16. 優點 2:讓呼叫端意識到處理遺漏

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. 優點 3:領域用語會留在程式碼中

如果只用 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. 優點 4:減少過度使用例外

.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
    }));

另一方面,像 DB 連線中斷、設定檔損毀、發生非預期的不一致這類情況,用例外表示就好。

判斷基準上,實務上大致可以這樣劃分。

呼叫端應該當作一般分支處理的情況
  => 用 Result / ADT 回傳

一般處理無法復原的情況
  => 做成例外

做好這種分工,就能避免 try-catch 變成業務分支的替代品。

19. 優點 5:測試變得容易撰寫

使用 ADT 之後,測試對象的案例會變得明確。

舉例來說,假設有下面這個結果型別。

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

在這種情況下,測試會自然地依案例分開。

正常的訂單會回傳 Submitted
已提交過的訂單會回傳 AlreadySubmitted
不正確的訂單會回傳 InvalidOrder
超過信用額度會回傳 CreditLimitExceeded

如果用 nullable 屬性的組合來表示狀態,測試端也必須理解「哪種組合才是有效的」。

改用 ADT 之後,案例本身就會成為測試觀點。

此外,測試資料也變得容易建立。

var result = SubmitOrderResult.CreditLimitExceeded(limit);

只用這一行,就能建立出具有「超過信用額度」意義的資料。

比起組合 StatusErrorCodeMessageLimit 來做出一個看起來像的物件,意圖明確得多。

20. 針對 .NET Framework 的導入方針

在既有的 .NET Framework 系統中導入 ADT 式的設計時,不要一次做太大的改動比較好。

建議先從回傳值開始。 在既有程式碼中,尋找下面這些情況。

  • bool TryXxx(...),但也需要失敗原因
  • 回傳 null,但找不到的原因有多種
  • enum Status 與 nullable 輔助屬性不斷增加
  • 用例外表示業務上的分支
  • 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 / pattern matching / minimal API 等

做好這種分離,就能在既有資產與新開發之間取得平衡。

22. 該把哪些東西做成 ADT

ADT 很方便,但不是什麼都適合做成 ADT。

適合的是案例集合在業務上幾乎是封閉的東西。舉例來說,以下這些。

  • 處理結果
  • 輸入驗證結果
  • 訂單狀態
  • 付款結果
  • 認證結果
  • 外部服務呼叫結果
  • 領域事件
  • 命令的種類
  • 畫面狀態

反過來,也有需要注意的東西。

  • 透過外掛程式從外部增加種類的東西
  • 由使用者自訂而增加種類的東西
  • 作為 DB 主資料,運行中會增加的東西
  • 以繼承擴充為前提的框架整合型別
  • 單純 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 + 一群 nullable 屬性 開始增加時,就是該轉成 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

不過,如果相同的分支散落在多個地方,新增案例時要修改的地方就會增加。

舉例來說,假設在很多地方對 PaymentResultswitch

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);

對一般的業務應用程式來說,這通常不會造成太大問題。

不過,以下這些地方需要注意。

  • 高頻率呼叫的底層處理
  • 處理大量事件的串流處理
  • 遊戲或即時處理
  • 想極度減少配置(allocation)的處理
  • 在巨大集合中大量儲存 ADT 的處理

在效能很重要的情況下,有幾個選項可以考慮。

  • 使用 struct 基礎的 Result 型別
  • 考慮 F# 的 struct discriminated union
  • 用 Source Generator 抑制配置
  • 在熱路徑(hot path)用 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。 最後再逐步減少字串錯誤碼與 nullable 輔助屬性。

按照這個順序,即使是既有系統也能逐步導入。

31. 導入時的檢查清單

建立 ADT 式型別時,可以確認以下這些項目。

這個型別是否表達「其中之一」
案例的集合在業務上是否封閉
每個案例需要的資料是否不同
bool / enum / null / string 錯誤碼是否讓意義變得模糊
是否想讓呼叫端意識到所有案例的處理
是否會影響公開 API 的相容性
是否有 JSON / DB / 畫面 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 + nullable 屬性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 與一群 nullable 屬性的組合開始增加,或開始用字串錯誤碼比較失敗原因時,就是該轉成 ADT 的訊號。不過,如果是像外掛程式那種案例會從外部增加的設計,比起封閉的 ADT,介面(interface)會更合適。
業務上的失敗該用例外還是 Result 型別來表示?
實務上的做法是分工:呼叫端應該當作一般分支處理的預期內失敗(例如庫存不足、電子郵件重複、密碼錯誤等)用 Result / ADT 回傳;一般處理無法復原的非預期異常(例如資料庫連線中斷、設定檔損毀等)則用例外。如果把業務上常見的分支也都做成例外,try-catch 就會變成業務分支的替代品,讓處理流程變得難以理解。光是做好這個分工,就能讓應用服務層或 API 層的可讀性大幅提升。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽