HttpClient & IHttpClientFactory

Production-ready patterns for consuming REST APIs with .NET’s native HttpClient and IHttpClientFactory

.NET’s built-in HttpClient class, combined with IHttpClientFactory, provides all the foundational capabilities needed to create a production-ready REST client. While specialized libraries like Refit or RestSharp offer convenience, HttpClient gives you complete control and eliminates third-party dependencies.

This guide demonstrates production-ready patterns for building robust, maintainable REST clients using .NET’s native HTTP capabilities.

Key Design Considerations

When building a REST client with HttpClient, consider these essential aspects:

AspectDescriptionPriority
HttpClient LifecycleUse IHttpClientFactory to prevent socket exhaustion and DNS issues🔴 Critical
ResilienceImplement retry policies, circuit breakers, and timeouts🟠 High
SerializationUse System.Text.Json with consistent options🟠 High
CancellationSupport CancellationToken for all async operations🟠 High
Error HandlingMap HTTP status codes to typed exceptions🟡 Medium
AuthenticationIntegrate with DelegatingHandler for token management🟡 Medium
Logging/TelemetryAdd observability via HttpClientHandler pipeline🟡 Medium

HttpClient Lifetime Management

❌ Anti-Pattern: Creating HttpClient per Request

// ⚠️ NEVER DO THIS - causes socket exhaustion!
public async Task<ProductDto?> GetProductAsync(int productId)
{
    using HttpClient client = new HttpClient();  // ❌ Creates new socket each time
    return await client.GetFromJsonAsync<ProductDto>($"https://api.example.com/products/{productId}");
}
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Net.Http.Json;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// Register named client with configuration
builder.Services.AddHttpClient("TasksApi", (IServiceProvider serviceProvider, HttpClient client) =>
{
    client.BaseAddress = new Uri("https://api.example.com/");
    client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
    client.Timeout = TimeSpan.FromSeconds(30);
});

IHost host = builder.Build();

// Resolve and use
IHttpClientFactory httpClientFactory = host.Services.GetRequiredService<IHttpClientFactory>();
HttpClient httpClient = httpClientFactory.CreateClient("TasksApi");

TaskDto? task = await httpClient.GetFromJsonAsync<TaskDto>("tasks/123");
Console.WriteLine($"Task: {task?.Title}");

public sealed record TaskDto(int Id, string Title, bool IsCompleted);

✅ Pattern 2: Static HttpClient with PooledConnectionLifetime

using System.Net.Http.Json;

// For console apps or scenarios without DI
public static class ApiClient
{
    private static readonly HttpClient s_httpClient = new(new SocketsHttpHandler
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(15)  // Handles DNS changes
    })
    {
        BaseAddress = new Uri("https://api.example.com/"),
        Timeout = TimeSpan.FromSeconds(30)
    };

    public static async Task<TaskDto?> GetTaskAsync(int taskId, CancellationToken cancellationToken = default)
    {
        return await s_httpClient.GetFromJsonAsync<TaskDto>($"tasks/{taskId}", cancellationToken);
    }
}

Typed Client Pattern (Production-Ready)

The typed client pattern encapsulates HTTP operations in a dedicated service class, providing strong typing, testability, and separation of concerns.

Base Client Implementation

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

/// <summary>
/// Abstract base class for JSON-based REST API clients.
/// Provides standardized HTTP operations with consistent error handling and serialization.
/// </summary>
public abstract class JsonApiClientBase : IDisposable
{
    private readonly HttpClient _httpClient;
    private readonly bool _disposeHttpClient;
    private bool _disposed;

