C#でWeb APIのJSONをクラスにデシリアライズして返すベストプラクティス|HttpClientとDI/IHttpClientFactory徹底解説

外部の Web API から JSON を取得して C# のクラスにデシリアライズし、呼び出し側から安全かつシンプルに扱いたい――というのは、.NET アプリ開発で非常に頻出のテーマです。本記事では、ありがちな HttpClient の使い方の落とし穴から、DI・IHttpClientFactory を使ったベストプラクティス、呼び出し側のコード例まで「実務でそのまま使える形」で整理します。

目次

Web API の JSON をクラスにデシリアライズして返したい問題の全体像

今回のテーマをもう少し具体的に整理すると、次のような状況です。

  • HttpClient で外部 Web API にアクセスして JSON を取得したい
  • 取得した JSON を Contact.Root クラスにデシリアライズして返却したい
  • メソッド内部でのデシリアライズは動作するが、呼び出し側でどう書けばよいか迷う
  • ついでに HttpClient のベストプラクティスやクラス設計も整理したい

ありがちな「とりあえず動く」コードは次のようなイメージです。


public async Task<Contact.Root> GetContactDataAsync()
{
    using var client = new HttpClient();
    var json = await client.GetStringAsync("https://api.example.com/contacts/123");
    var result = JsonSerializer.Deserialize<Contact.Root>(json);
    return result!;
}

確かに動きますが、実務レベルでは以下のような問題があります。

  • HttpClient を毎回 new していて非効率・危険
  • 手動で JSON 文字列を扱っており、コードが冗長・例外処理も抜けがち
  • 非同期メソッドの呼び出し側の書き方が分かりにくい
  • クラス名や責務の分割が曖昧で、メンテナンスが難しくなりやすい

まずはこれらの課題を整理し、どのように改善していくかを俯瞰してみましょう。

よくある実装の課題と改善方針

課題解決策・ポイント
HttpClient を毎回 new している
ソケット枯渇・パフォーマンス劣化の原因
アプリ全体で 1 インスタンスを共有するのが基本 推奨: DI + IHttpClientFactory で管理する 簡易対応: static readonly HttpClient を使い回す
JSON を文字列で取得して手作業でデシリアライズSystem.Net.Http.Json の GetFromJsonAsync<T> を使用する 1 行で「HTTP 要求 + デシリアライズ」まで完了する JsonSerializerOptions は 1 回だけ作成し、再利用する
クラス名・メソッド名の責務が曖昧「何をするサービスか」が分かる名詞系にする(例: ContactService) Web API 呼び出し専用のサービスクラスに責務を集約する
非同期メソッドの呼び出し方が分からない呼び出し元も async にして await で結果を受け取る 同期メソッドから直接 async を呼ばないように構成する
環境ごとに API ベース URL を切り替えたいHttpClient.BaseAddress を設定ファイルから注入する 開発/検証/本番など、環境ごとに差し替えやすい設計にする

以降では、これらの課題を一つずつ解消しながら、最終的に「呼び出し側は 1 行で済む」形まで仕上げていきます。

JSON を受け取るクラス設計:Contact.Root を整える

まず、Web API 側から返ってくる JSON の形を明確にしましょう。たとえば以下のような JSON を返す API を想定します。


{
  "id": 123,
  "name": "山田太郎",
  "email": "[email protected]",
  "phone": "090-xxxx-xxxx"
}

この JSON に対応する C# クラスは次のように設計できます。


public class Contact
{
    public class Root
    {
        public int Id { get; set; }
        public string? Name { get; set; }
        public string? Email { get; set; }
        public string? Phone { get; set; }
    }
}

ポイントは次の通りです。

  • JSON のキー名と C# プロパティ名を揃えておくと、設定なしでもデシリアライズが通りやすい
  • null の可能性がある項目は string? としておく(.NET 6+ の nullable 対応)
  • ネスト構造が複雑なら Root の中にさらにクラスを定義していく

つぎに、この Contact.Root を返す Web API 呼び出しクラスを作っていきます。

HttpClient を正しく使い回す設計

HttpClient の使い方を誤ると、ソケット枯渇や DNS キャッシュの問題など、現場では厄介な不具合に繋がります。代表的なパターンを比較してみましょう。

