DN

Personal .NET notes

Duck's .NET Notebook

.NET

HttpClient - A learning ladder for using HttpClient in .NET API

A step-by-step guide to creating, registering, injecting, and using HttpClient correctly in a .NET API, from basic cases to named clients and response handling.

This HttpClient learning ladder covers:

Step 1: What HttpClient is for

HttpClient is used when your API needs to call another HTTP service, for example:

  • another internal API
  • a third-party API
  • a remote endpoint returning JSON

Simple example:

using System.Net.Http;

var client = new HttpClient();
using var response = await client.GetAsync("https://api.example.com/weather");
var content = await response.Content.ReadAsStringAsync();

Step 2: The simplest app setup

In ASP.NET Core, the normal starting point is to register HttpClient support in Program.cs.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();

var app = builder.Build();

What this does:

  • Enables IHttpClientFactory
  • Allows the framework to manage HttpClient creation more safely

Step 3: Inject IHttpClientFactory

Once you register AddHttpClient(), you can inject IHttpClientFactory into a service and use it.

Example service:

using System.Net.Http;

public class WeatherService
{
    private readonly IHttpClientFactory _httpClientFactory;

    public WeatherService(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public async Task<string> GetWeatherAsync()
    {
        var client = _httpClientFactory.CreateClient();

        using var response = await client.GetAsync("https://api.example.com/weather");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}

And register the service:

builder.Services.AddHttpClient();
builder.Services.AddScoped<WeatherService>();

What this does:

  1. AddHttpClient() registers the HTTP client factory system
  2. your service receives IHttpClientFactory through constructor injection (DI)

That means your code does not manually manage the full client lifecycle itself.

Step 4: Typed client - cleaner

You can still inject IHttpClientFactory, but if one service always talks to one external API, a typed client often reads more cleanly.

Example:

public class GitHubService
{
    private readonly HttpClient _httpClient;

    public GitHubService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<string> GetUserAsync(string username)
    {
        using var response = await _httpClient.GetAsync($"/users/{username}");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}

Registration:

builder.Services.AddHttpClient<GitHubService>(client =>
{
    client.BaseAddress = new Uri("https://api.github.com");
});

What this does:

  • the service directly receives HttpClient
  • the base address can be configured just once.

Step 5: When to use HttpClient vs IHttpClientFactory

This confuses a lot of people because both appear in examples. The practical rule is:

  • Use plain injected HttpClient inside a typed client
  • Use IHttpClientFactory when you need to create clients dynamically or choose between multiple client configurations (e.g call two different websites)

When to use which:

  • one service, one external API: Typed client is often cleaner
  • many external APIs or dynamic choice: IHttpClientFactory is often better

Step 6: Named client

A named client is useful when you want different HTTP configurations with different names.

Example registration:

builder.Services.AddHttpClient("github", client =>
{
    client.BaseAddress = new Uri("https://api.github.com");
});

builder.Services.AddHttpClient("weather", client =>
{
    client.BaseAddress = new Uri("https://api.weather.example");
});

Then in a service:

public class ExternalApiService
{
    private readonly IHttpClientFactory _httpClientFactory;

    public ExternalApiService(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public async Task<string> GetGitHubUserAsync(string username)
    {
        var client = _httpClientFactory.CreateClient("github");
        using var response = await client.GetAsync($"/users/{username}");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }

    public async Task<string> GetWeatherAsync(string city)
    {
        var client = _httpClientFactory.CreateClient("weather");
        using var response = await client.GetAsync($"/forecast/{city}");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}

Why named clients help:

  • each external API can have its own setup
  • base address and headers can be separated clearly
  • you avoid mixing configurations

Step 7: Named client vs Typed client

Use a typed client when:

  • one service/class clearly maps to one external API
  • you want the cleanest constructor and service code

Use a named client when:

  • one service/class needs to call several external APIs
  • you want to select between clients by name
  • you want more central configuration without creating a separate typed service for each API

Step 8: Add default headers

Sometimes an API needs headers on every request.

Example registration:

builder.Services.AddHttpClient("github", client =>
{
    client.BaseAddress = new Uri("https://api.github.com");
    client.DefaultRequestHeaders.Add("User-Agent", "MyDotNetApi");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});

Use this for headers that should apply to every request for that client.

Step 9: Add custom headers for just one request

If a header should only exist on one request, use HttpRequestMessage.

Example:

var request = new HttpRequestMessage(HttpMethod.Get, "/users/octocat");
request.Headers.Add("X-Correlation-Id", Guid.NewGuid().ToString());

using var response = await client.SendAsync(request);

Use this when:

  • the header changes per request
  • the header is not a global default

Step 10: EnsureSuccessStatusCode()

When checking a response, you usually care about:

  • status code
  • headers
  • body content

Use EnsureSuccessStatusCode() when:

  • any non-2xx response should be treated as a failure
  • you do not need special handling for specific status codes

If the response is not in the 2xx range, EnsureSuccessStatusCode() throws an HttpRequestException.

Example:

using var response = await client.GetAsync("/users/octocat");
response.EnsureSuccessStatusCode();

var body = await response.Content.ReadAsStringAsync();

Step 11: Check http response content on error

Basic example:

using var response = await client.GetAsync("/users/octocat");

if (!response.IsSuccessStatusCode)
{
    var errorBody = await response.Content.ReadAsStringAsync();
    throw new Exception($"Request failed: {(int)response.StatusCode} - {errorBody}");
}

var body = await response.Content.ReadAsStringAsync();

This gives you more control than immediately calling EnsureSuccessStatusCode().

Step 12: How to read JSON response content - ReadFromJsonAsync

If the API returns JSON, you usually want to deserialize it into a class.

Example model:

public class GitHubUser
{
    public string Login { get; set; } = string.Empty;
    public long Id { get; set; }
}

Example usage:

using System.Net.Http.Json;

using var response = await client.GetAsync("/users/octocat");
response.EnsureSuccessStatusCode();

var user = await response.Content.ReadFromJsonAsync<GitHubUser>();

Step 13: GetFromJsonAsync

GetFromJsonAsync is a shortcut when:

  • you are making a GET request
  • you expect JSON back
  • you want to deserialize it directly into a model

Example model:

public class GitHubUser
{
    public string Login { get; set; } = string.Empty;
    public long Id { get; set; }
}

Example usage:

using System.Net.Http.Json;

var user = await _httpClient.GetFromJsonAsync<GitHubUser>("/users/octocat");

If the response is not in the 2xx range, GetFromJsonAsync throws an HttpRequestException rather than giving you the raw response to inspect first.

This is shorter than:

using var response = await _httpClient.GetAsync("/users/octocat");
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadFromJsonAsync<GitHubUser>();

Use GetFromJsonAsync when:

  • quick, simple GET requests
  • you do not need much custom response handling

Avoid it when:

  • you need to inspect status codes first
  • you need custom error handling
  • you need headers from the response

Full code example

Here is a simple typed client setup.

Registration

// Typed client: one service focused on one upstream API.
builder.Services.AddHttpClient<IGitHubUserClient, GitHubUserClient>(client =>
{
    client.BaseAddress = new Uri("https://api.github.com");
    client.DefaultRequestHeaders.Add("User-Agent", "LearningApi");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});

Service

using System.Net.Http.Json;

public class GitHubUserClient : IGitHubUserClient
{
    private readonly HttpClient _httpClient;
    public GitHubUserClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<GitHubUser?> GetUserAsync(string username, CancellationToken cancellationToken = default)
    {
        using var response = await _httpClient.GetAsync($"/users/{username}", cancellationToken);
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<GitHubUser>(cancellationToken: cancellationToken);
    }
}

Endpoint/controller setup usage

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
    private readonly IGitHubUserClient _gitHubUserClient;
    public UsersController(IGitHubUserClient gitHubUserClient)
    {
        _gitHubUserClient = gitHubUserClient;
    }

    [HttpGet("{username}")]
    public async Task<IResult> GetUser(string username, CancellationToken cancellationToken)
    {
        var user = await _gitHubUserClient.GetUserAsync(username, cancellationToken);
        return user is null ? Results.NotFound() : Results.Ok(user);
    }
}

Summary

  • Do not scatter new HttpClient() throughout your code (avoid new)
  • Register HTTP clients with DI
  • Use typed clients for simple one-service-to-one-API cases
  • Use named clients when you need multiple configurations
  • Use default headers only for values that apply to every request
  • Use request-specific headers through HttpRequestMessage
  • Dispose HttpResponseMessage when you manually handle the response
  • Check status codes.
  • Deserialize JSON into models.