FluentResultsとは|C#のResult型エラーハンドリングとv4.0移行
FluentResultsは、メソッドの成否を例外ではなく戻り値のオブジェクトで表す.NET向けライブラリです。2025年6月29日にNuGetで公開されたv4.0.0では、Errorsプロパティの型が変わるなどソース互換を壊す変更が3点入りました。この記事では、v4.0.0の実装を読んだうえで、生成から判定、独自エラークラス、例外との橋渡しまでをコード付きで整理し、v3.16から上げるときに実際にコンパイルが通らなくなる箇所と、ErrorOr・OneOfではなくFluentResultsを選ぶ条件までを扱います。
まとめ
- FluentResultsは、成功・失敗を
Result/Result<T>で返し、失敗理由を文字列ではなくオブジェクト(IError)として複数保持できるライブラリ。NuGetの累計ダウンロードは3,268万件(2026年7月27日時点)。 - 最新はv4.0.0(2025年6月29日公開)。対応フレームワークは.NET Standard 2.0/2.1と.NET 8/9で、.NET Framework 4.8はStandard 2.0経由で動く。
- v3.16からの移行で壊れるのは、
Errors/SuccessesのIReadOnlyList化、空コレクションを渡したResult.Failの例外化、Result<T>の2要素Deconstructが返す中身の入れ替わりの3点。 - 入力値の検証エラーのように「起こることを想定済みの失敗」だけをResultに移し、プログラムの前提が壊れた状態は例外のまま投げるのが実務的な線引き。
- 1件のエラーで即座に打ち切る設計ならErrorOrやOneOfのほうが軽い。FluentResultsが効くのは、複数エラーの蓄積とメタデータ付きの原因チェーンが必要な場面。
FluentResultsの位置づけと例外との使い分け
Resultオブジェクトが解決する問題
C#の例外はシグネチャに現れません。decimal CalculatePrice(Order order)という宣言を見ても、この呼び出しが失敗しうるのか、失敗するとしたら何種類の理由があるのかは、実装かXMLコメントを読むまで分かりません。呼び出し側がtry-catchを書き忘れてもコンパイルは通ります。
FluentResultsは戻り値の型をResult<decimal>に変えることで、この情報を型に載せます。失敗理由はIErrorを実装したオブジェクトとして保持されるため、エラーコードや対象IDといった付帯情報を文字列に埋め込まずに済みます。ひとつのResultが複数のエラーを同時に持てる点が、この種のライブラリの中でも特徴的です。入力フォームの全項目を検証して不備をまとめて返す用途が、そのまま設計に乗ります。
Result型そのものの考え方と、成功時だけ処理を連結していく設計の流儀については鉄道指向プログラミングとは|Result型で書くエラーハンドリング入門で扱っています。本記事はC#とFluentResultsの実装に絞ります。
例外を残すべき場面、Resultへ移すべき場面
すべての例外をResultに置き換えるのは行き過ぎです。判断基準は「呼び出し側がその失敗を業務ロジックとして処理するかどうか」に置きます。
Resultへ移すのは、在庫不足、権限不足、入力値の書式不正、外部APIが業務エラーを返したケースです。いずれも呼び出し側が分岐して画面表示やリトライを決める、つまり失敗が正常系の一部である処理にあたります。
一方、NullReferenceExceptionやArgumentExceptionのように前提条件の破れを示すものは例外のまま投げます。これらをResult.Failに変換すると、バグが「処理された失敗」として静かに握り潰され、原因の特定が遅れます。設定ファイルの欠落や接続文字列の不備といった起動時の致命的エラーも同様で、そこは落ちたほうが安全です。例外処理そのもののコストや設計についてはエラーハンドリングのパフォーマンス最適化と実用例もあわせて参照してください。
インストールと対応フレームワーク(v4.0.0)
NuGetから導入します。
dotnet add package FluentResults
v4.0.0のcsprojが宣言しているターゲットはnetstandard2.0、netstandard2.1、net8.0、net9.0の4つです。.NET Framework 4.8や4.7.2のプロジェクトは.NET Standard 2.0のアセンブリを参照して動作します。.NET Standard 1.1/.NET Framework 4.6.1/4.5しか使えない環境は、公式READMEがv2.x系の利用を案内しています。
| 版 | 公開日(NuGet) | ターゲット | 備考 |
|---|---|---|---|
| 4.0.0 | 2025-06-29 | netstandard2.0/2.1, net8.0, net9.0 | 現行。破壊的変更3点 |
| 3.16.0 | 2024-06-30 | netstandard2.0/2.1 | v3系の最終 |
| 3.15.2 | 2023-02-04 | netstandard2.0/2.1 | 暗黙変換の二重梱包を修正 |
使う側のコードでは名前空間FluentResultsを開きます。ライセンスはMITで、商用プロジェクトでも追加費用は発生しません。
v3.16からv4.0で壊れる3点と移行手順
v4.0のリリースノートが「(BREAKING CHANGE)」と明記しているのは「Deconstruct operators #217」の1点だけです。ただし同じCHANGED欄に並ぶ「Introduce ReadOnlyList type #164」「Calling Fail methods with an empty error list throws exception #227」も、既存コードのコンパイルと実行を止めます。v3.16タグとv4.0タグのソースを突き合わせ、実際に何が起きるかを3点に整理します。移行前にこの3点を検索しておくと作業が読めます。
ErrorsとSuccessesのIReadOnlyList化
v3.16のResultBaseはList<IError> ErrorsとList<ISuccess> Successesを公開していました。v4.0ではIReadOnlyList<IError>/IReadOnlyList<ISuccess>に変わっています(ReasonsはList<IReason>のまま)。
// v3.16では通ったが、v4.0ではコンパイルエラー
List<IError> errors = result.Errors;
result.Errors.Add(new Error("後から追加"));
// v4.0での書き方
IReadOnlyList<IError> errors = result.Errors;
var editable = result.Errors.ToList();
この変更の背景はPR #164で説明されています。v3.16のErrorsは参照するたびにReasons.OfType<IError>().ToList()で新しいリストを作って返す実装だったため、result.Errors.Add(...)と書いても次にresult.Errorsを読むと空のまま、という無言の不具合を生んでいました。型が読み取り専用になったことで、この誤用がコンパイル時に止まります。エラーを追加したいときはWithErrorを使います。
Result.Failに空のコレクションを渡すとArgumentException
v3.16では、要素が空のリストをResult.Failに渡すとIsFailedがfalseの「失敗のつもりで作った成功Result」ができていました(Issue #227)。v4.0はここでガードします。
var errors = Validate(input); // 不備が無ければ空のリスト
// v4.0: 空だと ArgumentException("The list of errors is empty")
var bad = Result.Fail(errors);
// 検証結果をそのまま渡すなら FailIfNotEmpty を使う
var ok = Result.FailIfNotEmpty(errors);
検証メソッドの戻り値をResult.Failにそのまま流している箇所は、v4.0に上げた瞬間に実行時例外へ変わります。コンパイルは通ってしまうため、この3点のうち最も見落としやすいのはここです。Result.Fail(で始まる呼び出しのうち、引数がコレクションのものを洗い出してFailIfNotEmptyに置き換えるのが安全です。v4.0では引数にエラーのコレクションを取るFailIfのオーバーロードも追加されたため、条件付きの一括失敗はそちらで書けます。
Deconstructの2要素オーバーロードとREADMEの記載ずれ
v4.0ではResult<TValue>にDeconstruct(out TValue value, out IReadOnlyList<IError> errors)が追加され、Go言語風の受け取り方ができるようになりました。3点のうち最も危険なのがここです。v3.16でも2要素の分解代入は書けましたが、それは基底クラスResultBaseが持つDeconstruct(out bool isSuccess, out bool isFailed)を継承したもので、返るのは真偽値2つでした。v4.0ではResult<TValue>が自前の2要素オーバーロードを持つため、同じソースが「値とエラー一覧」に解決され直します。
Result<bool> r = Result.Fail<bool>("失敗");
var (isSuccess, isFailed) = r;
// v3.16: isSuccess=false, isFailed=true (成否の真偽値)
// v4.0 : isSuccess=false, isFailed=エラー一覧 (value と errors)
TValueがboolの場合は両版ともコンパイルが通り、受け取る中身だけが静かに入れ替わります。それ以外の型なら型不一致でコンパイルエラーになるため気づけます。Result<T>を2要素で分解している箇所は、上げる前に3要素のオーバーロードへ書き換えてください。v4.0の2要素分解を新規に使うなら、エラー側を先に見る形になります。
var (value, errors) = GetPrice(orderId);
if (errors is not null)
{
return Problem(errors);
}
// 失敗時 value は default(TValue) が入るため、errors を先に見る
注意点がひとつあります。公式READMEは値を持たないResultについてもvar (isSuccess, errors) = Result.Fail("Failure 1");と書いていますが、v4.0.0のResult.csに存在する2要素のオーバーロードはDeconstruct(out bool isSuccess, out bool isFailed)だけです。READMEのとおりに書くと第2要素はboolとして解釈され、IEnumerable<IError>を受ける変数に代入した時点でコンパイルエラーになります。値なしのResultからエラーを取り出すなら、3要素のオーバーロードを使います。
var (isSuccess, isFailed, errors) = DeleteTask(taskId);
ResultとResult<T>の生成・判定
生成:Ok・Fail・FailIf・OkIf
戻り値を持たない処理はResult、値を返す処理はResult<T>を使います。
public Result CloseTask(TaskState state)
{
if (state == TaskState.Done)
return Result.Fail("完了済みのタスクは閉じられません");
return Result.Ok();
}
public Result<Order> FindOrder(int id)
{
var order = _repository.Find(id);
if (order is null)
return Result.Fail<Order>(new OrderNotFoundError(id));
return Result.Ok(order);
}
条件式から直接組み立てるFailIfとOkIfを使うと、三項演算子を書かずに済みます。エラーオブジェクトの生成コストを避けたい場合は、Func<string>やFunc<IError>を受けるオーバーロードで遅延生成できます。
var r1 = Result.FailIf(string.IsNullOrEmpty(firstName), "氏名が未入力です");
var r2 = Result.FailIf(
items.Any(IsExpired),
() => new Error($"期限切れの商品が{items.Count(IsExpired)}件あります"));
複数のエラーを積み上げるときはWithErrorとWithSuccessを鎖状につなぎます。Resultは失敗理由を1件に限定しません。
var result = Result.Fail("在庫が不足しています")
.WithError("配送先が未設定です")
.WithSuccess("与信チェックは通過しました");
判定:IsFailed・Value・ValueOrDefault
受け取った側はIsSuccess/IsFailedで分岐します。ValueとValueOrDefaultの違いは失敗時の挙動です。Valueは失敗状態のResultに対してInvalidOperationExceptionを投げ、ValueOrDefaultはdefault(T)を返します。
Result<int> result = CalculateStock(sku);
if (result.IsFailed)
{
foreach (var error in result.Errors)
_logger.LogWarning("{Message}", error.Message);
// var v1 = result.Value; // 失敗状態で読むと InvalidOperationException
var v2 = result.ValueOrDefault; // 0 が返る
return;
}
var stock = result.Value;
C# 8以降のパターンマッチングとも組み合わせられます。
var message = result switch
{
{ IsFailed: true } => string.Join("/", result.Errors.Select(e => e.Message)),
_ => $"在庫数は{result.Value}です"
};
Reasonsにはエラーと成功メッセージの両方が時系列で入ります。監査ログのように処理の経過を残したい場合は、ErrorsではなくReasonsを辿ります。
独自Errorクラスとルートコーズの記録
Errorを継承したドメイン固有エラー
失敗理由を文字列で渡すと、呼び出し側は文字列比較で分岐するしかなくなります。Errorクラスを継承して失敗の種類ごとにクラスを作れば、HasError<T>で型による判定ができます。
public class StockShortageError : Error
{
public StockShortageError(string sku, int requested, int available)
: base($"SKU {sku} の在庫が不足しています(要求{requested}、在庫{available})")
{
Metadata.Add("Sku", sku);
Metadata.Add("Shortage", requested - available);
}
}
var result = Result.Fail(new StockShortageError("A-100", 5, 2));
if (result.HasError<StockShortageError>(out var found))
{
var shortage = found.First().Metadata["Shortage"];
}
MetadataはDictionary<string, object>なので、HTTPステータスコードやリトライ可否のフラグを載せておき、API層で参照する使い方ができます。エラーメッセージを組み立て直す処理をコントローラーに書かずに済みます。
CausedByとExceptionalErrorで例外を保持
catchした例外をそのまま捨てるとスタックトレースが失われます。CausedByで原因を入れ子にすると、エラーの木構造として保持されます。
try
{
_csvExporter.Export(rows);
}
catch (IOException ex)
{
return Result.Fail(new Error("CSV出力に失敗しました").CausedBy(ex));
}
例外を渡したCausedByは内部でExceptionalErrorを作り、対象のエラーのReasonsにぶら下げます。取り出すときは型で絞り込みます。
foreach (IError error in result.Errors)
{
foreach (var cause in error.Reasons.OfType<ExceptionalError>())
_logger.LogError(cause.Exception, "根本原因");
}
Result.Tryによる例外の境界化
既存コードやサードパーティのライブラリが例外を投げる場合、呼び出しをResult.Tryで包むと、その場でResultに変換できます。
var result = Result.Try(() => _legacyApi.Send(payload));
変換のされ方は既定のcatchハンドラに従い、例外メッセージを持つExceptionalErrorになります。例外の型ごとに扱いを変えたいときは、アプリケーション起動時にResult.Setupで差し替えます。
Result.Setup(cfg =>
{
cfg.DefaultTryCatchHandler = exception => exception switch
{
SqlException sql => new ExceptionalError("データベースエラー", sql),
HttpRequestException http => new Error("外部API呼び出しに失敗")
.WithMetadata("Retryable", true)
.CausedBy(http),
_ => new Error(exception.Message)
};
});
Result.Setupはプロセス全体に効く静的設定です。テストを並列実行するプロジェクトでは、テスト間で設定が漏れて期待値が変わることがあります。単発の変換だけ挙動を変えたいなら、Result.Try(action, ex => new MyError(ex))のように第2引数でハンドラを渡すほうが副作用が小さくなります。
BindとMapの使い分けと処理の連鎖
Bindは、直前のResultが成功のときだけ次の処理を評価し、失敗ならその失敗をそのまま伝播させます。ifによるガードの積み重ねを1本の式にまとめられます。
Result<Invoice> result = FindOrder(orderId)
.Bind(order => ValidateOrder(order))
.Bind(order => ChargePayment(order))
.Bind(payment => IssueInvoice(payment));
両方のResultが持つReasonsは統合され、平坦化された新しいResultになります。非同期版のオーバーロードもあるため、awaitを挟む処理も同じ形で書けます。
var result = await Result.Ok(5)
.Bind(n => Task.FromResult(Result.Ok(n + 1).WithSuccess("加算しました")));
Mapとの違いは戻り値です。Bindに渡す関数はResultを返すため、失敗しうる処理の連結に向きます。一方のMapに渡す関数は素の値を返すので、DTOへの詰め替えのように失敗しない変換専用。型変換だけならToResult<T>もあります。
Result<OrderDto> dto = Result.Ok(order).Map(o => new OrderDto(o));
行ごとのResultを1本へ集約するのがResult.Mergeです。冒頭で挙げた「不備を全部返す」形は、これで完成します。
var results = rows.Select(Validate).ToArray(); // Result<Row>[]
Result<IEnumerable<Row>> merged = Result.Merge(results);
if (merged.IsFailed)
return BadRequest(merged.Errors.Select(e => e.Message));
foreach (var row in merged.Value)
Import(row);
ラムダ式の連鎖という点ではLINQのメソッド構文に似た読み味になります。C#でのラムダやクエリ構文の基礎はLINQの読み方は「リンク」|C#・.NETの統合言語クエリを基礎から解説にまとめています。
ASP.NET Coreでの返却とログ出力
Extensions.AspNetCoreは0.2.0のプレビュー
ResultをそのままコントローラーのアクションからHTTPレスポンスに変換する公式パッケージとしてFluentResults.Extensions.AspNetCoreがあります。ただし最新は0.2.0(2025年6月29日公開)で、本体READMEの機能一覧でも「IN PREVIEW」と表記されています。0.1.0の公開が2022年11月なので、2年7か月で2バージョンという更新頻度です。
本番のAPIで採用するなら、IErrorの型を見てステータスコードを決める変換を自前で数十行書くほうが、破壊的変更の影響を受けにくくなります。ControllerBaseを継承したコントローラー内に置く前提のコードです。
public IActionResult ToActionResult<T>(Result<T> result)
{
if (result.IsSuccess)
return Ok(result.Value);
if (result.HasError<OrderNotFoundError>())
return NotFound(result.Errors.Select(e => e.Message));
return BadRequest(result.Errors.Select(e => e.Message));
}
IResultLoggerとLogIfFailed
ログ出力はIResultLoggerを実装してResult.Setupに登録すると、Resultに対してLog()を呼べるようになります。失敗時だけ記録するLogIfFailed()は既定のログレベルがErrorです(v3.14でInformationから変更)。成功時だけのLogIfSuccess()はInformationになります。
Result<int> result = DoSomething();
result.LogIfFailed();
result.Log<OrderContext>("注文処理の結果", LogLevel.Debug);
テスト側の補助としてFluentResults.Extensions.FluentAssertionsもありますが、導入前に依存関係を確認してください。最新の2.2.1(2025年7月10日公開)はFluentAssertionsのバージョンを[6.7.0, 6.7.0]と厳密に固定しており、FluentAssertions側の最新である8.x系とは共存できません。FluentAssertions 8.x以降は非商用・オープンソース用途のみ無償で、商用利用にはXceed社の有償ライセンスが必要になったため、6.7.0固定は結果的にライセンス面の回避先にもなっています。
FluentResultsを選ぶべきでない場面
.NETのResultパターン実装は複数あり、FluentResultsが常に最適とは限りません。設計上の前提が違います。
| ライブラリ | 最新版 | 累計DL | 複数エラー保持 | 向く用途 |
|---|---|---|---|---|
| FluentResults | 4.0.0 | 3,268万 | 可 | 一括検証・原因チェーン |
| ErrorOr | 2.1.1 | 1,024万 | 可 | ASP.NET Core・DDD寄り |
| OneOf | 3.0.271 | 6,686万 | 不可(判別共用体) | 戻り値の型を厳密に分岐 |
| CSharpFunctionalExtensions | 3.7.0 | 3,543万 | 不可 | 関数型スタイル全般 |
数値は2026年7月27日時点のNuGet集計です。判断の軸は「失敗を1件で打ち切るか、集めるか」に置きます。
失敗した時点で処理を止めるAPIエンドポイントが大半を占めるなら、FluentResultsのReasonsリストは持て余します。最初のエラーで返すだけなら、戻り値の型に「成功型かエラー型のどちらか」を表現するOneOfのほうが、コンパイラの網羅性チェックが効くぶん安全です。ASP.NET CoreのProblemDetailsへの変換を標準で持つErrorOrも、Web APIに限れば配線が短くなります。
逆に、CSVの一括取り込みで1万行を検証して不備を全部返す、あるいは複数の外部サービス呼び出しの失敗理由を原因つきで積み上げる、といった要件ではFluentResultsが有利です。MetadataとCausedByで構造化した失敗情報を、そのまま画面やログに流せます。
もうひとつ、既存の大規模プロジェクトに後から入れる場合は範囲を区切ってください。ドメイン層だけResultに変え、インフラ層は例外のままResult.Tryで受けるのが移行コストの小さい形です。全レイヤーを一気に置き換えると、途中でResultを無視してValueを直接読む箇所が残り、失敗を握り潰すコードが生まれます。
よくある質問
FluentResultsは商用プロジェクトで無償利用できますか
ライセンスはMITなので、商用・非商用を問わず無償で利用・改変・再配布できます。ただしテスト補助のFluentResults.Extensions.FluentAssertionsが依存するFluentAssertions 8.x以降は商用利用に有償ライセンスが必要です。同拡張は6.7.0を固定参照しているため現時点で費用は発生しませんが、テスト基盤側でFluentAssertionsを別途更新する場合は確認してください。
.NET Framework 4.8のプロジェクトでも使えますか
使えます。v4.0.0は.NET Standard 2.0をターゲットに含むため、.NET Framework 4.7.2以降なら参照可能です。.NET Framework 4.6.1や4.5を対象にする必要がある場合は、公式READMEがv2.x系を案内しています。
Result型とFluentResultsはどう違いますか
Result型は「成功か失敗かを戻り値で表す」という設計パターンの総称で、FluentResultsはそれを.NET向けに実装したライブラリのひとつです。パターン自体の考え方は鉄道指向プログラミングとは|Result型で書くエラーハンドリング入門で解説しています。
v3系のコードをv4.0に上げると何が壊れますか
Errors/SuccessesをList<T>型の変数に代入している箇所と、空のコレクションをResult.Failに渡している箇所です。前者はコンパイルエラー、後者はArgumentExceptionとなる実行時エラーになります。加えてResult<T>に2要素のDeconstructが追加されたため、既存の分解代入がどのオーバーロードに解決されるかも確認してください。
Resultを導入したら例外処理は不要になりますか
不要にはなりません。業務上想定される失敗をResultで表現しても、メモリ不足やネットワーク断のような制御外の例外は発生します。アプリケーションの最上位には例外ハンドラを残し、Resultは「呼び出し側が分岐して処理する失敗」に限定して使うのが実務的です。