パターン説明メリットデメリット
毎回 new HttpClient()メソッドごとに using var client = new HttpClient();コードが短く直感的接続が大量に生成・破棄されソケット枯渇の原因になる パフォーマンスが悪い
static readonly HttpClientアプリ全体で 1 つのインスタンスを使い回すソケット枯渇を防げる 実装がシンプルDNS 変更を拾いにくい 設定を変えたい場合に拡張性が低い
IHttpClientFactory + DIASP.NET Core 標準の HttpClient 管理機構を利用接続プールが自動管理される リトライ・タイムアウトなどのポリシーを集中管理できる テスト・モックがしやすいDI コンテナ前提の構成が必要

Web アプリや API サーバのように DI コンテナを使える環境では、基本的に IHttpClientFactory を使うのがおすすめです。コンソールアプリなど最小構成の場合は、まず static HttpClient で使い回し、必要になったら DI を導入する、というステップでも良いでしょう。

GetFromJsonAsync<T> で「取得+デシリアライズ」を 1 行にする

.NET 5 以降では、System.Net.Http.Json 名前空間に便利な拡張メソッドが用意されています。

  • GetFromJsonAsync<T>
  • ReadFromJsonAsync<T>

これを使うと、次の 2 ステップを 1 行で書けます。

  1. HTTP GET で JSON を取得する
  2. JSON を Contact.Root にデシリアライズする

using System.Net.Http.Json;

Contact.Root? contact =
    await httpClient.GetFromJsonAsync&lt;Contact.Root&gt;("contacts/123");

さらに、JsonSerializerOptions を一度だけ作成し、再利用するのがベストです。


private static readonly JsonSerializerOptions _jsonOptions =
    new(JsonSerializerDefaults.Web)
    {
        // 必要に応じてカスタマイズ
        // PropertyNameCaseInsensitive = true,
    };

JsonSerializerDefaults.Web を使うことで、Web API の一般的なスタイル(camelCase)に合わせた設定が一括で適用されます。

改善版 ContactService クラスの実装

ここまでの内容を踏まえて、Web API 呼び出しを担当する ContactService クラスを設計します。


using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;

public class ContactService
{
    private readonly HttpClient _client;

    // JSON の設定は 1 回だけ作成して使い回す
    private static readonly JsonSerializerOptions _options =
        new(JsonSerializerDefaults.Web);

    // HttpClient は DI などから注入する
    public ContactService(HttpClient client)
    {
        _client = client;
    }

    /// &lt;summary&gt;
    /// Web API から Contact データを取得し、Contact.Root として返す
    /// &lt;/summary&gt;
    public async Task&lt;Contact.Root&gt; GetContactDataAsync(
        int id,
        CancellationToken token = default)
    {
        // 例: BaseAddress = https://api.example.com/
        string requestUri = $"contacts/{id}";

        // HTTP + JSON デシリアライズを 1 行で
        Contact.Root? result =
            await _client.GetFromJsonAsync&lt;Contact.Root&gt;(
                requestUri,
                _options,
                token
            ).ConfigureAwait(false);

        if (result is null)
        {
            // API 仕様に応じて例外を投げるか、null 許容にするかを決める
            throw new InvalidOperationException(
                $"API から期待したデータを取得できませんでした。ID = {id}");
        }

        return result;
    }
}

このクラスのポイントを整理します。

  • HttpClient はコンストラクタで受け取り、メソッド内では new しない
  • メソッドの責務は「API を叩いて Contact.Root を返すこと」だけに絞る
  • ConfigureAwait(false) を使い、ライブラリ側でコンテキストの復元をしない
  • 何らかの理由でデシリアライズが null になった場合は例外として検知しやすくする

最小構成での呼び出し例(DI を使わない場合)

まずは DI や IHttpClientFactory を使わない、最小構成の例を見てみます。


using System;
using System.Net.Http;
using System.Threading.Tasks;

public class Program
{
    // アプリ全体で共有する HttpClient
    private static readonly HttpClient sharedClient = new()
    {
        BaseAddress = new Uri("https://api.example.com/")
    };

    public static async Task Main()
    {
        var service = new ContactService(sharedClient);

        Contact.Root contact = await service.GetContactDataAsync(123);

        Console.WriteLine($"ID   : {contact.Id}");
        Console.WriteLine($"Name : {contact.Name}");
        Console.WriteLine($"Mail : {contact.Email}");
        Console.WriteLine($"Phone: {contact.Phone}");
    }
}

呼び出し側のコードはたったこれだけです。

  • HttpClient は static readonly で 1 つだけ生成
  • ContactService に渡して使うだけ
  • 利用側は await service.GetContactDataAsync(123); するだけで、デシリアライズ済みの Contact.Root が手に入る

ここまででも、当初の「メソッド本体でのデシリアライズは動くが、呼び出し側が分からない」という問題はほぼ解決しています。

