FluentResultsは、メソッドの成否を例外ではなく戻り値のオブジェクトで返す.NET向けのライブラリです。この記事では現行のv4.0.0を.NET SDK 10.0.401で動かし、生成・判定・連結・例外の受け止め方を実行結果つきで示します。v3.16から上げるときに壊れる3点、失敗したResult<T>をJSONに直すと落ちる理由、C# 15で入るunion型との使い分けまで扱います。
まとめ
- FluentResultsは
Result/Result<T>で成否を返し、失敗理由をIErrorオブジェクトとして複数保持できる。NuGetの累計ダウンロードは3,846万件(2026年11月3日時点)、ライセンスはMIT。 - 現行はv4.0.0(2025年6月29日公開)。ターゲットは.NET Standard 2.0/2.1と.NET 8/9で、.NET 10のプロジェクトではnet9.0版のDLLが選ばれる。2025年7月10日を最後にコミットは止まっている。
- v3.16からの移行で壊れるのは、
ErrorsのIReadOnlyList化、空リストを渡したResult.Failの例外化、Result<T>の2要素分解の中身の入れ替わりの3点。3点目はコンパイルが通るので検索して直す。 - 失敗した
Result<T>をSystem.Text.Jsonに渡すとInvalidOperationExceptionで落ちる。API境界では自前のDTOへ詰め替える。 - C# 15の
unionは「結果がどれか1つ」の分岐を網羅チェックつきで書ける。複数エラーの蓄積と原因チェーンが要る場面は、引き続きFluentResultsの担当になる。
FluentResultsの位置づけと例外との使い分け
Resultオブジェクトが解決する問題
C#の例外はメソッドのシグネチャに現れません。decimal CalculatePrice(Order order)という宣言からは、失敗しうるのか、失敗理由が何種類あるのかが読めず、呼び出し側がtry-catchを書き忘れてもコンパイルは通ります。
戻り値をResult<decimal>に変えると、失敗しうることが型に載ります。失敗理由はIErrorを実装したオブジェクトなので、エラーコードや対象IDを文字列に埋め込まずに持てます。1つのResultが複数のエラーを同時に持てるため、入力フォームの全項目を検証して不備をまとめて返す処理がそのまま書けます。
Result型という設計そのものと、成功時だけ処理をつなぐ考え方は鉄道指向プログラミング(ROP)とは|Result型のbind・mapと5言語の実装、使わない場面で扱っています。本記事はC#とFluentResultsの実装に絞ります。
例外を残す場面、Resultへ移す場面
判断基準は「呼び出し側がその失敗を業務ロジックとして分岐するか」です。在庫不足、権限不足、入力値の書式不正、外部APIが業務エラーを返したケースは、画面表示やリトライの判断に使うのでResultへ移します。
NullReferenceExceptionやArgumentExceptionのような前提条件の破れは例外のまま投げます。これをResult.Failに変えると、バグが「処理された失敗」として握り潰されます。設定ファイルの欠落など起動時の致命的エラーも、落ちたほうが安全です。例外処理の基本と言語ごとの違いはエラーハンドリングとは?例外処理のベストプラクティスとtry-catch-finallyの言語別の違いにまとめています。
FluentResults v4.0.0の導入と.NET 10での互換性
dotnet add package FluentResults --version 4.0.0
使う側では名前空間FluentResultsを開きます。v4.0.0のcsprojが宣言するターゲットはnetstandard2.0、netstandard2.1、net8.0、net9.0の4つです(NuGetのFluentResults 4.0.0)。net10.0は含まれず、NuGet上は「計算上の互換」扱いです。.NET 10のプロジェクトで復元すると、obj/project.assets.jsonに記録される参照先はlib/net9.0/FluentResults.dllでした。.NET Framework 4.7.2/4.8は.NET Standard 2.0版で動きます。
| 版 | 公開日(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系の最終 |
| 2.6.0 | 2021-10-14 | net45, net461, netstandard1.1/2.0/2.1 | .NET 4.5/4.6.1向け |
採用前に開発状況も見ておきます。GitHubのaltmann/FluentResultsは、2025年7月10日のコミット(拡張パッケージの参照をv4.0へ更新)を最後に更新がなく、2026年11月3日時点で未解決のissueが36件、PRが7件あります。依存先はMicrosoft.Extensions.Logging.Abstractions(2.1.1以上)とSystem.Threading.Tasks.Extensions(4.5.4以上)の2つだけで、この記事のコードは.NET 11 RC3(11.0.100-rc.3)のnet11.0プロジェクトでも動きました。ただし更新が止まっている以上、不具合が見つかっても修正版が出る保証はありません。採用するなら、使うAPIを自分のテストで押さえ、いざとなればフォークできるかをMITライセンスの範囲で確認しておきます。
ResultとResult<T>の生成と成否判定
生成:Ok・Fail・FailIf・OkIf
using FluentResults;
Result ok = Result.Ok();
Result<int> okWithValue = Result.Ok(42);
Result failed = Result.Fail("在庫が見つかりません");
Result<int> failedTyped = Result.Fail<int>("在庫が見つかりません");
// 条件から作る(FailIfは条件がtrueで失敗、OkIfはfalseで失敗)
string? name = null;
int qty = 0;
Result nameCheck = Result.FailIf(string.IsNullOrEmpty(name), "名前は必須です");
Result qtyCheck = Result.OkIf(qty > 0, "数量は1以上です");
// 暗黙の型変換:値は成功、Errorは失敗になる
Result<int> fromValue = 5;
Result<int> fromError = new Error("暗黙のエラー");
WithError/WithSuccessで理由を後から足せます。成功のResultにWithErrorを付けると、その時点でIsFailedがtrueに変わります。成否はフラグではなく、ReasonsにIErrorが1つでもあるかで決まるためです。
判定:IsFailed・Value・ValueOrDefault
失敗状態でValueを読むと例外、ValueOrDefaultはdefault(T)を返します。
Result<int> result = Result.Fail<int>("在庫が見つかりません");
if (result.IsFailed)
{
foreach (var error in result.Errors)
Console.WriteLine(error.Message);
var v = result.ValueOrDefault; // 0
// var x = result.Value; // 例外になる(下の実行結果)
return;
}
.NET 10でresult.Valueを読んだときの例外と、ToString()の出力は次のとおりです。ログにそのまま出すと、どのエラーで失敗したかが1行で残ります。
InvalidOperationException: Result is in status failed. Value is not set. Having: Error with Message='在庫が見つかりません'
Result.Fail("A").WithError("B").ToString()
→ Result: IsSuccess='False', Reasons='Error with Message='A'; Error with Message='B''
Result.Ok(5).WithSuccess("加算しました").ToString()
→ Result: IsSuccess='True', Reasons='Success with Message='加算しました'', Value='5'
Reasonsにはエラーと成功メッセージの両方が追加順に入ります。監査ログのように経過を残したいときはErrorsではなくReasonsを辿ります。
独自Errorクラスと原因の記録
Errorの継承とHasErrorによる型判定
失敗理由を文字列だけで渡すと、呼び出し側は文字列比較で分岐するしかありません。Errorを継承してクラスを分けると、HasError<T>で型による判定ができます。
var result = Result.Fail(new StockShortageError("A-100", 5, 2));
if (result.HasError<StockShortageError>(out var found))
Console.WriteLine(found.First().Metadata["Shortage"]); // 3
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);
}
}
MetadataはDictionary<string, object>なので、HTTPステータスやリトライ可否のフラグを載せてAPI層で読む使い方ができます。成功側も同じで、Successを継承したクラスをHasSuccess<T>で判定できます。
CausedByによる例外保持とHasExceptionによる検索
catchした例外を捨てるとスタックトレースが失われます。CausedByに例外を渡すとExceptionalErrorとして対象エラーのReasonsにぶら下がり、HasException<T>で例外の型から探せます。
try
{
_csvExporter.Export(rows);
}
catch (IOException ex)
{
return Result.Fail(new Error("CSV出力に失敗しました").CausedBy(ex));
}
// 呼び出し側
if (result.HasException<IOException>())
{
var cause = result.Errors[0].Reasons.OfType<ExceptionalError>().First();
_logger.LogError(cause.Exception, "根本原因");
}
HasException<T>は基底型でも一致します。存在しないディレクトリのファイルをResult.Tryで読んだ結果(中身はDirectoryNotFoundException)に対し、HasException<IOException>()もHasException<Exception>()もTrue、兄弟のHasException<FileNotFoundException>()だけがFalseでした。原因の種類で処理を分けるなら、具体的な型から先に判定します。
全エラーに注文番号などの文脈を付け足すならMapErrorsを使います。変換されるのは1段目のエラーだけですが、新しいErrorを返すと元のエラーのReasonsとMetadataは引き継がれません。CausedBy付き・Metadata1件のエラーを下の1行目の形で変換すると、どちらも0件になりました。原因を残すなら元のエラーをCausedBy(e)でぶら下げます。
var lost = result.MapErrors(e => new Error("[注文A-1] " + e.Message));
// Reasons 0件・Metadata 0件(原因が消える)
var kept = result.MapErrors(e => new Error("[注文A-1] " + e.Message).CausedBy(e));
// Reasons[0] が元のエラー。その下に元の原因も残る
Result.Tryによる境界での例外変換
既存コードや外部ライブラリが例外を投げる場合は、呼び出しをResult.Tryで包みます。既定では例外メッセージを持つExceptionalErrorになります。
var r1 = Result.Try(() => int.Parse("abc"));
// ExceptionalError: The input string 'abc' was not in a correct format.
var r2 = Result.Try(() => int.Parse("abc"),
ex => new Error("数値に変換できません").CausedBy(ex));
// Error: 数値に変換できません(Reasons[0]はExceptionalError)
例外の型ごとに変換を変えたいときはResult.SetupでDefaultTryCatchHandlerを差し替えられます。ただしこれはプロセス全体に効く静的設定で、並列実行するテストの間で設定が漏れます。変換を1か所だけ変えたいなら、上のr2のように第2引数でハンドラを渡すほうが副作用がありません。
Bind・Map・Mergeによる処理の連結・変換・集約
Bindは直前のResultが成功のときだけ次の関数を呼び、失敗ならその失敗を後ろへ流します。ifのガードを積み重ねる代わりに1本の式で書けます。
Result<Invoice> result = FindOrder(orderId)
.Bind(order => ValidateOrder(order))
.Bind(order => ChargePayment(order))
.Bind(payment => IssueInvoice(payment));
途中で失敗すると、以降のラムダは呼ばれません。成功メッセージも引き継がれ、Ok(1).WithSuccess("s1")→Bindで"s2"を足す→BindでFail("e3")、とつないだ結果のReasonsはs1, s2, e3の順でした。Taskを返す関数を渡すオーバーロードもあり、awaitを挟む処理も同じ形で書けます。
var r = await Result.Ok(5)
.Bind(n => Task.FromResult(Result.Ok(n + 1).WithSuccess("加算しました")));
// r.Value == 6
Bindに渡す関数はResultを返し、Mapに渡す関数は素の値を返します。失敗しうる処理の連結はBind、DTOへの詰め替えのような失敗しない変換はMapです。失敗したResultにMapを呼んでも関数は実行されず、失敗のまま型だけ変わります。
Result<OrderDto> dto = FindOrder(orderId).Map(o => new OrderDto(o));
行ごとの検証結果を1本にまとめるのがResult.Mergeです。「不備を全部返す」形はこれで書けます。
var rows = new[]
{
Result.Ok(1),
Result.Fail<int>("2行目: 数量が負"),
Result.Fail<int>("3行目: SKU未登録"),
Result.Ok(4)
};
Result<IEnumerable<int>> merged = Result.Merge(rows);
// merged.IsFailed == true, merged.Errors.Count == 2
// 全部成功なら merged.Value に各行の値が並ぶ
ラムダの連鎖はLINQのメソッド構文と同じ読み方になります。基礎はLINQの読み方は「リンク」|C#での書き方・遅延実行と.NET 10の新メソッドを参照してください。
v3.16からv4.0で壊れる3点と直し方
v4.0のリリースノートが「(BREAKING CHANGE)」と書いているのは「Deconstruct operators #217」だけです。ただし同じ欄の「Introduce ReadOnlyList type #164」と「Calling Fail methods with an empty error list throws exception #227」も既存コードを止めます。3.16.0と4.0.0で同じコードを動かした結果を並べます。
ErrorsとSuccessesのIReadOnlyList化
v3.16のErrorsは、読むたびにReasonsから新しいListを作って返す実装でした。そのためresult.Errors.Add(...)はコンパイルが通るのに何も起きません。エラー1件のResultにAddしても、件数は1のままでした。v4.0では型がIReadOnlyList<IError>になり、同じコードはコンパイルエラーになります(ReasonsはList<IReason>のまま)。
// v4.0でのエラー
List<IError> errors = result.Errors; // CS0266: IReadOnlyList<IError> を List<IError> に暗黙的に変換できません
result.Errors.Add(new Error("後から追加")); // CS1061: 'IReadOnlyList<IError>' に 'Add' の定義が含まれておらず…
// 直し方
IReadOnlyList<IError> errors = result.Errors;
result.WithError("後から追加");
Result.Failに空のリストを渡すと例外
v3.16ではResult.Fail(new List<IError>())がIsSuccess == trueのResultを返していました。失敗を作ったつもりが成功になる不具合です。v4.0ではArgumentException: The list of errors is empty (Parameter 'errors')を投げます(文字列リスト版はParameter 'errorMessages')。検証結果を集めてからFailに渡すコードは、件数0のときにResult.Ok()を返す分岐を先に置きます。
分解代入の要素変更とコンパイル時の見落とし
最も見落としやすいのがこれです。v3.16では基底のResultBaseにDeconstruct(out bool isSuccess, out bool isFailed)があり、Result<T>も継承していました。v4.0ではResult<T>自身が(value, errors)の2要素版を持ちます。
var (a, b) = Result.Ok(false);
// v3.16: a=True, b=False (IsSuccess, IsFailed)
// v4.0 : a=False, b=null (Value, Errors)
varで受けている分解代入そのものはどちらの版でもビルドが通ります。Result<bool>では第1要素がどちらもboolなので、if (a)のように第1要素だけを使うコードは警告もなく意味だけが変わります。第2要素をboolとして使っている箇所は、型がIReadOnlyList<IError>に変わるのでコンパイルエラーで見つかります。もう1つ、v4.0の2要素版は成功時にerrorsが空のリストではなくnullです。errors.Countで成否を見るとNullReferenceExceptionになるので、errors is nullかIsFailedで判定します。
READMEの非ジェネリックResultの例var (isSuccess, errors) = Result.Fail("Failure 1");も実装とずれています。非ジェネリックの2要素版は(bool isSuccess, bool isFailed)なので、このコードはビルドは通りますがerrorsはboolになり、errors.Countと書いた時点でCS1061: 'bool' に 'Count' の定義が含まれておらずになります。エラーも受け取るなら要素数を増やします。
var (isSuccess, isFailed, errors) = Result.Fail("x"); // Result
var (ok, ng, value, errs) = Result.Ok(7); // Result<T>: True, False, 7, null
移行時はvar (と.Errors、Result.Fail(の3つをソース全体で検索し、上の3パターンに当たる箇所を直すと漏れが出にくくなります。
ASP.NET CoreのAPI境界におけるResultのDTO変換
失敗したResult<T>の既定JSONシリアライズ時の例外
公式READMEのベストプラクティスは「FluentResultsのResultをシリアライズしない」「API境界では自前のResultDtoを作る」です。理由は推奨というより実害で、System.Text.Jsonに失敗したResult<T>を渡すとValueのゲッターが呼ばれて落ちます。
JsonSerializer.Serialize(Result.Ok(5));
// {"ValueOrDefault":5,"Value":5,"IsFailed":false,"IsSuccess":true,"Reasons":[],"Errors":[],"Successes":[]}
JsonSerializer.Serialize(Result.Fail<int>("NG"));
// InvalidOperationException: Result is in status failed. Value is not set. …
成功時だけ通るため、テストを正常系しか書いていないと本番の失敗時に初めて500エラーになります。公開する形を自分で決めたDTOに詰め替えます。
public record ErrorDto(string Message, IReadOnlyDictionary<string, object> Metadata);
public record ResultDto<T>(bool IsSuccess, T? Value, IReadOnlyList<ErrorDto> Errors);
public static class ResultDto
{
public static ResultDto<T> From<T>(Result<T> result) => new(
result.IsSuccess,
result.ValueOrDefault,
result.Errors.Select(e => new ErrorDto(e.Message, e.Metadata)).ToList());
}
// {"IsSuccess":false,"Value":0,"Errors":[{"Message":"数量が負です","Metadata":{"Field":"Qty"}}]}
Minimal APIにおけるProblemDetailsへの変換
HTTPのステータスに対応づけるなら、エラーの型で分岐してTypedResultsを返す関数を1つ用意します。.NET 10で、失敗はProblemHttpResult、OrderNotFoundErrorはNotFound、成功はOk<T>が返ることを確かめています。
static IResult ToHttpResult<T>(Result<T> result)
{
if (result.IsSuccess)
return TypedResults.Ok(result.Value);
if (result.HasError<OrderNotFoundError>())
return TypedResults.NotFound();
return TypedResults.Problem(
title: "入力内容に誤りがあります",
statusCode: StatusCodes.Status400BadRequest,
extensions: new Dictionary<string, object?>
{
["errors"] = result.Errors.Select(e => e.Message).ToArray()
});
}
app.MapGet("/orders/{id}", (string id) => ToHttpResult(FindOrder(id)));
公式のFluentResults.Extensions.AspNetCoreもありますが、最新は0.2.0で、READMEでも「IN PREVIEW」の扱いです。issue #238は、.NET 8/9でPackageReferenceの代わりにFrameworkReferenceを使う提案で、2025年7月から未解決です。本番のAPIでは上のような変換を自前で持つほうが、拡張の更新停止に引きずられません。
IResultLoggerによるログ出力と既定レベル
IResultLoggerを実装してResult.Setup(cfg => cfg.Logger = ...)で登録すると、Log()/LogIfFailed()/LogIfSuccess()が使えます。実行すると、引数を省いたときのログレベルはLogIfFailed()がError、LogIfSuccess()がInformationでした。
テスト補助のFluentResults.Extensions.FluentAssertionsは、最新の2.2.1(2025年7月10日公開)がFluentAssertionsを6.7.0に厳密固定しています。FluentAssertionsは8以降、商用利用に有償ライセンスが必要になりました(Xceedの製品ページではBasicが開発者1人14.95ドル・12か月単位)。テスト基盤でFluentAssertionsを8系へ上げると、この拡張とは共存できず、ライセンス費用も発生します。
C# 15のunion型とFluentResultsの使い分け
.NET 11は2026年11月10日に正式公開の予定で、サポート期間は2028年11月9日までのSTSです(dotnet/coreのリリース一覧)。.NET 11を対象にしたプロジェクトの既定はC# 15で、C# 15の新機能としてunion宣言が入ります。<LangVersion>preview</LangVersion>の指定は要りません。
「成功・見つからない・不正のどれか1つ」を返す型が、ライブラリなしで書けるようになります。.NET 11 RC3 SDKで次のコードを実行し、FluentResults 4.0.0を同じプロジェクトで併用できることも確かめました。
string Describe(OrderResult r) => r switch
{
Order o => $"注文 {o.Sku} x{o.Qty}",
NotFound n => $"見つかりません: {n.Sku}",
Invalid i => $"不正: {i.Reason}",
};
Console.WriteLine(Describe(new Order("A-100", 3))); // 注文 A-100 x3
Console.WriteLine(Describe(new NotFound("B-200"))); // 見つかりません: B-200
public record Order(string Sku, int Qty);
public record NotFound(string Sku);
public record Invalid(string Reason);
public union OrderResult(Order, NotFound, Invalid);
Invalidの行を消すと、コンパイラはwarning CS8509: この switch 式では入力型の可能な値がすべて扱われるわけではありません … パターン 'Invalid' がカバーされていません。と、漏れたケースを名指しします。ケースを足したときに処理漏れが警告で見つかる点は、FluentResultsのHasError<T>には無い性質です。網羅性チェックの考え方はDiscriminated Unionとは?判別可能なユニオン型の書き方とneverでの網羅チェックでTypeScriptやRustと比べています。
使い分けは次のとおりです。
- unionへ寄せる:.NET 11以降の新規コードで、結果が排他的な数種類のどれか1つになり、呼び出し側が全ケースを分岐するもの。OneOfで書いていた用途の多くはここに移る。
- FluentResultsを続ける:検証エラーを複数ためて返す、
MetadataやCausedByで原因を構造化して運ぶ、Mergeで行単位の結果を集約する。unionはこれらの仕組みを持たない。 - .NET 10(LTS)に留まる:既定はC# 14で、unionは前提にできない。2028年11月までLTSを使う案件では、従来どおりライブラリで書く。
.NET 11で変わる点の全体は.NET 11の新機能とリリース日|サポート期限・C# 15・.NET 10との挙動差を実測【RC1】、LTS側の事情は.NET 10(LTS)とは?SDKの選び方と入手・サポート期限・C# 14と移行を解説にあります。
FluentResultsを選ぶべきでない場面
| ライブラリ | 最新版 | 累計DL | 複数エラー | 向く用途 |
|---|---|---|---|---|
| FluentResults | 4.0.0 | 3,846万 | 可 | 一括検証・原因チェーン |
| ErrorOr | 2.1.1 | 1,312万 | 可 | Web API・エラー種別での分岐 |
| OneOf | 3.0.271 | 7,923万 | 不可(判別共用体) | 戻り値の型を厳密に分岐 |
| CSharpFunctionalExtensions | 3.7.0 | 3,988万 | Combineで文字列結合 | 関数型スタイル全般 |
数値は2026年11月3日時点のNuGet集計です。判断の軸は「失敗を1件で打ち切るか、集めるか」です。
最初のエラーで返すだけのAPIが大半なら、FluentResultsのReasonsリストは持て余します。OneOfのMatchは型引数ごとのハンドラを必須にするため、ケースを足すと呼び出し側の修正漏れがコンパイルエラーで分かります。ErrorOrはFailure・Validation・NotFound・Conflictなど7種類のErrorTypeを組み込みで持ち、内部に複数のErrorも保持できるので、HTTPステータスへの対応づけが短く書けます。
FluentResultsが勝つのは、CSVの一括取り込みで全行を検証して不備を全部返す、複数の外部サービス呼び出しの失敗を原因つきで積み上げる、といった要件です。逆に、開発が止まっているライブラリをドメイン層の全面に入れるのが不安なら、まずドメインの検証処理だけをResultに変え、インフラ層は例外のままResult.Tryで受ける範囲に留めます。全レイヤーを一度に置き換え、失敗判定なしにValueを読む箇所が残ると、上で見たInvalidOperationExceptionが本番で出ます。
よくある質問
FluentResultsは商用プロジェクトで無償利用できますか?
できます。ライセンスはMITで、商用・非商用を問わず無償で利用・改変・再配布できます。費用が発生しうるのはテスト補助側で、FluentAssertionsを8系へ上げると商用利用に有償ライセンスが必要です。
.NET Framework 4.8のプロジェクトでも使えますか?
使えます。v4.0.0は.NET Standard 2.0をターゲットに含むため、.NET Framework 4.7.2以降から参照できます。4.6.1や4.5が対象なら、公式READMEの案内どおりv2.x系を使います。
C#のResult型とFluentResultsはどう違いますか?
Result型は「成否を戻り値で表す」設計パターンの総称で、FluentResultsはそれを.NET向けに実装したライブラリの1つです。.NETの標準ライブラリにResult型はありません。パターン自体の考え方は鉄道指向プログラミング(ROP)とは|Result型のbind・mapと5言語の実装、使わない場面で解説しています。
BindやMapを使うのに別パッケージは必要ですか?
不要です。Bind・Map・Merge・Tryは本体のFluentResults 4.0.0に入っています。NuGetにあるNKZSoft.FluentResults.Extensions.Functionalのような名前の似たパッケージは第三者の拡張で、公式ではありません。
Resultを導入したら例外処理は不要になりますか?
不要にはなりません。業務上の失敗をResultで表しても、メモリ不足やネットワーク断のような制御外の例外は起きます。アプリケーションの最上位には例外ハンドラを残し、Resultは呼び出し側が分岐して処理する失敗に限って使います。