第18章:実装ハンズオン① 外部API統合(HttpClient + デバッグ)🌐🪲
この章でできるようになること 🎯✨
- 外部APIを 安全に呼ぶ(成功/失敗/タイムアウトに強くする)💪
HttpClientを 正しい置き場所(DI+Factory)で使えるようになる 🔌- 失敗時に どこを見れば原因にたどり着けるか がわかる 👀🧭
- 「外部のクセ」を内側へ持ち込まない(ACLの“境界感覚”)が身につく 🧱✨
まず大事な前提:HttpClient の“正しい持ち方” 🧠💡
HttpClient を 毎回 new して捨てるのは、地味に事故りやすいです(接続枯渇やDNS更新の問題など)😵💫
定番は次のどちらか👇
- ✅
IHttpClientFactoryを使う(おすすめ):DI・ログ・設定がきれいにまとまる (Microsoft Learn) - ✅ Singleton
HttpClient+PooledConnectionLifetime調整:ガイドラインで推奨されるやり方もある (Microsoft Learn)
この章では **IHttpClientFactory(Typed client)**で進めるよ〜!🫶 (Microsoft Learn)
18-1. 今日の題材:クセあり決済API(モック)💳🧪
外部APIって、だいたいこういう“クセ”あるよね…って要素を入れるよ😇
snake_caseで返ってくる 🐍- 金額が cents(1/100通貨)で来る 💰
- たまに遅い(タイムアウトさせたい)🐢
- エラーが独自フォーマットで返る 🧯
18-2. ハンズオン:モック外部APIを作る(最小の別プロジェクト)🛠️🌐
① プロジェクトを1つ追加する ➕
- ソリューションに
PaymentApiMock(ASP.NET Core Minimal API)を追加 - 1つだけエンドポイントを生やす:
POST /payments
② 最小モックAPI(成功/失敗/タイムアウトを切り替え)🧪
// PaymentApiMock/Program.cs
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/payments", async (CreatePaymentRequest req, string? mode) =>
{
// mode=timeout でわざと遅延(タイムアウト用)
if (string.Equals(mode, "timeout", StringComparison.OrdinalIgnoreCase))
{
await Task.Delay(TimeSpan.FromSeconds(10));
}
// mode=fail でわざと失敗(400)
if (string.Equals(mode, "fail", StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest(new ApiErrorResponse
{
error_code = "CARD_DECLINED",
message = "カードが拒否されました",
});
}
// 通常は成功(200)
return Results.Ok(new PaymentResponse
{
payment_id = Guid.NewGuid().ToString("N"),
status = "captured",
amount_cents = req.amount_cents,
currency = req.currency,
processed_at_utc = DateTimeOffset.UtcNow.ToString("O"),
});
});
app.Run();
public sealed class CreatePaymentRequest
{
public int amount_cents { get; init; }
public string currency { get; init; } = "JPY";
public string token { get; init; } = ""; // 本物のカード番号は禁止だよ🧯
}
public sealed class PaymentResponse
{
public string payment_id { get; init; } = "";
public string status { get; init; } = "";
public int amount_cents { get; init; }
public string currency { get; init; } = "";
public string processed_at_utc { get; init; } = "";
}
public sealed class ApiErrorResponse
{
public string error_code { get; init; } = "";
public string message { get; init; } = "";
}
ここでは
snake_caseのままにして「外部DTOは外側に隔離する」練習にするよ📦✨
18-3. クライアント側:外部DTO(外側)+ Translator(翻訳)+ Gateway(窓口)🧱🔁
ここからがACLっぽい動き! 「外部APIを叩く役」と「内側の型へ変換する役」を分けるよ😊
① 外部DTO(Infrastructure側)📦
// Infrastructure/Payments/ExternalDtos.cs
public sealed class PaymentApiCreateRequestDto
{
public int amount_cents { get; init; }
public string currency { get; init; } = "JPY";
public string token { get; init; } = "";
}
public sealed class PaymentApiSuccessResponseDto
{
public string payment_id { get; init; } = "";
public string status { get; init; } = "";
public int amount_cents { get; init; }
public string currency { get; init; } = "";
public string processed_at_utc { get; init; } = "";
}
public sealed class PaymentApiErrorResponseDto
{
public string error_code { get; init; } = "";
public string message { get; init; } = "";
}
② Typed client(HTTP呼び出し担当)📞🌐
ポイント👇
HttpClientは DI から受け取る(IHttpClientFactory経由) (Microsoft Learn)- JSONは
System.Net.Http.Jsonの拡張メソッドが便利 🧁 (Microsoft Learn) - “1回だけ短いタイムアウト” を個別に設定したいときは
CancellationTokenSourceを使える ⏳ (Microsoft Learn)
// Infrastructure/Payments/PaymentApiClient.cs
using System.Net;
using System.Net.Http.Json;
public sealed class PaymentApiClient
{
private readonly HttpClient _http;
public PaymentApiClient(HttpClient http)
{
_http = http;
}
public async Task<PaymentApiCallResult> CreatePaymentAsync(
PaymentApiCreateRequestDto dto,
string? mode,
CancellationToken ct)
{
// リクエスト単位で “短いタイムアウト” を掛けたい例(3秒)
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(3));
var url = "/payments" + (mode is null ? "" : $"?mode={mode}");
HttpResponseMessage res;
try
{
res = await _http.PostAsJsonAsync(url, dto, cts.Token);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
// だいたい「タイムアウト」扱い(ユーザーキャンセルとは分ける)⏳🧯
return PaymentApiCallResult.Timeout();
}
catch (HttpRequestException ex)
{
return PaymentApiCallResult.NetworkError(ex.Message);
}
if (res.IsSuccessStatusCode)
{
var ok = await res.Content.ReadFromJsonAsync<PaymentApiSuccessResponseDto>(cancellationToken: ct);
return PaymentApiCallResult.Success(ok!);
}
if (res.StatusCode == HttpStatusCode.BadRequest)
{
var err = await res.Content.ReadFromJsonAsync<PaymentApiErrorResponseDto>(cancellationToken: ct);
return PaymentApiCallResult.Failure((int)res.StatusCode, err);
}
// それ以外(500など)
return PaymentApiCallResult.Failure((int)res.StatusCode, null);
}
}
public sealed record PaymentApiCallResult(
bool IsSuccess,
bool IsTimeout,
bool IsNetworkError,
int? StatusCode,
PaymentApiSuccessResponseDto? SuccessBody,
PaymentApiErrorResponseDto? ErrorBody,
string? NetworkErrorMessage)
{
public static PaymentApiCallResult Success(PaymentApiSuccessResponseDto body)
=> new(true, false, false, 200, body, null, null);
public static PaymentApiCallResult Failure(int statusCode, PaymentApiErrorResponseDto? body)
=> new(false, false, false, statusCode, null, body, null);
public static PaymentApiCallResult Timeout()
=> new(false, true, false, null, null, null, null);
public static PaymentApiCallResult NetworkError(string message)
=> new(false, false, true, null, null, null, message);
}
③ Translator(外部→内側の翻訳)🔁🧱
ここで「cents→円」「status→内側enum」みたいな“意味変換”を入れるよ💡
// Infrastructure/Payments/PaymentTranslator.cs
public static class PaymentTranslator
{
public static PaymentResult TranslateSuccess(PaymentApiSuccessResponseDto dto)
{
// cents → 円(この章は例として “1円=100cents” の世界にするね)
var amountYen = dto.amount_cents / 100;
return PaymentResult.Success(
paymentId: dto.payment_id,
amountYen: amountYen,
status: dto.status,
processedAtUtc: dto.processed_at_utc);
}
public static PaymentError TranslateError(PaymentApiErrorResponseDto? err, int? statusCode)
{
// 外部の error_code を “内側のエラー” に寄せる(第16〜17章の続き)
if (err is null)
return new PaymentError("EXTERNAL_UNKNOWN", $"外部エラー(HTTP {statusCode})");
return err.error_code switch
{
"CARD_DECLINED" => new PaymentError("PAYMENT_DENIED", err.message),
_ => new PaymentError("EXTERNAL_UNKNOWN", err.message),
};
}
}
public sealed record PaymentResult(bool Ok, string? PaymentId, int? AmountYen, string? Status, string? ProcessedAtUtc, PaymentError? Error)
{
public static PaymentResult Success(string paymentId, int amountYen, string status, string processedAtUtc)
=> new(true, paymentId, amountYen, status, processedAtUtc, null);
public static PaymentResult Fail(PaymentError error)
=> new(false, null, null, null, null, error);
}
public sealed record PaymentError(string Code, string Message);
18-4. DI登録:AddHttpClient で設定をまとめる 🔧🧩
① BaseAddress と基本タイムアウト ⏱️
// App/Program.cs など
builder.Services.AddHttpClient<PaymentApiClient>(http =>
{
http.BaseAddress = new Uri("https://localhost:5001"); // PaymentApiMock のURLに合わせる
http.Timeout = TimeSpan.FromSeconds(15); // 全体の上限(個別の方が短ければそっちが勝つ)
});
HttpClient.Timeout は「クライアント全体のタイムアウト」で、リクエスト単位の CancellationTokenSource と併用すると 短い方が適用されるよ⏳ (Microsoft Learn)
② DNS更新などのために Handler の寿命を整える(豆知識)🫘
ガイドライン的には PooledConnectionLifetime を意識すると良い場面があるよ〜🧠 (Microsoft Learn)
(ただ、まずは Factory でOK!慣れてきたらで大丈夫😊)
18-5. 失敗に強くする:標準レジリエンス(リトライ等)🛡️🔁


