1. はじめに:なぜC#でScalaの話をするのか
業務でC#を書いていると、こんなサービスメソッドに心当たりはないでしょうか。
public bool RegisterUser(UserDto dto)
{
if (dto == null)
{
// ログ
_logger.LogWarning("UserDto is null.");
return false;
}
if (string.IsNullOrWhiteSpace(dto.Name))
{
_logger.LogWarning("Name is required.");
return false;
}
try
{
var entity = new User(dto.Id, dto.Name);
if (_db.Users.Any(u => u.Id == dto.Id))
{
_logger.LogWarning("User already exists.");
return false;
}
_db.Users.Add(entity);
_db.SaveChanges();
return true;
}
catch (SqlException ex)
{
_logger.LogError(ex, "Database error.");
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error.");
return false;
}
}
条件分岐(if)と try-catch が増えてくると、「とりあえず動くけど読みたくないメソッド」になりがちです。
Scalaでは、こういった「成功/失敗」「ある/ない」の情報を型で表現して、
処理をつなげやすくするための道具として Option や Either がよく使われます。
この記事では、そのエッセンスを C# に持ち込んで、
-
Option<T>で null を減らす -
Either<L, R>で成功/失敗を1つの値にまとめる - LINQメソッドチェーンと
switch式で読みやすく書く
という流れを紹介します。
1.1 条件分岐と例外だらけのメソッドがしんどい理由
先ほどのようなサービスメソッドが増えていくと、次のようなつらさが出てきます。
(1) 正常系より「エラー処理のためのノイズ」が目立つ
本当に読みたいのは「ユーザーをどう登録するか」なのに、
- nullチェック
- if の早期
return false - 例外ごとの
catch
が行の大半を占めていて、正常系のフローが埋もれてしまいます。
(2) 分岐のパターンが頭に入りづらい
- どの
ifで終了する可能性があるか - どの例外がどこで投げられうるか
を追っていく必要があり、制御フローを頭の中でシミュレーションしながら読む必要があります。
行数以上に、読む人の認知コストが高くなります。
(3) 情報がバラけていて再利用しづらい
「このメソッドの結果」を表現している情報は、例えばこんなふうに散らばっています。
- 戻り値:
bool(成功/失敗だけ) - ログメッセージ:
_logger.LogWarning(...)などの文字列 - 例外:
SqlException/Exception
ひとつの操作の結果なのに、関数の外からは bool しか見えない状態です。
呼び出し側でエラーメッセージや種類を使いたくなっても、
- 「失敗した理由」が
falseに潰されている - 例外は catch されてログで消費されてしまっている
ため、再利用しづらく・テストもしづらいという問題が出ます。
こういった問題は、「成功/失敗」と「結果/エラー」を
ひとつの値として扱っていないことが根っこにあります。
そこで出てくるのが、Scala でよく使われる Either<L, R> のような「成功か失敗かのどちらか一方を持つ型」です。
1.1.1 同じ処理を Either で書くとどうなるか
先ほどの登録処理を、Either<AppError, User> で書き直すと、こんなイメージになります。
public Either<AppError, User> RegisterUser(UserDto dto)
{
if (dto is null)
{
return Either<AppError, User>.FromLeft(
AppError.Validation("入力がnullです。")
);
}
if (string.IsNullOrWhiteSpace(dto.Name))
{
return Either<AppError, User>.FromLeft(
AppError.Validation("名前は必須です。")
);
}
var user = new User(dto.Id, dto.Name);
try
{
if (_db.Users.Any(u => u.Id == dto.Id))
{
return Either<AppError, User>.FromLeft(
AppError.Business("同じIDのユーザーがすでに存在します。")
);
}
_db.Users.Add(user);
_db.SaveChanges();
return Either<AppError, User>.FromRight(user);
}
catch (SqlException ex)
{
return Either<AppError, User>.FromLeft(
AppError.Infrastructure("DBエラーが発生しました。", ex)
);
}
catch (Exception ex)
{
return Either<AppError, User>.FromLeft(
AppError.Unexpected(ex)
);
}
}
まだ if や try-catch は残っていますが、次の点で改善されています。
- 戻り値1つで「成功/失敗」と「結果/エラー」の両方を表現できる
- 呼び出し側は
Either<AppError, User>という型を見ただけで- バリデーションや業務ルールで落ちる可能性がある
- インフラエラーも返ってくるかもしれない
といった情報を把握できる
- ログやHTTPレスポンスへの変換は呼び出し側でまとめて書ける
例えば Web API 側では次のように扱えます。
public IActionResult Register(RegisterUserRequest request)
{
var dto = new UserDto(request.Id, request.Name);
var result = _service.RegisterUser(dto);
return result switch
{
{ IsRight: true } r =>
Ok(new { id = r.Right.Id }),
{ IsLeft: true, Left.Kind: ErrorKind.Validation } e =>
BadRequest(e.Left.Message),
{ IsLeft: true, Left.Kind: ErrorKind.Business } e =>
Conflict(e.Left.Message),
{ IsLeft: true } e =>
HandleServerSideError(e.Left),
_ => StatusCode(500, "Unexpected state.")
};
}
ここまで来ると、
- サービス側:「結果かエラーか」を
Eitherで返すことに専念 - API側:
AppErrorを見てステータスコードやレスポンスを決める
という役割分担ができ、条件分岐と例外が「どこに書いてあるのか」がはっきりするようになります。
この「結果とエラーを1つの値にまとめる」という発想を、
より小さなスコープ(null扱いや単純な失敗)に持ち込んだのが Option<T> や Either<L, R> です。
1.2 Tryメソッドパターンの限界:bool + out のつらさ
C#ではおなじみの int.TryParse に代表される「Tryメソッドパターン」があります。
if (int.TryParse(input, out var age))
{
// age が使える
}
else
{
// パース失敗
}
標準ライブラリレベルの小さい変換にはとても便利ですが、
アプリケーションロジックにこのノリを持ち込むと、やはりつらさが出てきます。
たとえば、ユーザー登録の処理を「Tryメソッド」で書くとこうなりがちです。
public bool TryRegisterUser(UserDto dto, out User registeredUser, out string errorMessage)
{
registeredUser = null!;
errorMessage = "";
if (dto == null)
{
errorMessage = "入力がnullです";
return false;
}
if (string.IsNullOrWhiteSpace(dto.Name))
{
errorMessage = "名前は必須です";
return false;
}
try
{
var user = new User(dto.Id, dto.Name);
_repository.Save(user);
registeredUser = user;
return true;
}
catch (Exception ex)
{
errorMessage = ex.Message;
return false;
}
}
このスタイルには、ちょっとずつ効いてくるつらみがいくつかあります。
(1) 本当に欲しいものが out に追い出される
- 戻り値の
boolは「成功/失敗」しか表せない - 呼び出し側が一番欲しい
Userとエラー詳細はoutに追い出される
「メインの結果」が return ではなく out にいるので、
コードを読んだときの直感とも少しズレます。
(2) 情報がバラけていて合成しづらい
「このメソッドの結果」を表している情報は、
- 成功/失敗 …
bool - 成功時の値 …
registeredUser - 失敗時の詳細 …
errorMessage
と、1つの操作の結果が3つの値に分散してしまいます。
この形式のメソッドを複数つなげようとすると、
User user;
Order order;
string error;
if (!TryCreateUser(dto, out user, out error))
{
return false;
}
if (!TryCreateOrder(user, out order, out error))
{
return false;
}
のように、if と早期 return だらけになりがちです。
(4) out 用の変数宣言で、地味にステップ数を踏む
もうひとつ地味につらいのが、「out のためだけの変数宣言」です。
User user;
string errorMessage;
if (!TryRegisterUser(dto, out user, out errorMessage))
{
// エラー処理
}
- 呼び出し側で 「使うかどうかも分からない変数」を先に宣言しないといけない
- 変数のライフタイムが広がるので、スコープも少し読みづらくなる
C# 7以降なら out var user で短く書けますが、それでも
if (!TryRegisterUser(dto, out var user, out var errorMessage))
{
// ...
}
のように、1つの操作の結果のために bool + out + out の3か所を触る必要があります。
(5)LINQ や async/await と相性がよくない
-
TryXxx + outは「ステートフルなメソッド呼び出し」に寄ってしまうので、
LINQメソッドチェーンやTaskとの相性があまりよくありません。 - 合成しづらく、「1個の大きなメソッド」にロジックがたまりがちです。
この記事で紹介する Option<T> / Either<L, R> は、
「成功/失敗 と 結果/エラー を 1つの値にまとめて返したい」
というところから出発して、
- 戻り値ひとつで結果とエラーを表す
- LINQメソッドチェーンと合わせて、
result.Map(...).Bind(...)のように自然につなげられる
ようにするための道具です。
2. Scalaのエッセンスをざっくり整理する
2.1 Option:null の代わりに「ある/ない」を型で表す
Scalaの Option[A] は「あるかもしれない A」を表す型です。
-
Some(value)… 値がある -
None… 値がない
def findUser(id: UserId): Option[User] =
// 見つかれば Some(user)、見つからなければ None
呼び出し側は、null を気にするのではなく、戻り値の型だけで「ない可能性」が分かります。
val nameOrGuest =
findUser(id)
.map(_.name) // User → String に変換
.getOrElse("ゲスト") // なければデフォルト
ここでポイントになるのが map です。
map: Option[A] × (A => B) → Option[B]- 「中身があるときだけ関数を適用し、ないときはそのまま
Noneを返す」
nullチェックを毎回書かなくても、「あるときだけ処理する」というパターンを再利用できます。
2.2 Either:成功と失敗を「どちらか一方の値」として表す
Scalaの Either[L, R] は「左か右、どちらか一方の値」を表します。
- 慣習的に
Leftをエラー、Rightを成功として使うことが多い - 例外を投げるのではなく、失敗理由を値として返したいときに便利
def parseAge(input: String): Either[String, Int] =
Try(input.toInt).toEither.left.map(_ => "年齢は数値で入力してください")
呼び出し側は「例外が飛んでこない」代わりに、Either をパターンマッチで分解します。
parseAge(input) match
case Right(age) => println(s"年齢は $age 歳です")
case Left(msg) => println(s"エラー: $msg")
2.3 パターンマッチと「式」として書くスタイル
Scalaでは match 式を使って、
「分岐しつつ、最後に値を返す」書き方がよく登場します。
val result: String =
parseAge(input) match
case Right(age) => s"年齢は $age 歳です"
case Left(msg) => s"エラー: $msg"
このスタイルは C# の
-
switch式(C# 8 以降) - 式形式メンバー(expression-bodied)
と相性がよく、C#でもかなりScalaっぽい書き味に寄せることができます。
3. C#で簡易 Option<T> を実装してみる
ここからは、Scalaの Option を真似したシンプルな Option<T> を
外部ライブラリなしで自作してみます。
3.1 Option<T> の型定義
まずは「値がある/ない」を表す型を1つ用意します。
ポイント:
-
readonly structにしてイミュータブル(不変)にする -
HasValueとValueを持たせる -
Some/Noneを静的メソッドで生成する
public readonly struct Option<T>
{
private readonly T _value;
public bool HasValue { get; }
public T Value
{
get
{
if (!HasValue)
{
throw new InvalidOperationException("Option has no value.");
}
return _value;
}
}
private Option(T value)
{
_value = value;
HasValue = true;
}
public static Option<T> Some(T value)
{
if (value is null)
{
throw new ArgumentNullException(nameof(value));
}
return new Option<T>(value);
}
public static Option<T> None()
{
return default;
}
}
3.2 Map / GetOrElse で「あるときだけ処理する」
次に、Scalaの map に相当する拡張メソッドを用意します。
public static class OptionExtensions
{
public static Option<TResult> Map<T, TResult>(
this Option<T> option,
Func<T, TResult> selector)
{
if (!option.HasValue)
{
return Option<TResult>.None();
}
return Option<TResult>.Some(selector(option.Value));
}
public static T GetOrElse<T>(this Option<T> option, T defaultValue)
{
return option.HasValue ? option.Value : defaultValue;
}
}
これを使うと、nullチェックを毎回書かずに
- 「あるときだけ変換する」
- 「なければデフォルト値で置き換える」
というコードが書けます。
Option<User> TryFindUser(UserId id)
{
var user = _repository.Find(id);
return user is null
? Option<User>.None()
: Option<User>.Some(user);
}
string GetUserNameOrGuest(UserId id)
{
return TryFindUser(id)
.Map(user => user.Name)
.GetOrElse("ゲスト");
}
3.3 LINQ(メソッドチェーン)とつなげる
C#でよく見かける
numbers.Select(item => item * 3);
のような書き方と同じ感覚で Option<T> をつなげられるように、
LINQ向けの Select / SelectMany を実装します。
public static class OptionLinqExtensions
{
// Map と同じ
public static Option<TResult> Select<T, TResult>(
this Option<T> option,
Func<T, TResult> selector)
{
return option.Map(selector);
}
// Bind(flatMap)に相当
public static Option<TResult> SelectMany<T, TResult>(
this Option<T> option,
Func<T, Option<TResult>> selector)
{
if (!option.HasValue)
{
return Option<TResult>.None();
}
return selector(option.Value);
}
}
これを使うと、Option 同士の処理を
全部メソッドチェーンで書けるようになります。
Option<string> GetUserEmail(UserId id)
{
return TryFindUser(id) // Option<User>
.SelectMany(user => user.PrimaryEmail) // Option<string> を返すプロパティ想定
.Select(email => email); // Option<string>
}
普段から IEnumerable<T> に対して
numbers
.Where(x => x > 10)
.Select(x => x * 3);
と書いているのと同じノリで、
- 「存在しないかもしれない
User」 - 「存在しないかもしれない
Email」
をつないでいけます。
(C#のクエリ式 from ... in ... select ... も内部的には
この Select / SelectMany に展開されますが、
この記事ではメソッドチェーンのほうをメインに使います)
4. C#で簡易 Either<L, R> を実装してみる
次は、成功・失敗を両方とも型で表す Either<L, R> を作ってみます。
4.1 Either<L, R> の型定義
ここでは
-
Rightを成功 -
Leftを失敗(エラー情報)
として扱います。
public readonly struct Either<L, R>
{
private readonly L _left;
private readonly R _right;
public bool IsRight { get; }
public bool IsLeft => !IsRight;
public L Left
{
get
{
if (!IsLeft)
{
throw new InvalidOperationException("Either does not contain a Left value.");
}
return _left;
}
}
public R Right
{
get
{
if (!IsRight)
{
throw new InvalidOperationException("Either does not contain a Right value.");
}
return _right;
}
}
private Either(L left)
{
_left = left;
_right = default!;
IsRight = false;
}
private Either(R right)
{
_right = right;
_left = default!;
IsRight = true;
}
public static Either<L, R> FromLeft(L value) => new Either<L, R>(value);
public static Either<L, R> FromRight(R value) => new Either<L, R>(value);
}
4.2 Map / Bind で処理をつなげる(メソッドチェーン)
Either を活かすには、「成功したときだけ処理をつなげる」メソッドが欲しくなります。
public static class EitherExtensions
{
// Right を変換する(Left はそのまま)
public static Either<L, RResult> Map<L, R, RResult>(
this Either<L, R> either,
Func<R, RResult> selector)
{
if (either.IsLeft)
{
return Either<L, RResult>.FromLeft(either.Left);
}
return Either<L, RResult>.FromRight(selector(either.Right));
}
// Right を返す処理をつなげる(flatMap / Bind)
public static Either<L, RResult> Bind<L, R, RResult>(
this Either<L, R> either,
Func<R, Either<L, RResult>> selector)
{
if (either.IsLeft)
{
return Either<L, RResult>.FromLeft(either.Left);
}
return selector(either.Right);
}
}
使う側のイメージはこんな感じです。
Either<string, int> ParseAge(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return Either<string, int>.FromLeft("年齢は必須です。");
}
if (!int.TryParse(input, out var age))
{
return Either<string, int>.FromLeft("年齢は数値で入力してください。");
}
if (age < 0 || age > 150)
{
return Either<string, int>.FromLeft("年齢は0〜150の範囲で入力してください。");
}
return Either<string, int>.FromRight(age);
}
Either<string, string> DescribeAge(string input)
{
return ParseAge(input)
.Map(age => $"年齢は {age} 歳です。");
}
Bind を使えば、「失敗したらそこで打ち切り、成功したら次へ進む」という処理を
if や try-catch なしでメソッドチェーンできます。
4.3 Left に何を入れるか問題:string / Exception / AppError
サンプルではまず Either<string, T> のように Left にメッセージ文字列を置くのが分かりやすいです。
ただ、実務を考えると
- ログ出力のために
Exceptionを持っておきたい - 「バリデーションエラー」と「DBのタイムアウト」を区別したい
といったニーズが出てきます。
そこで、Left に入れる型としては主に次の3パターンが考えられます。
-
Either<string, T>- メリット:シンプルで分かりやすい
- デメリット:エラーの種別を表現しづらい、スタックトレースが失われる
-
Either<Exception, T>- メリット:ログ出力しやすい(スタックトレース付き)、外部ライブラリの例外をそのまま運べる
-デメリット:業務エラーとバグ(プログラミングミス)の区別がつきにくい
- メリット:ログ出力しやすい(スタックトレース付き)、外部ライブラリの例外をそのまま運べる
-
Either<AppError, T>(おすすめ)- エラー情報を表す独自型を定義し、その中に必要に応じて
Exceptionを含める
- エラー情報を表す独自型を定義し、その中に必要に応じて
public enum ErrorKind
{
Validation, // 入力値などのバリデーションエラー
Business, // 業務ルール違反
Infrastructure, // DB, ネットワークなど
Unexpected // 想定外の例外
}
public sealed record AppError(
ErrorKind Kind,
string Message,
Exception? Cause = null
)
{
public static AppError Validation(string message) =>
new(ErrorKind.Validation, message);
public static AppError Business(string message) =>
new(ErrorKind.Business, message);
public static AppError Infrastructure(string message, Exception ex) =>
new(ErrorKind.Infrastructure, message, ex);
public static AppError Unexpected(Exception ex) =>
new(ErrorKind.Unexpected, ex.Message, ex);
}
これを使って Either<AppError, T> にすると、例えば次のように書けます:
Either<AppError, User> FindUser(UserId id)
{
try
{
var entity = _db.Users.Find(id.Value);
if (entity is null)
{
return Either<AppError, User>.FromLeft(
AppError.Business("ユーザーが存在しません。")
);
}
return Either<AppError, User>.FromRight(
User.FromEntity(entity)
);
}
catch (SqlException ex)
{
return Either<AppError, User>.FromLeft(
AppError.Infrastructure("ユーザー検索中にDBエラーが発生しました。", ex)
);
}
catch (Exception ex)
{
return Either<AppError, User>.FromLeft(
AppError.Unexpected(ex)
);
}
}
- UI 層・API 層から見ると
ErrorKindで分岐できる - ログを出すときは
AppError.Causeから例外を参照できる
ので、「型安全なエラー種別」と「ログ用の例外情報」の両方をいいとこ取りできます。
5. 「実はこれ全部モナドです」という話
ここまでで
Option<T>Either<L, R>-
Map/Bind(Select/SelectMany)
を実装してきました。
この「何かしらの“文脈”を持った値を、Map や Bind でつなげていく」というパターンが、
関数型界隈でいう モナド(monad) です。
「モナド」という言葉だけ聞くと身構えますが、
やっていること自体はこの記事のOption/Eitherでほぼ完走しています。
5.1 「文脈付きの値」としての Option / Either
さっき作った型を「文脈付きの値」として見てみます。
-
Option<T>- 「値があるかもしれないし、ないかもしれない」という“文脈”付きの
T - 文脈:見つからない(null)かもしれない
- 「値があるかもしれないし、ないかもしれない」という“文脈”付きの
-
Either<L, R>- 「成功して
Rが得られるかもしれないし、Lな理由で失敗するかもしれない」 - 文脈:失敗(エラー)するかもしれない
- 「成功して
普通の T ではなく、
- 「存在しないかもしれない
T」 (Option<T>) - 「エラーつきの
T」 (Either<AppError, T>など)
という「T に何らかの条件・意味づけ(=文脈)がくっついたもの」として扱う、という見方です。
5.2 モナド三点セット(C#版)
モナドの説明はいろいろありますが、実装者目線に寄せると、
ざっくり次の「三点セット」がそろってるかどうか、くらいに見るのがわかりやすいです。
ある型コンストラクタ M<T> があったときに:
-
生の値を包む関数
- C#でいうと
SomeやFromRightなど - 型的には
T -> M<T>
- C#でいうと
-
中身を取り出して次の計算につなげる関数
- C#でいうと
Bind/SelectMany - 型的には
M<T> × (T -> M<U>) -> M<U>
- C#でいうと
-
中身だけ変える関数
- C#でいうと
Map/Select - 型的には
M<T> × (T -> U) -> M<U>
- C#でいうと
この記事で作ったものに当てはめると:
// 1. 生の値を包む(Option)
public static Option<T> Some<T>(T value);
// 2. 中身を取り出して次の計算につなげる(Either.Bind)
public static Either<L, RResult> Bind<L, R, RResult>(
this Either<L, R> either,
Func<R, Either<L, RResult>> selector);
// 3. 中身だけ変える(Option.Map / Either.Map)
public static Option<TResult> Map<T, TResult>(
this Option<T> option,
Func<T, TResult> selector);
public static Either<L, RResult> Map<L, R, RResult>(
this Either<L, R> either,
Func<R, RResult> selector);
もうこの時点で、**Option も Either も「モナドっぽい動きをしている」**と言ってOKです。
5.3 LINQとモナド(SelectMany の正体:メソッドチェーン編)
普段 C# でこんなコードを書いているとします。
var result = numbers
.Where(x => x > 10)
.Select(x => x * 3);
LINQで IEnumerable<T> をメソッドチェーンしているだけですが、
これを「文脈付きの値」として見直すと、
- 文脈:複数個あるかもしれない値
-
Select:中身の値を変換する -
SelectMany:ネストした列をフラットにしつつ次へ進める
というモナドそのもののパターンになっています。
同じことを Option<T> でやると、こうなります。
Option<string> GetUserEmail(UserId id)
{
return TryFindUser(id) // Option<User>
.SelectMany(user => user.PrimaryEmail) // Option<string> を返すプロパティ想定
.Select(email => email); // Option<string>
}
- 「ユーザーが存在しないかもしれない」
- 「メールアドレスが設定されていないかもしれない」
という2つの「文脈付きの値」を、SelectMany / Select でつないでいるイメージです。
ポイントは:
- LINQのメソッドチェーン(
Select/SelectMany) = モナドのmap/bind -
IEnumerable<T>だけじゃなく、Option<T>やEither<L, T>にも同じインターフェースを生やすことで、
いつもの LINQ の書き味で「文脈付きの値」を扱えるようになる
というところです。
5.4 「モナドを知っていると何がうれしいか」
実務的なメリットに絞って書くと、次の2つです。
-
パターンに名前がつく
- 「文脈付きの値を
Map/Bindでつなげていくあの感じ」を
「モナド」と呼べるので、他の人のコードや記事が読みやすくなります。 - 例:「この型、モナドっぽく扱えるように
Bind生やしとくか」みたいな会話ができる。
- 「文脈付きの値を
-
LINQメソッドチェーンの“中身”が分かる
- 「
Select/SelectManyの意味」を意識できるので、
独自の文脈型(Option,Either,Resultなど)を LINQ 対応させやすくなります。
- 「
逆にいうと、この記事でやってきた
-
Option<T>/Either<L, R>の実装 -
Map/Bind/Select/SelectMany - LINQメソッドチェーンによる合成
を理解していれば、モナドの“実務に効く部分”はほぼ踏破していると言ってよさそうです。
6. C#の switch 式でパターンマッチ風に書いてみる
C# 8 以降の switch 式を使うと、Either や Option を
「式として」分解しながら値を返す書き方に寄せられます。
6.1 Either × switch 式
string DescribeAge(string input)
{
var result = ParseAge(input); // Either<string, int> を返す前提
return result switch
{
{ IsRight: true } => $"年齢は {result.Right} 歳です。",
{ IsLeft: true } => $"エラー: {result.Left}",
_ => "不正な状態です。"
};
}
AppError を使う場合も同様です。
string DescribeAgeWithAppError(string input)
{
var result = ParseAgeWithAppError(input); // Either<AppError, int>
return result switch
{
{ IsRight: true } r =>
$"年齢は {r.Right} 歳です。",
{ IsLeft: true, Left.Kind: ErrorKind.Validation } e =>
$"入力エラー: {e.Left.Message}",
{ IsLeft: true, Left.Kind: ErrorKind.Unexpected } e =>
$"システムエラー: {e.Left.Message}",
_ => "不正な状態です。"
};
}
6.2 Option × switch 式
string GetUserNameOrGuest(UserId id)
{
var userOpt = TryFindUser(id);
return userOpt switch
{
{ HasValue: true } => userOpt.Value.Name,
{ HasValue: false } => "ゲスト",
_ => "不正な状態です。"
};
}
7. 実務コードへの当てはめ例(DDD寄り)
7.1 リポジトリの戻り値を Option<T> にする
ドメイン層に近いところでは、「見つからない」という状況を例外にしたくないことが多いです。
public interface IUserRepository
{
Option<User> Find(UserId id);
void Save(User user);
}
アプリケーションサービス側は、存在しない可能性が型から分かるようになります。
public sealed class UserQueryService
{
private readonly IUserRepository _repository;
public UserQueryService(IUserRepository repository)
{
_repository = repository;
}
public string GetUserNameOrGuest(UserId id)
{
return _repository.Find(id)
.Map(user => user.Name)
.GetOrElse("ゲスト");
}
}
7.2 アプリケーションサービスの戻り値を Either<AppError, T> にする
登録処理など、業務的な失敗が多いアプリケーションサービスでは
Either<AppError, T> で戻すのも有効です。
public sealed class RegisterUserService
{
private readonly IUserRepository _repository;
public RegisterUserService(IUserRepository repository)
{
_repository = repository;
}
public Either<AppError, RegisterUserResult> Handle(RegisterUserCommand command)
{
// バリデーション
if (string.IsNullOrWhiteSpace(command.Name))
{
return Either<AppError, RegisterUserResult>.FromLeft(
AppError.Validation("名前は必須です。")
);
}
// 重複チェック
var existing = _repository.Find(command.UserId);
if (existing.HasValue)
{
return Either<AppError, RegisterUserResult>.FromLeft(
AppError.Business("同じIDのユーザーがすでに存在します。")
);
}
var user = new User(command.UserId, command.Name);
try
{
_repository.Save(user);
}
catch (SqlException ex)
{
return Either<AppError, RegisterUserResult>.FromLeft(
AppError.Infrastructure("ユーザー登録中にDBエラーが発生しました。", ex)
);
}
catch (Exception ex)
{
return Either<AppError, RegisterUserResult>.FromLeft(
AppError.Unexpected(ex)
);
}
return Either<AppError, RegisterUserResult>.FromRight(
new RegisterUserResult(user.Id)
);
}
}
public sealed record RegisterUserCommand(UserId UserId, string Name);
public sealed record RegisterUserResult(UserId UserId);
UI層から見ると、業務エラーとシステムエラーを見分けやすくなります。
public IActionResult Register(RegisterUserRequest request)
{
var command = new RegisterUserCommand(
new UserId(request.Id),
request.Name);
var result = _service.Handle(command);
return result switch
{
{ IsRight: true } r =>
Ok(new { id = r.Right.UserId.Value }),
{ IsLeft: true, Left.Kind: ErrorKind.Validation } e =>
BadRequest(e.Left.Message),
{ IsLeft: true, Left.Kind: ErrorKind.Business } e =>
Conflict(e.Left.Message),
{ IsLeft: true } e =>
HandleServerSideError(e.Left),
_ => StatusCode(500, "Unexpected state.")
};
}
private IActionResult HandleServerSideError(AppError error)
{
_logger.LogError(error.Cause, "Server error: {Message}", error.Message);
return StatusCode(500, "サーバーでエラーが発生しました");
}
8. 実は既にある
ここまで自作してきましたがすでにあります!
代表的なものとしては、例えば以下のようなライブラリがあります。
-
LanguageExt-
Option<T>/Either<L, R>/Try<T>など、関数型プログラミング用の型がひと通り揃っている - LINQ やパターンマッチヘルパーなども充実
-
以上、車輪の再開発でした。