第20章:例外と戻り値の境界(エラー設計の入口)🚨🧩
この章は「エラーをどこで扱うとテストがラクになる?」を学ぶ回だよ〜😊 結論から言うと、
- 内側(ルール):なるべく「戻り値(Resultっぽい)」で伝える🧾✨
- 外側(I/O):例外が起きやすいので、境界で受け止めて“意味のある失敗”に変換する🛡️
- バグ:基本「直してね」のやつなので、無理に握らない(握るならログして落とす)💥
…って整理すると、テストが一気に安定するよ🎉
1) まずは“エラーの種類”を3つに分けよう🧠✨

エラーって全部同じに見えるけど、性格が違うんだよね😳
A. ルール違反(期待される失敗)📦✅
例:
- パスワード短すぎる
- 期限切れ
- すでに登録済み
👉 これは「よく起こる」ので、例外にするとテストも実行もややこしい。 Result(成功/失敗)で返すのが相性良い🙆♀️✨
B. I/O失敗(外の世界が壊れた)🌍💥
例:
- DB落ちた
- ファイル読めない
- 外部APIタイムアウト
👉 これは 外側の都合。.NETは例外が飛びやすい世界だよね。 ただし「そのまま例外ドーン」だと、アプリ内側が汚れるので、境界で変換がコツ🧩
※例外の扱い方のベストプラクティスはMS公式にもまとまってるよ。(Microsoft Learn)
C. バグ(想定外)🐛🔥
例:
- null参照
- index範囲外
- 絶対起きないはずの分岐に来た
👉 これは「握っても直らない」タイプ。 ログ出して落とすか、上位で共通ハンドリング(例:画面にはごめんね表示)に寄せるのが多いよ〜。
2) 「どこまで投げる?どこで握る?」の基本ルール📍✨
ここが今日のメイン!🎯
ルール①:内側(ルール層)は、I/O例外を知らない🙅♀️
内側は純粋ロジック寄りに保ちたいから、
IOExceptionとかHttpRequestExceptionとか そういう「外側の事情」を持ち込まない🌱
ルール②:外側(I/O境界)で、例外→意味のある失敗に翻訳する🈂️✨