「たまに落ちる」「たまに遅い」は日常茶飯事〜😇
.NET には **標準レジリエンス(Resilience)**のガイドがあって、AddStandardResilienceHandler が用意されてるよ✨ (Microsoft Learn)
// 追加で NuGet: Microsoft.Extensions.Http.Resilience
builder.Services.AddHttpClient<PaymentApiClient>(http =>
{
http.BaseAddress = new Uri("https://localhost:5001");
})
.AddStandardResilienceHandler(); // まずは標準でOK!
リトライは「二重決済」みたいな事故につながることもあるから、決済系は特に慎重にね💳🧯 (この教材ではモックだから安心!)
18-6. ログを出す:HttpClientFactoryの“標準ログ”が強い 🪵👀
IHttpClientFactory で作ったクライアントは、HTTPのログカテゴリが分かれていて見やすいよ✨
例:System.Net.Http.HttpClient.MyNamedClient.LogicalHandler みたいなカテゴリで出る 📚 (Microsoft Learn)
appsettings.json(例)🧾
{
"Logging": {
"LogLevel": {
"Default": "Information",
"System.Net.Http.HttpClient": "Information",
"System.Net.Http.HttpClient.PaymentApiClient.LogicalHandler": "Information",
"System.Net.Http.HttpClient.PaymentApiClient.ClientHandler": "Information"
}
}
}
ヘッダーまで出すと便利だけど、機密(token等)を出さないよう注意ね🧯🔒
18-7. Visual Studio デバッグ:ここを見ると一気に楽になる 🔍🪲
ブレークポイントおすすめ位置 📍
PaymentApiClient.CreatePaymentAsyncのPostAsJsonAsyncの直前res.IsSuccessStatusCodeの分岐PaymentTranslator.TranslateSuccess / TranslateError
ウォッチおすすめ 👀
urlres.StatusCodeawait res.Content.ReadAsStringAsync()(※デバッグ中だけでOK)cts.IsCancellationRequested(タイムアウト判定の確認)⏳
例外で止めたい(超大事)🧨
Visual Studio の Exception Settings で
HttpRequestExceptionTaskCanceledExceptionにチェックを入れると、「投げられた瞬間」に止まって追いやすいよ〜😌✨
18-8. 3パターン実験:成功/失敗/タイムアウトを“目で見る” 👀✅⏳
① 成功(200)🎉
modeなしで呼ぶ- 期待:
PaymentResult.Success(...)
② 失敗(400)🧯
mode=failで呼ぶ- 期待:外部
CARD_DECLINED→ 内側PAYMENT_DENIEDに翻訳される
③ タイムアウト(TaskCanceled)🐢⏳
mode=timeoutで呼ぶ- 期待:
PaymentApiCallResult.Timeout()に入る - さらに:ログとブレークで「どこで止まったか」確認!
18-9. ミニ課題(提出物)📝✨
課題A:デバッグスクショ3枚 📸
- 成功時の
StatusCode=200 - 失敗時の
StatusCode=400とerror_code - タイムアウト時に
TaskCanceledExceptionが起きた瞬間
課題B:Translatorの改善(1つでOK)🔁
次のどれかをやってみてね👇
statusを内側の enum に変換するprocessed_at_utcをDateTimeOffsetに変換する- 未知の
error_codeをEXTERNAL_UNKNOWNに寄せる(ログも残す)
18-10. AI活用(時短だけど、判断は人間🧠✨)🤖💨
使いどころおすすめ
- サンプルJSONから DTO クラスを作る 📦
AddHttpClientの登録コードの雛形を作る 🔧- 「成功/失敗/タイムアウト」のテストケース案を出す ✅
AIに投げると便利な指示(例)💬
- 「このJSONからC# DTOを
snake_caseのまま生成して」 - 「HttpClientFactoryのTyped clientの雛形を作って。例外/タイムアウトも分けたい」
- 「
CARD_DECLINEDを内側のPAYMENT_DENIEDに翻訳する設計案を3つ」
18-11. 章末チェックリスト ✅🧼
-
HttpClientをnew連打してない(DIで受け取ってる) (Microsoft Learn) - 成功/失敗/タイムアウトで分岐できた
- 外部DTOの形(snake_case)が内側に漏れてない
- エラーを“内側の言葉”に翻訳できた(第16〜17章の続き)
- HttpClientのログカテゴリを見て原因追跡できた (Microsoft Learn)
おまけ:今日の「最新」メモ 🆕📌
.NET 9 は 2026年1月13日時点で最新の更新が提供されているよ(servicing update)🧰 (support.microsoft.com)