ASP.NET Core での DI + IHttpClientFactory 利用例

実務では ASP.NET Core など DI が標準な環境で使うケースが多いでしょう。この場合、IHttpClientFactory を使うことで、より安全で柔軟な構成が取れます。

サービス登録(Program.cs)


var builder = WebApplication.CreateBuilder(args);

// ContactService 用の HttpClient(Typed Client)を登録
builder.Services.AddHttpClient&lt;ContactService&gt;(c =&gt;
{
    c.BaseAddress = new Uri("https://api.example.com/");
    c.DefaultRequestHeaders.Accept.Add(
        new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
    // タイムアウトなどもここで設定可能
    // c.Timeout = TimeSpan.FromSeconds(10);
});

var app = builder.Build();

この設定によって、ASP.NET Core の DI から ContactService を解決すると、自動的に適切に構成された HttpClient が注入されます。

コントローラからの呼び出し例


[ApiController]
[Route("api/[controller]")]
public class ContactsController : ControllerBase
{
    private readonly ContactService _contactService;

    public ContactsController(ContactService contactService)
    {
        _contactService = contactService;
    }

    [HttpGet("{id:int}")]
    public async Task&lt;ActionResult&lt;Contact.Root&gt;&gt; Get(int id, CancellationToken token)
    {
        try
        {
            var contact = await _contactService.GetContactDataAsync(id, token);
            return Ok(contact);
        }
        catch (HttpRequestException ex)
        {
            // ネットワークエラーなど
            return StatusCode(StatusCodes.Status503ServiceUnavailable, ex.Message);
        }
        catch (InvalidOperationException ex)
        {
            // デシリアライズ失敗など
            return BadRequest(ex.Message);
        }
    }
}

コントローラ側からすると、普通のサービスクラスを呼び出す感覚で Web API クライアントを利用できる点が最大のメリットです。

ConfigureAwait(false) の意味と使いどころ

ConfigureAwait(false) は、ライブラリコードを書くときにはほぼ「お約束」のように登場するメソッドです。

  • 目的: await 後に「元のコンテキスト(UI スレッドなど)」に戻らないようにする
  • コンソールアプリや ASP.NET Core など、コンテキストを意識しないアプリと相性が良い
  • 余計なコンテキスト切り替えを防ぎ、パフォーマンスとデッドロック回避に役立つ

逆に、WPF / WinForms など UI スレッドが重要なアプリでは、UI レイヤー側(画面コード)では ConfigureAwait(false) を付けずに await し、ライブラリ層(今回の ContactService など)では ConfigureAwait(false) を付ける、という住み分けにすると分かりやすくなります。

環境ごとに API ベース URL を切り替える設計

開発・ステージング・本番などで API の URL が違う、というのはよくある話です。そのたびにコードを書き換えるのではなく、設定から注入できるようにしておきましょう。

設定ファイル(例: appsettings.json)にベース URL を定義


{
  "ContactApi": {
    "BaseUrl": "https://api.example.com/"
  }
}

オプションクラスを作成


public class ContactApiOptions
{
    public string BaseUrl { get; set; } = string.Empty;
}

DI への登録と HttpClient 設定


var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure&lt;ContactApiOptions&gt;(
    builder.Configuration.GetSection("ContactApi"));

builder.Services.AddHttpClient&lt;ContactService&gt;((sp, client) =&gt;
{
    var options = sp.GetRequiredService&lt;IOptions&lt;ContactApiOptions&gt;&gt;().Value;
    client.BaseAddress = new Uri(options.BaseUrl);
});

こうしておけば、環境別の設定ファイル(appsettings.Development.json など)を差し替えるだけで、コードを書き換えずに API の接続先を切り替えられます。

エラー処理と戻り値設計のポイント

Web API 呼び出しでは、ネットワークエラーや API 側のエラー、JSON の仕様変更など、様々な失敗パターンが起こり得ます。GetFromJsonAsync<T> を使う場合でも、次のような点に注意しましょう。

HTTP ステータスコードの扱い

GetFromJsonAsync<T> は、ステータスコードが非成功(4xx/5xx)の場合、HttpRequestException を投げます。そのため、呼び出し側で try-catch して適切にハンドリングする必要があります。


try
{
    var contact = await _contactService.GetContactDataAsync(id, token);
    // 正常処理
}
catch (HttpRequestException ex)
{
    // ログを出力してユーザーにエラーを通知するなど
}

API が空のレスポンスを返した場合

API の仕様によっては、ボディが空だったり null に近いデータを返す場合もあります。先ほどのサンプルでは、result is null の時に InvalidOperationException を投げるようにしましたが、次のようなバリエーションも考えられます。

  • Task<Contact.Root?> を戻り値にして、null を許容する
  • 見つからなかった時だけ専用の例外(例: ContactNotFoundException)を投げる
  • API 側のエラーコードをラップして返す DTO を設ける

チームのコーディング規約や API の仕様に合わせて選択しましょう。

テストしやすい Web API クライアントにする工夫

ContactService のような Web API クライアントは、単体テストでモック化・スタブ化できるようにしておくと非常に便利です。HttpClient は HttpMessageHandler を差し替えることで擬似レスポンスを返せるため、DI と相性が良い設計になっています。

カスタム HttpMessageHandler の例


public class FakeHttpMessageHandler : HttpMessageHandler
{
    private readonly HttpResponseMessage _response;

    public FakeHttpMessageHandler(HttpResponseMessage response)
    {
        _response = response;
    }

    protected override Task&lt;HttpResponseMessage&gt; SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        return Task.FromResult(_response);
    }
}