たとえば「DB接続失敗」が起きたら、内側にはこう言わせたい:
- 「今は登録できない(再試行してね)」
- 「一時的にサービス利用不可」
っていう アプリにとっての意味に変換して返す🧩
ルール③:よく起きる失敗を例外で流さない⚠️
MS公式でも「よくある条件は例外を避ける」方向が推奨だよ。(Microsoft Learn) (例外を投げるのはコストが高い&読みづらくなることがある💦)
3) Result型っぽい考え方(チラ見せ👀✨)
C#/.NETに標準の Result<T> は(基本)用意されてないので、最小自作が理解しやすいよ😊
(「TryParse」系が“戻り値で失敗を表す文化”の代表だよね🧠)
ここでは 超ミニResult を作ってみよ〜🎀
public readonly record struct Result<T>(bool IsSuccess, T? Value, Error? Error)
{
public static Result<T> Ok(T value) => new(true, value, null);
public static Result<T> Fail(Error error) => new(false, default, error);
}
public abstract record Error(string Code, string Message);
public sealed record DomainError(string Code, string Message) : Error(Code, Message);
public sealed record InfrastructureError(string Code, string Message) : Error(Code, Message);
ポイント💡
- DomainError:ルール違反(入力ミス・条件未達)
- InfrastructureError:I/O失敗(DB/ネット/ファイル)
この2種類を分けるだけで「どこで処理する?」が急に見える👓✨
4) 具体例:ユーザー登録(ルール違反 vs DB障害)🧑💻🗄️
4-1) 内側:ルールは Result で返す📦🧾
public static class PasswordRules
{
public static Result<string> Validate(string password)
{
if (string.IsNullOrWhiteSpace(password))
return Result<string>.Fail(new DomainError("PW_EMPTY", "パスワードが空だよ😵"));
if (password.Length < 8)
return Result<string>.Fail(new DomainError("PW_SHORT", "8文字以上にしてね🙏"));
return Result<string>.Ok(password);
}
}
✅ これ、爆速で単体テストできるやつ!⚡
4-2) 境界:I/Oは例外が飛ぶので、ここで翻訳する🌍➡️🧩
リポジトリは「インターフェース」で包んでたよね(第17章のノリ)🗄️✨
public interface IUserRepository
{
// 既に存在したらtrue
Task<bool> ExistsByEmailAsync(string email, CancellationToken ct);
// 保存してUserIdを返す
Task<Guid> SaveAsync(string email, string password, CancellationToken ct);
}
ユースケース(アプリ内側寄り)は「I/O失敗をドメインに混ぜない」方針で書くよ😊
public sealed class RegisterUserUseCase
{
private readonly IUserRepository _repo;
public RegisterUserUseCase(IUserRepository repo) => _repo = repo;
public async Task<Result<Guid>> ExecuteAsync(string email, string password, CancellationToken ct)
{
// ルール違反(期待される失敗)
var pw = PasswordRules.Validate(password);
if (!pw.IsSuccess) return Result<Guid>.Fail(pw.Error!);
try
{
// I/O(外の世界)
if (await _repo.ExistsByEmailAsync(email, ct))
return Result<Guid>.Fail(new DomainError("EMAIL_EXISTS", "そのメールは既に登録済みだよ📮💦"));
var id = await _repo.SaveAsync(email, password, ct);
return Result<Guid>.Ok(id);
}
catch (OperationCanceledException)
{
// キャンセルは“エラー”というより「やめた」なので、そのまま投げ直す派も多いよ👌
throw;
}
catch (Exception ex) when (IsInfrastructureException(ex))
{
// ここが“境界の翻訳”✨
return Result<Guid>.Fail(new InfrastructureError(
"INFRA_TEMP",
"今は登録できないみたい…少し待ってもう一回やってみてね🙏"
));
}
}
private static bool IsInfrastructureException(Exception ex)
=> ex is TimeoutException
or System.IO.IOException;
// DBやHTTPの例外型は利用技術で増えるので、最初は大雑把でOK😊
}
ここで使ってる 例外フィルター(when) は、条件で catch を分岐できる便利機能だよ。(Microsoft Learn) (必要な例外だけ拾って、それ以外は上に任せられるのがキレイ✨)
5) テストの書き方:揺れない!速い!🧪⚡
5-1) ルール違反は超かんたん(純粋ロジック)🌿
using Xunit;
public class PasswordRulesTests
{
[Fact]
public void ShortPassword_ReturnsDomainError()
{
var r = PasswordRules.Validate("123");
Assert.False(r.IsSuccess);
Assert.Equal("PW_SHORT", r.Error!.Code);
}
}
5-2) I/O失敗は「例外を投げるFake」で再現する🎭💥
using Xunit;
using System.Threading;
using System.Threading.Tasks;
public class RegisterUserUseCaseTests
{
private sealed class ThrowingRepo : IUserRepository
{
public Task<bool> ExistsByEmailAsync(string email, CancellationToken ct)
=> throw new System.IO.IOException("disk/network broken");
public Task<Guid> SaveAsync(string email, string password, CancellationToken ct)
=> Task.FromResult(Guid.NewGuid());
}
[Fact]
public async Task InfraFailure_ReturnsInfrastructureError()
{
var useCase = new RegisterUserUseCase(new ThrowingRepo());
var r = await useCase.ExecuteAsync("a@b.com", "password123", CancellationToken.None);
Assert.False(r.IsSuccess);
Assert.Equal("INFRA_TEMP", r.Error!.Code);
}
}
✅ これで「DBが落ちた時の挙動」も、実DB無しで再現できる🎉
6) よくある落とし穴ベスト3⚠️😵💫
落とし穴①:例外で分岐してしまう(例外=if代わり)🙅♀️
「ログイン失敗=例外」とかやると、テストもしんどいし読みづらい💦 MS公式も「よくある条件は例外を避ける」方向でまとめてるよ。(Microsoft Learn)
落とし穴②:throw ex; で投げ直す😱
スタックトレースが壊れて泣くやつ…
投げ直すなら基本 throw; が安全(“捕まえた例外そのまま再スロー”)
※この系の注意も例外ベストプラクティスで触れられてるよ。(Microsoft Learn)
落とし穴③:何でも catch (Exception) で握って成功扱いにする🤝💣
それ“静かに壊れる”ので超危険… 握るなら「失敗として返す」か「ログして落とす」かを決めよ〜。
7) ミニ演習(手を動かすと一気に腹落ちするよ🧁✨)
演習A:DomainErrorを増やす🍓
- メールが空なら
EMAIL_EMPTY @が無ければEMAIL_INVALIDみたいにして、戻り値で返してみよう😊
演習B:InfrastructureErrorのメッセージを状況別に🧊
- Timeoutなら「混み合ってるかも」
- IOExceptionなら「保存に失敗しちゃった」 みたいに、翻訳を作ってみよう🧩✨
演習C:UI側での表示(握る場所を外へ)🖥️
- UseCaseは Result を返すだけ
- UIは Result を見て表示を変えるだけ にして「UIが薄くなる」感覚をつかもう🎯
8) AI(Copilot/Codex)活用プロンプト例🤖💡
コピペで使ってOKだよ〜🧠✨
- 「このメソッドの失敗を DomainError と InfrastructureError に分類して Result で返したい。候補のErrorコード案を出して」
- 「この try/catch を 境界での翻訳になるように整理して。例外フィルター(when)も使っていい」
- 「xUnitで、I/O失敗(例外発生)を Fakeで再現するテストを書いて」
- 「この処理、例外で分岐してない?してたら戻り値設計に寄せる案を出して」
9) 今日のまとめ🎀✨
- ルール違反(期待される失敗):Resultで返すのが気持ちいい🧾
- I/O失敗(外の世界):境界で例外を受けて、意味のある失敗に翻訳🧩
- バグ:握らず直す(握るならログ+共通ハンドリング)🐛🔥
- 例外の扱いはMS公式のベストプラクティスも参考になるよ。(Microsoft Learn)
次の第21章は「分解の練習」だよ〜✂️🧱 “判断(if)”と“I/O”をもっときれいに分けられるようになるから、ここで学んだエラー整理がめっちゃ効いてくるよ💖