代数的データ型を.NET Framework / .NETで使う ── 状態と結果を型で表す設計
· 小村 豪 · .NET, .NETFramework, CSharp, FSharp, AlgebraicDataTypes, DiscriminatedUnion, DomainModeling, 既存資産活用
代数的データ型を.NET Framework / .NETで使う ── 状態と結果を型で表す設計
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 == nullIsSuccess == trueなのにErrorCodeが入っているIsSuccess == falseなのにUserが入っているErrorCode == "DuplicateEmail"なのにErrorMessage == null- 新しいエラーコードを追加したのに、呼び出し側の処理が更新されていない
このような型は、最初は便利でも、規模が大きくなるほど読み手と保守者に負担をかけます。
そこで使いたい考え方が、代数的データ型です。
代数的データ型という名前は少し堅いですが、実務での感覚としては、次のように考えると分かりやすいです。
「この値は、取り得る形があらかじめ決まっている」ことを、コメントや命名規則ではなく、型で表す。
たとえば、ユーザー作成の結果は、次のどれか 1 つだけである、と表せます。
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 の両方で使う方法と、実務上のメリット・注意点を整理します。
2. 代数的データ型とは何か
代数的データ型、英語では Algebraic Data Type、略して ADT と呼ばれます。
ADT は、ざっくり言うと次の 2 種類の型の組み合わせです。
- 直積型: A と B を両方持つ型
- 直和型: 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
一方で、直和型は「どれか 1 つ」です。
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 だけでは足りないのか
小さな処理では、bool や enum でも十分に見えることがあります。
たとえば、次のような戻り値です。
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 でも使えることです。
Created、DuplicateEmail、WeakPassword、SystemFailure はすべて 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 property を無理に使うより、普通の 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"
null を返す代わりに option を返すことで、「存在しない可能性がある」ことが型に現れます。
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> は、「この値は User、DuplicateEmail、WeakPassword のどれか 1 つ」という意味になります。
この方法のメリットは、専用の基底クラスを作らなくても、局所的な戻り値として使いやすいことです。
特に、アプリケーションサービスやユースケース層で、次のような戻り値を表すのに向いています。
ユーザー作成結果 = User または DuplicateEmail または WeakPassword
商品取得結果 = Product または NotFound または AccessDenied
決済結果 = Receipt または InsufficientFunds または PaymentRejected
一方で、注意点もあります。
OneOf<A, B, C> のような型を公開 API にそのまま出すと、ドメイン上の名前が薄くなることがあります。
たとえば、次の 2 つは、型引数だけ見ると同じような構造です。
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 を使って判別共用体風の型を生成するライブラリもあります。
たとえば、属性を付けるだけで、Switch、Map、検証、シリアライズ連携などのコードを生成するものがあります。
イメージとしては次のような形です。
[Union]
public partial record Result<T>
{
public sealed record Success(T Value) : Result<T>;
public sealed record Failure(string Error) : Result<T>;
}
このようなライブラリは、手書きの Match や Switch の定型コードを減らせます。
また、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 型がプレビュー機能として登場しています。
プレビューの方向性では、次のように「この型は、指定した型のどれか 1 つである」と宣言できます。
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# でも「閉じた型の集合」と「網羅的な pattern matching」を、より自然に扱えるようになります。
プレビュー時点の生成型が struct である場合、default(Pet) のように内部の Value が null の値も渡り得ます。
公開メソッドで union 値を受け取る場合は、このような既定値も防御的に扱う必要があります。
ただし、プレビュー機能は実務の本番コードに入れる前に慎重に評価すべきです。
言語仕様、IDE サポート、ランタイム側の補助型、Analyzer、シリアライザー連携などが、正式リリースまでに変わる可能性があります。
そのため、現時点の実務では次の位置づけが現実的です。
- 新規検証や技術調査では C# union を試す価値がある
- 本番で長期保守するコードでは、F# DU、class / record 階層、OneOf、Source Generator などの安定した選択肢を使う
- 今後 C# union に移行しやすいように、戻り値や状態を「どれか 1 つ」という型として整理しておく
つまり、C# union を待たなくても、今日から ADT 的な設計はできます。
むしろ、今のうちに Result、Option、状態型、ドメインイベント型などを整理しておくと、将来的に言語機能へ移行しやすくなります。
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 が出てきます。
しかし、ドメインロジックの内部では、null よりも Option<T> の方が意図が明確になる場面が多いです。
特に次のようなメソッドでは、Option<T> が向いています。
Option<User> TryFindUser(UserId id);
Option<Customer> FindCustomerByEmail(Email email);
Option<Discount> GetApplicableDiscount(Order order);
メソッド名に Try と付けるだけでなく、戻り値の型にも「ない可能性」を表すのがポイントです。
12. Result型: 想定内の失敗を型で返す
もう 1 つよく使うのが 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 スキーマに合わせてドメインモデルを弱くする必要はありません。
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 は、「この値にはどんなケースがあるのか」を呼び出し側に見せます。
たとえば、次の戻り値を見れば、呼び出し側は Found、NotFound、Forbidden を処理する必要があると分かります。
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: ドメイン用語がコードに残る
bool、int、string、null だけで状態を表すと、業務上の意味がコードから消えます。
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);
この 1 行で、「与信超過」という意味を持つデータが作れます。
Status と ErrorCode と Message と Limit を組み合わせて、それっぽいオブジェクトを作るよりも、意図が明確です。
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 に依存しすぎないようにします。
たとえば、次のような設計は共有ライブラリでは避けた方が無難なことがあります。
recordやinitに強く依存する- .NET 6 以降の API を直接使う
- Source Generator 前提のコードを広く公開する
- ASP.NET Core 固有の型をドメイン層に入れる
共有ライブラリでは、単純な class、値オブジェクト、ADT 的な結果型を中心にすると、長く使いやすくなります。
public abstract class PaymentResult
{
private PaymentResult()
{
}
// .NET Framework でも .NET でも使いやすい、普通の class で表す
}
新しい .NET 専用のアプリケーション層では、record や switch expression を使えばよいです。
共有ドメイン層
古い環境でも読める普通の型
新しいアプリケーション層
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));
判断基準は次の通りです。
本当に2択で、追加情報も不要
=> bool
2択だが成功値や失敗理由が必要
=> Result
3択以上、またはケースごとにデータが違う
=> 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 + pattern matching / Match が向いています。
return notification.Match(
email => SendEmail(email),
sms => SendSms(sms),
push => SendPush(push));
業務アプリケーションでは、戻り値や状態は ADT、振る舞いの差し替えはインターフェース、という使い分けが分かりやすいです。
26. パターンマッチングを散らしすぎない
ADT を使い始めると、あちこちで switch や Match を書きたくなります。
しかし、同じ分岐が複数箇所に散らばると、ケース追加時の修正箇所が増えます。
たとえば、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 的な型は、名前が重要です。
Result、Error、Response のような一般名だけだと、意味が薄くなります。
よく使う命名は次のようなものです。
CreateUserResult
RegisterMemberResult
SubmitOrderResult
ReserveStockResult
PaymentResult
LoginResult
GetDocumentResult
OrderState
ReservationState
ケース名は、業務用語に寄せます。
Created
DuplicateEmail
WeakPassword
SystemFailure
AlreadySubmitted
CreditLimitExceeded
OutOfStock
MfaRequired
AccountLocked
Error1、Error2、Failed だけでは、呼び出し側が意味を理解しにくくなります。
また、ケースに持たせるデータも、できるだけ業務上の型にします。
public sealed class CreditLimitExceeded : SubmitOrderResult
{
public Money Limit { get; }
public Money RequestedAmount { get; }
}
decimal や string のままでも動きますが、Money、Email、UserId、OrderId のような値オブジェクトと組み合わせると、さらに意図が明確になります。
ADT と値オブジェクトは相性が良いです。
値オブジェクト
1つの値の意味と制約を表す
ADT
複数の取り得る形を表す
この 2 つを組み合わせると、業務ルールを型に閉じ込めやすくなります。
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 に寄せる。
最後に文字列エラーコードや nullable な補助プロパティを減らしていく。
この順番なら、既存システムでも段階的に導入できます。
31. 導入時のチェックリスト
ADT 的な型を作るときは、次の点を確認するとよいです。
その型は「どれか1つ」を表しているか
ケースの集合は業務上閉じているか
ケースごとに必要なデータが違うか
bool / enum / null / string error code では意味が崩れていないか
呼び出し側に全ケース処理を意識させたいか
公開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 と pattern matching でより簡潔に書けます。
F# なら、判別共用体として言語機能そのものを使えます。
ライブラリを使えば、C# でも手軽に OneOf や Result を扱えます。
大事なのは、構文ではなく設計の考え方です。
bool、null、enum + nullable プロパティ、string ErrorCode で表していたものを見直し、次のように考えます。
この値は、どのケースのどれか1つなのか
各ケースに必要なデータは何か
そのケース以外では存在してはいけないデータは何か
呼び出し側に何を必ず処理させたいのか
この問いに答える形で型を作ると、不正な状態が減り、分岐の見通しが良くなり、業務用語がコードに残ります。
既存システムでは、まず戻り値から始めるのがおすすめです。
TryXxx、null、ErrorCode、例外による業務分岐が増えている箇所を、専用の結果型に置き換えてみる。
それだけでも、コードの読みやすさと安全性は大きく変わります。
参考
- Discriminated Unions - F# | Microsoft Learn
- Pattern matching overview - C# | Microsoft Learn
- switch expression - C# reference | Microsoft Learn
- Records - C# reference | Microsoft Learn
- .NET Standard - .NET | Microsoft Learn
- Explore union types in C# 15 - .NET Blog
- Unions - C# feature specifications | Microsoft Learn
- OneOf - NuGet
- Thinktecture.Runtime.Extensions - NuGet
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Windowsの偽装トークンを正しく扱う ── スレッド単位の権限借用と安全な戻し方
Windowsの偽装トークンについて、アクセストークン、プライマリトークン、スレッドトークン、偽装レベル、RevertToSelf、.NETのWindowsIdentity.RunImpersonatedまで、実務で安全に扱うための考え方を整理します。
TCPでSendした単位ごとにReceiveできるという誤解 ── バイトストリームとして扱うための受信設計
TCP通信で、SendやWriteした単位ごとに受信できると思い込むと、分割・結合・文字化け・プロトコル破損が起きます。TCPをバイトストリームとして扱い、アプリケーション側でフレーミングする考え方と、.NET/C#での実装例を整理します。
.NETでGC待ちとメモリリークを見分ける ── 増えるメモリを観測・比較・証明する実務手順
.NETアプリケーションで、メモリが増えている理由がガベージコレクション待ちなのか、本当にメモリリークしているのかを、dotnet-counters、dotnet-gcdump、dotnet-dumpを使って切り分ける手順を整理します。
C#(CSharp)でPowerShellを実行して、オブジェクトとして受け取る方法
C#からPowerShellを起動し、文字列ではなくPSObjectとして結果を受け取る方法を、PowerShell SDK、AddCommand、AddParameter、BaseObject、Properties、エラー処理まで実務目線で整理します。
PesterによるPowerShellのテスト整備 ── 運用スクリプトを壊しにくくする実務の型
PowerShellスクリプトをPester v5でテストし、日付処理、ファイル操作、削除処理、モック、CI実行までを安全に整備する実務手順を整理します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
既存資産活用・移行支援
COM / ActiveX / OCX、32bit / 64bit 制約を抱える既存資産の活用と移行を支援します。
著者プロフィール
記事の著者プロフィールページです。
小村 豪
合同会社小村ソフト 代表
Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。
公開リンク