単体テストでの利用例


[Fact]
public async Task GetContactDataAsync_正常系()
{
    // Arrange
    var json = @"{
        ""id"": 123,
        ""name"": ""山田太郎"",
        ""email"": ""[email protected]"",
        ""phone"": ""090-xxxx-xxxx""
    }";

    var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
    {
        Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
    };

    var handler = new FakeHttpMessageHandler(response);
    var httpClient = new HttpClient(handler)
    {
        BaseAddress = new Uri("https://api.example.com/")
    };

    var service = new ContactService(httpClient);

    // Act
    var contact = await service.GetContactDataAsync(123);

    // Assert
    Assert.Equal(123, contact.Id);
    Assert.Equal("山田太郎", contact.Name);
}

このように、HttpClient や ContactService を DI で注入できるようにしておけば、テストコードからはモックのハンドラを差し込むだけで、外部の実 API に依存しない形で動作確認ができます。

UI 層からの呼び出しパターン例

最後に、実際の UI からどのように呼び出すかの具体例をいくつか挙げておきます。

コンソールアプリ

すでに紹介した通り、async Task Main を使えば素直に await できます。


public static async Task Main(string[] args)
{
    var service = new ContactService(sharedClient);
    var contact = await service.GetContactDataAsync(123);
    Console.WriteLine(contact.Name);
}

WPF / WinForms からの呼び出し

ボタンクリックイベントなど UI スレッドから呼び出す場合は、イベントハンドラ自体を async void にして await するのが一般的です。


private readonly ContactService _contactService;

public MainWindow()
{
    InitializeComponent();
    _contactService = new ContactService(sharedClient);
}

private async void FetchButton_Click(object sender, RoutedEventArgs e)
{
    try
    {
        var contact = await _contactService.GetContactDataAsync(123);
        NameTextBlock.Text = contact.Name;
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message, "エラー", MessageBoxButton.OK, MessageBoxImage.Error);
    }
}

UI 層では ConfigureAwait(false) を付けずに await しておけば、復帰後も UI スレッド上で実行されるため、安心して UI 要素を更新できます。

まとめ:安全・簡潔・テストしやすい Web API 呼び出しにするために

Web API から取得した JSON をクラスにデシリアライズして返す、という一見シンプルな要件でも、HttpClient のライフサイクル管理や JSON デシリアライズ、非同期処理、DI など、考えるべきポイントは多く存在します。

観点ポイント
HttpClient の扱い毎回 new せず、DI または static で使い回す。可能なら IHttpClientFactory を利用。
JSON デシリアライズGetFromJsonAsync<T> / ReadFromJsonAsync<T> を活用し、文字列扱いをなくす。
クラス設計Web API 呼び出し専用の ContactService を作り、責務を集約する。
非同期処理呼び出し側も async / await でつなぎ、同期メソッドから直接待ち合わせない。
設定と環境切り替えベース URL はコードにベタ書きせず、設定ファイルや DI で注入する。
テスト性HttpMessageHandler を差し替えられる形にし、単体テストで API をモックできるようにする。

この記事で紹介したパターンをベースに、自身のプロジェクトの API クライアントを見直してみると、

  • コード量が減り、可読性が上がる
  • エラーが起きた時の原因切り分けがしやすくなる
  • テストや将来の仕様変更にも強くなる

といった効果が期待できます。特に、「HttpClient + JSON デシリアライズ + 非同期メソッド」 の組み合わせはあらゆる Web API 連携の基本形なので、一度しっかりパターン化しておくと、今後の開発効率と品質が大きく向上します。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次