第17章:エラー設計② 境界で変換する(外→内の翻訳)🔁🧱
17.1 なぜ「境界でエラーを変換」するの?😵💫
外部APIって、エラーの出し方がバラバラです。
- 返ってくる HTTPステータス が意味不明なことがある(例:本当は入力ミスなのに 500 😇)
- エラーコード が急に増える / 名前が変わる(例:
PAY_002→P002) - メッセージが英語・専門用語・内部情報てんこ盛り(UIに出せない…🫠)
- レスポンス形式が突然変わる(DTOが爆発💥)
これをそのまま内側(ドメイン)に持ち込むと、ドメインが外部仕様に支配されて“腐敗”します🧼💦 だから ACL の境界で「外のエラー」を「内側の言葉」に翻訳して、内側を守ります🛡️✨
17.2 今日のゴール 🎯
この章のゴールはこれ👇
- 外部APIのエラー形式(HTTP + JSON)を 内側のエラー型 に変換できる🧩
- 内側(ドメイン / アプリ層)は 外部のエラーコードやHTTPを知らない で済む🙅♀️
- ログとUI表示を分離して、ユーザーに安全な言葉 を返せる💬
17.3 “エラーの通り道” を整理しよう 🛣️

エラーもデータと同じで、通る道を決めるとラクです✨
- 外側(外部API):HTTPステータス + エラーDTO(外部独自)
- ACL:外部エラーを受け取り、内側エラーに翻訳(ここが主役🧱)
- アプリ層(UseCase):内側エラーを見て、処理分岐(リトライする?やめる?)
- UI/API:ユーザー向け文言にして返す(内部事情は隠す🤫)
ポイント: 「外部の形」を ACL から先に持ち込まない(DTO直通禁止と同じノリ📦🙅♀️)
17.4 内側のエラー型:まずは“最低限”でOK 🙆♀️✨
外部エラーって全部細かく表現しようとすると地獄です😇 初心者のうちは、まずこの3つだけで十分強いです💪
- 何が起きた?(種類)
- 一時的?(リトライできる?)
- 調査用の手がかり(TraceIdなど)
それに加えて「サービス名」だけは必ず持つと運用が楽です🧑💻📌
17.5 “外部のエラー” 例(決済API)📡
例えば外部がこう返すとします👇
- 400(入力ミス)
- 401/403(認証/権限)
- 429(レート制限)
- 5xx(外部側の障害)
- タイムアウト(通信が切れる/遅い)
さらに、JSONボディ(外部DTO)はこんな感じかも👇
{
"error": {
"code": "PAY_002",
"message": "card expired",
"retryAfterSeconds": 60,
"traceId": "abc-123"
}
}
でも内側には PAY_002 を持ち込みたくない!🙅♀️
内側は「期限切れ」みたいな意味だけ知りたいんです🧠✨
17.6 内側の「統合エラー」型を作る(シンプル版)🧩
ここでは “外部統合に失敗した” ことを表す IntegrationError を作ります🧱
namespace MyApp.Integration.Errors;
// 外部との通信・統合で起きるエラー(ドメインには持ち込まない想定)
public abstract record IntegrationError(
string Service,
bool IsTransient,
string? TraceId
);
public sealed record IntegrationBadRequest(
string Service,
string Reason,
string? TraceId
) : IntegrationError(Service, IsTransient: false, TraceId);
public sealed record IntegrationUnauthorized(
string Service,
string? TraceId
) : IntegrationError(Service, IsTransient: false, TraceId);
public sealed record IntegrationRateLimited(
string Service,
int RetryAfterSeconds,
string? TraceId
) : IntegrationError(Service, IsTransient: true, TraceId);
public sealed record IntegrationUnavailable(
string Service,
string Summary,
string? TraceId
) : IntegrationError(Service, IsTransient: true, TraceId);
public sealed record IntegrationUnexpected(
string Service,
string Summary,
bool IsTransient,
string? TraceId
) : IntegrationError(Service, IsTransient, TraceId);
ここが大事💡
- IsTransient があると、アプリ層で「じゃあリトライする?」が決められる🔁
- TraceId はユーザーに見せない。でもログに入れると神👼✨
17.7 外部エラーDTO → 内側エラーへ翻訳する(Translator)🧑🏫
外部のレスポンス(HTTP + 外部DTO)を受け取って、内側エラーに変換します🔁🧱
まず外部DTO(例):
namespace MyApp.Integration.Payment.ExternalDtos;
public sealed class ExternalPaymentErrorResponse
{
public ExternalPaymentError? Error { get; set; }
}
public sealed class ExternalPaymentError
{
public string? Code { get; set; }
public string? Message { get; set; }
public int? RetryAfterSeconds { get; set; }
public string? TraceId { get; set; }
}
次に Translator(翻訳係):
using System.Net;
using MyApp.Integration.Errors;
using MyApp.Integration.Payment.ExternalDtos;
namespace MyApp.Integration.Payment;
public static class PaymentErrorTranslator
{
private const string ServiceName = "PaymentApi";
public static IntegrationError FromHttp(
HttpStatusCode status,
ExternalPaymentErrorResponse? body,
string? traceIdFromHeader = null
)
{
var traceId = body?.Error?.TraceId ?? traceIdFromHeader;
return status switch
{
HttpStatusCode.BadRequest =>
new IntegrationBadRequest(ServiceName,
Reason: NormalizeBadRequestReason(body),
TraceId: traceId),
HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden =>
new IntegrationUnauthorized(ServiceName, traceId),
HttpStatusCode.TooManyRequests =>
new IntegrationRateLimited(ServiceName,
RetryAfterSeconds: body?.Error?.RetryAfterSeconds ?? 30,
TraceId: traceId),
// 5xx系は基本「外部が不安定」→ 一時的扱いにすることが多い
>= HttpStatusCode.InternalServerError =>
new IntegrationUnavailable(ServiceName,
Summary: $"Remote server error (HTTP {(int)status})",
TraceId: traceId),
_ =>
new IntegrationUnexpected(ServiceName,
Summary: $"Unexpected status (HTTP {(int)status})",
IsTransient: false,
TraceId: traceId),
};
}
private static string NormalizeBadRequestReason(ExternalPaymentErrorResponse? body)
{
// 外部の code/message を「内側の言葉」に寄せる(例)
var code = body?.Error?.Code ?? "";
return code switch
{
"PAY_002" => "CardExpired",
"PAY_003" => "CardDeclined",
_ => "InvalidRequest"
};
}
}
いい感じポイント✨
PAY_002みたいな外部コードは Translatorの中で吸収 🙆♀️- 内側には
CardExpiredみたいな 意味 だけ渡す🧠 - 未知コードは
InvalidRequestに丸める(安全第一🧯)
17.8 「通信例外」も外→内に翻訳しよう 📡🧯
外部呼び出しでは、HTTPが返らずに落ちることがあります😵 たとえば…
- DNS/接続失敗 →
HttpRequestException - タイムアウト →
TaskCanceledException(※状況によってはタイムアウト扱いになることが多い)
これも同じく Translator で内側のエラーにまとめます🧱
using MyApp.Integration.Errors;
namespace MyApp.Integration.Payment;
public static class PaymentExceptionTranslator
{
private const string ServiceName = "PaymentApi";
public static IntegrationError FromException(Exception ex, string? traceId = null)
{
return ex switch
{
TaskCanceledException =>
new IntegrationUnavailable(ServiceName, "Timeout", traceId),
HttpRequestException =>
new IntegrationUnavailable(ServiceName, "NetworkError", traceId),
_ =>
new IntegrationUnexpected(ServiceName, ex.GetType().Name, IsTransient: true, traceId)
};
}
}
ちなみに
HttpClientは「毎回 new して捨てる」とポート枯渇とかの事故が起きやすいので、再利用やIHttpClientFactoryの利用が推奨されています📌(Microsoft Learn)
17.9 “ログ用” と “ユーザー表示用” を分離する 🧾💬
ここ、超大事です!!!🔥🔥🔥
- ログ:調査したい(外部コード・外部文言・レスポンス断片・TraceId)
- ユーザー表示:安心できる言葉だけ(内部事情は出さない🤫)
ユーザー表示文言を作る(例)
using MyApp.Integration.Errors;
namespace MyApp.Presentation;
public static class UserMessageMapper
{
public static string ToUserMessage(IntegrationError err)
{
return err switch
{
IntegrationRateLimited r =>
$"アクセスが集中してるみたい🥲 {r.RetryAfterSeconds}秒くらい待って、もう一回やってみてね🙏",
IntegrationUnauthorized =>
"認証がうまくいかなかったみたい😵 いったんログインし直してみてね🔑",
IntegrationBadRequest b when b.Reason is "CardExpired" =>
"カードの有効期限が切れてるみたい🥺 別のカードを試してみてね💳",
IntegrationBadRequest =>
"入力内容を確認してみてね✍️(うまくいかない時は少し変えて試してみて🙏)",
IntegrationUnavailable =>
"いま外部サービスが混み合ってるみたい🥲 少し時間をおいて試してみてね⏳",
_ =>
"予期しない問題が起きちゃった…🥺 少し時間をおいて再度試してね🙏",
};
}
}
ログに入れる(例)
using Microsoft.Extensions.Logging;
using MyApp.Integration.Errors;
namespace MyApp.Observability;
public static class IntegrationLogger
{
public static void Log(ILogger logger, IntegrationError err, object? rawExternal = null)
{
logger.LogWarning(
"Integration error: {Service} {Type} transient={Transient} traceId={TraceId} raw={Raw}",
err.Service,
err.GetType().Name,
err.IsTransient,
err.TraceId,
rawExternal
);
}
}
17.10 ハンズオン:5つの外部失敗を「内側の言葉」に変換してみよう 🧪✨
HTTP実装は次章でやるので、この章では“入力を作って”練習します🙆♀️
using System.Net;
using MyApp.Integration.Payment;
using MyApp.Integration.Payment.ExternalDtos;
using MyApp.Presentation;
static ExternalPaymentErrorResponse Body(string code, string msg, int? retry = null, string? traceId = "t-001")
=> new()
{
Error = new ExternalPaymentError
{
Code = code,
Message = msg,
RetryAfterSeconds = retry,
TraceId = traceId
}
};
// 1) 入力ミス(カード期限切れ)
var e1 = PaymentErrorTranslator.FromHttp(HttpStatusCode.BadRequest, Body("PAY_002", "card expired"));
Console.WriteLine(UserMessageMapper.ToUserMessage(e1));
// 2) レート制限
var e2 = PaymentErrorTranslator.FromHttp(HttpStatusCode.TooManyRequests, Body("RATE", "too many", retry: 60));
Console.WriteLine(UserMessageMapper.ToUserMessage(e2));
// 3) 外部障害
var e3 = PaymentErrorTranslator.FromHttp(HttpStatusCode.ServiceUnavailable, null, traceIdFromHeader: "hdr-777");
Console.WriteLine(UserMessageMapper.ToUserMessage(e3));
// 4) タイムアウト(例外)
var e4 = PaymentExceptionTranslator.FromException(new TaskCanceledException("timeout"));
Console.WriteLine(UserMessageMapper.ToUserMessage(e4));
// 5) 未知のエラーコード(でも落ちない!)
var e5 = PaymentErrorTranslator.FromHttp(HttpStatusCode.BadRequest, Body("PAY_999", "unknown"));
Console.WriteLine(UserMessageMapper.ToUserMessage(e5));
✅ ここで確認したいこと
- 未知コードでも 例外で落ちない 😇
- ユーザー文言が 外部の英語メッセージを出してない 🤫
- TraceId など調査情報は ログ側 に寄せる🧾
17.11 ミニ課題 📝🎀
課題1:エラーを3つ追加して翻訳してみよう🧩
次のケースを追加して、IntegrationError に翻訳してみてね👇
- 404:契約違反っぽい(エンドポイント変わった?)
- 409:二重請求防止っぽい(すでに処理済み)
- 502:ゲートウェイエラー
💡ヒント:
“アプリ側がリトライして良さそうか?” を IsTransient で決めるよ🔁
課題2:ユーザー文言を「やさしい日本語」に整える💬
- 難しい単語(認証、レート制限、タイムアウト)を、もっと日常語に言い換えてみよう😊
17.12 よくある失敗あるある 😇💥
- 外部の
codeをそのまま UI に出す(ユーザーは意味わからん!) - 外部の
messageをそのまま UI に出す(英語&内部情報👻) - 内側の分岐が
if (status == 429)だらけ(HTTPが侵食してくる🫠) - 未知コードが来たら
throw(本番で死ぬ💀) → Unknown/InvalidRequest に丸める のが安全🧯
17.13 AI活用(Copilot / Codex)🤖✨
使いどころ(時短できるやつ)⚡
- 外部エラーDTOのプロパティ作成(単純作業📦)
switchのたたき台生成(分岐の骨組み🦴)- ユーザー向け文言の案を大量に出す(言い換え💬)
コピペで使えるプロンプト例 🪄
- 「この外部エラーDTOを元に、C#で安全なエラー変換 Translator を switch で作って。未知コードでも落ちないようにして」
- 「IntegrationError の派生型を増やす案を10個。IsTransient の判断も添えて」
- 「ユーザー向け文言を、やさしい日本語で3案ずつ。責めない言い方で🙏」
17.14 まとめ ✅🎁
- 外部のエラー形式(HTTP/外部コード/外部文言)は 境界(ACL)で吸収 🧱
- 内側は「意味が通じるエラー型」だけを見る🧠✨
- ログ用(調査情報)と ユーザー表示用(安全な言葉)を分ける🧾💬
HttpClientは使い方を間違えると事故りやすいので、再利用やIHttpClientFactoryを意識すると安心📌(Microsoft Learn)- 回復性(リトライ等)は次の章で育てるけど、公式に
Microsoft.Extensions.Http.Resilienceを使ったパターンも整理されてるよ🛟(Microsoft Learn)
(補足:C# 14 が最新で、.NET 10 上でサポートされています。(Microsoft Learn))