    /// <summary>
    /// JSON serialization options used for all request/response serialization.
    /// </summary>
    protected static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        PropertyNameCaseInsensitive = true,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
    };

    /// <summary>
    /// Initializes a new instance of the JSON API client.
    /// </summary>
    /// <param name="httpClient">The HttpClient instance (preferably from IHttpClientFactory).</param>
    /// <param name="disposeHttpClient">Whether to dispose the HttpClient when this instance is disposed.</param>
    protected JsonApiClientBase(HttpClient httpClient, bool disposeHttpClient = false)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        _disposeHttpClient = disposeHttpClient;
    }

    /// <summary>
    /// Performs an HTTP GET request and deserializes the response.
    /// </summary>
    protected async Task<TResponse?> GetAsync<TResponse>(
        string requestUri,
        CancellationToken cancellationToken = default)
    {
        using HttpResponseMessage response = await _httpClient
            .GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
            .ConfigureAwait(false);

        await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
        return await DeserializeAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    /// Performs an HTTP POST request with a JSON body and deserializes the response.
    /// </summary>
    protected async Task<TResponse?> PostAsync<TRequest, TResponse>(
        string requestUri,
        TRequest body,
        CancellationToken cancellationToken = default)
    {
        using HttpResponseMessage response = await _httpClient
            .PostAsJsonAsync(requestUri, body, JsonOptions, cancellationToken)
            .ConfigureAwait(false);

        await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
        return await DeserializeAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    /// Performs an HTTP PUT request with a JSON body and deserializes the response.
    /// </summary>
    protected async Task<TResponse?> PutAsync<TRequest, TResponse>(
        string requestUri,
        TRequest body,
        CancellationToken cancellationToken = default)
    {
        using HttpResponseMessage response = await _httpClient
            .PutAsJsonAsync(requestUri, body, JsonOptions, cancellationToken)
            .ConfigureAwait(false);

        await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
        return await DeserializeAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    /// Performs an HTTP DELETE request.
    /// </summary>
    protected async Task<bool> DeleteAsync(
        string requestUri,
        CancellationToken cancellationToken = default)
    {
        using HttpResponseMessage response = await _httpClient
            .DeleteAsync(requestUri, cancellationToken)
            .ConfigureAwait(false);

        return response.IsSuccessStatusCode ||
               response.StatusCode == HttpStatusCode.NotFound; // Idempotent: already deleted
    }

    /// <summary>
    /// Validates the HTTP response and throws appropriate exceptions for error status codes.
    /// </summary>
    private static async Task EnsureSuccessAsync(
        HttpResponseMessage response,
        CancellationToken cancellationToken)
    {
        if (response.IsSuccessStatusCode)
        {
            return;
        }

        string? errorContent = null;
        try
        {
            errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
        }
        catch
        {
            // Ignore read errors, we'll throw with status code info
        }

        throw response.StatusCode switch
        {
            HttpStatusCode.NotFound => new ApiNotFoundException(
                $"Resource not found: {response.RequestMessage?.RequestUri}", errorContent),
            HttpStatusCode.Unauthorized => new ApiUnauthorizedException(
                "Authentication required", errorContent),
            HttpStatusCode.Forbidden => new ApiForbiddenException(
                "Access denied", errorContent),
            HttpStatusCode.BadRequest => new ApiBadRequestException(
                "Invalid request", errorContent),
            HttpStatusCode.TooManyRequests => new ApiRateLimitException(
                "Rate limit exceeded", errorContent),
            >= HttpStatusCode.InternalServerError => new ApiServerException(
                $"Server error: {response.StatusCode}", errorContent),
            _ => new ApiException(
                $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}", errorContent)
        };
    }

    /// <summary>
    /// Deserializes the HTTP response content to the specified type.
    /// </summary>
    private static async Task<T?> DeserializeAsync<T>(
        HttpResponseMessage response,
        CancellationToken cancellationToken)
    {
        if (response.Content.Headers.ContentLength == 0)
        {
            return default;
        }

        await using Stream stream = await response.Content
            .ReadAsStreamAsync(cancellationToken)
            .ConfigureAwait(false);

        return await JsonSerializer
            .DeserializeAsync<T>(stream, JsonOptions, cancellationToken)
            .ConfigureAwait(false);
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;
        if (disposing && _disposeHttpClient)
        {
            _httpClient.Dispose();
        }
        _disposed = true;
    }
}

// Custom exception hierarchy for API errors
public class ApiException : Exception
{
    public string? ResponseContent { get; }
    public ApiException(string message, string? responseContent = null) : base(message)
        => ResponseContent = responseContent;
}

public sealed class ApiNotFoundException : ApiException
{
    public ApiNotFoundException(string message, string? responseContent = null)
        : base(message, responseContent) { }
}

public sealed class ApiUnauthorizedException : ApiException
{
    public ApiUnauthorizedException(string message, string? responseContent = null)
        : base(message, responseContent) { }
}

public sealed class ApiForbiddenException : ApiException
{
    public ApiForbiddenException(string message, string? responseContent = null)
        : base(message, responseContent) { }
}

public sealed class ApiBadRequestException : ApiException
{
    public ApiBadRequestException(string message, string? responseContent = null)
        : base(message, responseContent) { }
}

public sealed class ApiRateLimitException : ApiException
{
    public ApiRateLimitException(string message, string? responseContent = null)
        : base(message, responseContent) { }
}

public sealed class ApiServerException : ApiException
{
    public ApiServerException(string message, string? responseContent = null)
        : base(message, responseContent) { }
}

Domain-Specific API Client

Build domain-specific clients that inherit from the base class:

using System.Net.Http.Json;

/// <summary>
/// Typed client for the Tasks API.
/// Register with DI using AddHttpClient&lt;TasksApiClient&gt;().
/// </summary>
public sealed class TasksApiClient : JsonApiClientBase
{
    private const string TasksEndpoint = "api/v1/tasks";

    public TasksApiClient(HttpClient httpClient) : base(httpClient) { }

    /// <summary>
    /// Retrieves a task by its unique identifier.
    /// </summary>
    /// <param name="taskId">The task's unique identifier.</param>
    /// <param name="cancellationToken">Cancellation token for the operation.</param>
    /// <returns>The task if found; otherwise null.</returns>
    /// <exception cref="ApiNotFoundException">When the task does not exist.</exception>
    public Task<TaskDto?> GetTaskByIdAsync(int taskId, CancellationToken cancellationToken = default)
        => GetAsync<TaskDto>($"{TasksEndpoint}/{taskId}", cancellationToken);

    /// <summary>
    /// Retrieves all tasks with optional filtering.
    /// </summary>
    /// <param name="status">Optional status filter (all, completed, pending).</param>
    /// <param name="cancellationToken">Cancellation token for the operation.</param>
    /// <returns>Array of tasks matching the filter criteria.</returns>
    public Task<TaskDto[]?> GetTasksAsync(
        string? status = null,
        CancellationToken cancellationToken = default)
    {
        string endpoint = status is not null
            ? $"{TasksEndpoint}?status={Uri.EscapeDataString(status)}"
            : TasksEndpoint;

        return GetAsync<TaskDto[]>(endpoint, cancellationToken);
    }

    /// <summary>
    /// Creates a new task.
    /// </summary>
    /// <param name="request">The task creation request.</param>
    /// <param name="cancellationToken">Cancellation token for the operation.</param>
    /// <returns>The created task with its assigned identifier.</returns>
    /// <exception cref="ApiBadRequestException">When validation fails.</exception>
    public Task<TaskDto?> CreateTaskAsync(
        CreateTaskRequest request,
        CancellationToken cancellationToken = default)
        => PostAsync<CreateTaskRequest, TaskDto>(TasksEndpoint, request, cancellationToken);

    /// <summary>
    /// Updates an existing task.
    /// </summary>
    /// <param name="taskId">The task's unique identifier.</param>
    /// <param name="request">The update request.</param>
    /// <param name="cancellationToken">Cancellation token for the operation.</param>
    /// <returns>The updated task.</returns>
    public Task<TaskDto?> UpdateTaskAsync(
        int taskId,
        UpdateTaskRequest request,
        CancellationToken cancellationToken = default)
        => PutAsync<UpdateTaskRequest, TaskDto>($"{TasksEndpoint}/{taskId}", request, cancellationToken);

    /// <summary>
    /// Deletes a task by its identifier.
    /// </summary>
    /// <param name="taskId">The task's unique identifier.</param>
    /// <param name="cancellationToken">Cancellation token for the operation.</param>
    /// <returns>True if deleted or already absent; false on failure.</returns>
    public Task<bool> DeleteTaskAsync(int taskId, CancellationToken cancellationToken = default)
        => DeleteAsync($"{TasksEndpoint}/{taskId}", cancellationToken);
}

// DTOs with explicit types and XML documentation
/// <summary>Represents a task in the system.</summary>
public sealed record TaskDto(
    int Id,
    string Title,
    string? Description,
    bool IsCompleted,
    DateTime CreatedAt,
    DateTime? CompletedAt,
    TaskPriority Priority);

/// <summary>Request model for creating a new task.</summary>
public sealed record CreateTaskRequest(
    string Title,
    string? Description = null,
    TaskPriority Priority = TaskPriority.Medium);

/// <summary>Request model for updating an existing task.</summary>
public sealed record UpdateTaskRequest(
    string? Title = null,
    string? Description = null,
    bool? IsCompleted = null,
    TaskPriority? Priority = null);

/// <summary>Task priority levels.</summary>
public enum TaskPriority { Low, Medium, High, Critical }

Dependency Injection Registration

ASP.NET Core / Generic Host

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;

IServiceCollection services = new ServiceCollection();

// Register typed client with configuration and resilience
services
    .AddHttpClient<TasksApiClient>((IServiceProvider provider, HttpClient client) =>
    {
        client.BaseAddress = new Uri("https://api.example.com/");
        client.DefaultRequestHeaders.Add("Accept", "application/json");
        client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
        client.Timeout = TimeSpan.FromSeconds(30);
    })
    .AddStandardResilienceHandler();  // Microsoft.Extensions.Http.Resilience

// Usage via DI
public sealed class TasksController : ControllerBase
{
    private readonly TasksApiClient _tasksClient;

    public TasksController(TasksApiClient tasksClient)
    {
        _tasksClient = tasksClient;
    }

    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetTask(
        int id,
        CancellationToken cancellationToken)
    {
        try
        {
            TaskDto? task = await _tasksClient.GetTaskByIdAsync(id, cancellationToken);
            return task is not null ? Ok(task) : NotFound();
        }
        catch (ApiNotFoundException)
        {
            return NotFound();
        }
    }
}

Resilience with Microsoft.Extensions.Http.Resilience

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using Polly;

services
    .AddHttpClient<TasksApiClient>((HttpClient client) =>
    {
        client.BaseAddress = new Uri("https://api.example.com/");
    })
    // Standard resilience: retry, circuit breaker, timeout, rate limiting
    .AddStandardResilienceHandler()
    // Or configure custom resilience pipeline
    .AddResilienceHandler("custom", (ResiliencePipelineBuilder<HttpResponseMessage> builder) =>
    {
        builder
            .AddRetry(new HttpRetryStrategyOptions
            {
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromMilliseconds(500),
                BackoffType = DelayBackoffType.Exponential,
                ShouldHandle = static args => ValueTask.FromResult(
                    args.Outcome.Result?.StatusCode >= System.Net.HttpStatusCode.InternalServerError ||
                    args.Outcome.Exception is HttpRequestException)
            })
            .AddTimeout(TimeSpan.FromSeconds(10));
    });

Complete Usage Example

using System.Net.Http.Json;

// With IHttpClientFactory (recommended)
IServiceProvider services = BuildServices();
TasksApiClient tasksClient = services.GetRequiredService<TasksApiClient>();

// CREATE - Add a new task
CreateTaskRequest createRequest = new(
    Title: "Review pull requests",
    Description: "Review open PRs in the main repository",
    Priority: TaskPriority.High);

TaskDto? createdTask = await tasksClient.CreateTaskAsync(createRequest);
Console.WriteLine($"Created task #{createdTask?.Id}: {createdTask?.Title}");

// READ - Get task by ID
TaskDto? task = await tasksClient.GetTaskByIdAsync(createdTask!.Id);
Console.WriteLine($"Retrieved: {task?.Title} (Completed: {task?.IsCompleted})");

// READ - Get all tasks with filter
TaskDto[]? pendingTasks = await tasksClient.GetTasksAsync(status: "pending");
Console.WriteLine($"Pending tasks: {pendingTasks?.Length ?? 0}");

// UPDATE - Mark task as completed
UpdateTaskRequest updateRequest = new(IsCompleted: true);
TaskDto? updatedTask = await tasksClient.UpdateTaskAsync(createdTask.Id, updateRequest);
Console.WriteLine($"Updated: {updatedTask?.Title} (Completed: {updatedTask?.IsCompleted})");

// DELETE - Remove a task
bool deleted = await tasksClient.DeleteTaskAsync(createdTask.Id);
Console.WriteLine($"Deleted: {deleted}");

// Error handling example
try
{
    TaskDto? notFound = await tasksClient.GetTaskByIdAsync(999999);
}
catch (ApiNotFoundException ex)
{
    Console.WriteLine($"Task not found: {ex.Message}");
}
catch (ApiException ex)
{
    Console.WriteLine($"API error: {ex.Message}");
    Console.WriteLine($"Response: {ex.ResponseContent}");
}

// Using CancellationToken for timeout control
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(5));
try
{
    TaskDto[]? tasks = await tasksClient.GetTasksAsync(cancellationToken: cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Request timed out after 5 seconds");
}

Authentication with DelegatingHandler

For scenarios requiring authentication, implement a DelegatingHandler:

using System.Net.Http.Headers;

/// <summary>
/// DelegatingHandler that automatically adds Bearer tokens to outgoing requests.
/// Handles token refresh when tokens expire.
/// </summary>
public sealed class BearerTokenHandler : DelegatingHandler
{
    private readonly ITokenProvider _tokenProvider;

    public BearerTokenHandler(ITokenProvider tokenProvider)
    {
        _tokenProvider = tokenProvider;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        // Get token (provider handles caching and refresh)
        string accessToken = await _tokenProvider.GetAccessTokenAsync(cancellationToken);

        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

        HttpResponseMessage response = await base.SendAsync(request, cancellationToken);

        // Handle 401 by refreshing token and retrying once
        if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
        {
            accessToken = await _tokenProvider.RefreshAccessTokenAsync(cancellationToken);
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

            response.Dispose();
            response = await base.SendAsync(request, cancellationToken);
        }

        return response;
    }
}

/// <summary>Interface for token management.</summary>
public interface ITokenProvider
{
    Task<string> GetAccessTokenAsync(CancellationToken cancellationToken = default);
    Task<string> RefreshAccessTokenAsync(CancellationToken cancellationToken = default);
}

// Registration
services.AddTransient<ITokenProvider, MyTokenProvider>();
services.AddTransient<BearerTokenHandler>();
services
    .AddHttpClient<TasksApiClient>(client => client.BaseAddress = new Uri("https://api.example.com/"))
    .AddHttpMessageHandler<BearerTokenHandler>();

Unit Testing

Testing typed clients is straightforward with mocked HttpMessageHandler:

using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Moq.Protected;
using Xunit;

public sealed class TasksApiClientTests
{
    [Fact]
    public async Task GetTaskByIdAsync_ReturnsTask_WhenTaskExists()
    {
        // Arrange
        TaskDto expectedTask = new(
            Id: 123,
            Title: "Test Task",
            Description: "A test task",
            IsCompleted: false,
            CreatedAt: DateTime.UtcNow,
            CompletedAt: null,
            Priority: TaskPriority.Medium);

        Mock<HttpMessageHandler> handlerMock = new();
        handlerMock
            .Protected()
            .Setup<Task<HttpResponseMessage>>(
                "SendAsync",
                ItExpr.Is<HttpRequestMessage>(req =>
                    req.Method == HttpMethod.Get &&
                    req.RequestUri!.PathAndQuery == "/api/v1/tasks/123"),
                ItExpr.IsAny<CancellationToken>())
            .ReturnsAsync(new HttpResponseMessage
            {
                StatusCode = HttpStatusCode.OK,
                Content = JsonContent.Create(expectedTask)
            });

        HttpClient httpClient = new(handlerMock.Object)
        {
            BaseAddress = new Uri("https://api.example.com/")
        };

        TasksApiClient client = new(httpClient);

        // Act
        TaskDto? result = await client.GetTaskByIdAsync(123);

        // Assert
        Assert.NotNull(result);
        Assert.Equal(123, result.Id);
        Assert.Equal("Test Task", result.Title);
        Assert.False(result.IsCompleted);
    }

    [Fact]
    public async Task GetTaskByIdAsync_ThrowsApiNotFoundException_WhenTaskNotFound()
    {
        // Arrange
        Mock<HttpMessageHandler> handlerMock = new();
        handlerMock
            .Protected()
            .Setup<Task<HttpResponseMessage>>(
                "SendAsync",
                ItExpr.IsAny<HttpRequestMessage>(),
                ItExpr.IsAny<CancellationToken>())
            .ReturnsAsync(new HttpResponseMessage
            {
                StatusCode = HttpStatusCode.NotFound,
                Content = new StringContent("{\"error\": \"Task not found\"}")
            });

        HttpClient httpClient = new(handlerMock.Object)
        {
            BaseAddress = new Uri("https://api.example.com/")
        };

        TasksApiClient client = new(httpClient);

        // Act & Assert
        await Assert.ThrowsAsync<ApiNotFoundException>(
            async () => await client.GetTaskByIdAsync(999));
    }

    [Fact]
    public async Task CreateTaskAsync_ReturnsCreatedTask()
    {
        // Arrange
        CreateTaskRequest request = new("New Task", "Description", TaskPriority.High);
        TaskDto expectedResponse = new(
            Id: 456,
            Title: "New Task",
            Description: "Description",
            IsCompleted: false,
            CreatedAt: DateTime.UtcNow,
            CompletedAt: null,
            Priority: TaskPriority.High);

        Mock<HttpMessageHandler> handlerMock = new();
        handlerMock
            .Protected()
            .Setup<Task<HttpResponseMessage>>(
                "SendAsync",
                ItExpr.Is<HttpRequestMessage>(req => req.Method == HttpMethod.Post),
                ItExpr.IsAny<CancellationToken>())
            .ReturnsAsync(new HttpResponseMessage
            {
                StatusCode = HttpStatusCode.Created,
                Content = JsonContent.Create(expectedResponse)
            });

        HttpClient httpClient = new(handlerMock.Object)
        {
            BaseAddress = new Uri("https://api.example.com/")
        };

        TasksApiClient client = new(httpClient);

        // Act
        TaskDto? result = await client.CreateTaskAsync(request);

        // Assert
        Assert.NotNull(result);
        Assert.Equal(456, result.Id);
        Assert.Equal("New Task", result.Title);
    }
}

Comparison: HttpClient vs Specialized Libraries

✅ Pros of Native HttpClient

  • No external dependencies - Zero third-party packages required
  • Complete control - Full access to HTTP request/response pipeline
  • Performance - Direct access to HttpClient optimizations
  • Native DI integration - First-class IHttpClientFactory support
  • Resilience - Microsoft.Extensions.Http.Resilience integration
  • Security compliance - Easier to pass security audits
  • Smaller deployment - No additional package footprint
  • Latest features - Immediate access to new .NET HTTP features

❌ Cons of Native HttpClient

  • More boilerplate - Requires manual client implementation
  • No code generation - No automatic client from OpenAPI/Swagger
  • Manual serialization - Must configure JSON options explicitly
  • Error handling - Custom exception hierarchy needed
  • Testing setup - More mocking configuration required
  • Learning curve - Deeper HTTP knowledge required

When to Choose Native HttpClient

ScenarioRecommendation
Security policies prohibit third-party packages✅ Use HttpClient
Need maximum control over HTTP pipeline✅ Use HttpClient
Simple API with few endpoints✅ Use HttpClient
Complex API with many endpointsConsider Refit
Need OpenAPI-generated clientsConsider NSwag/Kiota
Rapid prototypingConsider RestSharp
Team unfamiliar with HTTP internalsConsider Refit

Further Reading

Full Sample

See the full sample on GitHub: https://github.com/BenjaminAbt/dotnet.rest-samples