HttpClient & IHttpClientFactory
12 minute read
.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:
| Aspect | Description | Priority |
|---|---|---|
| HttpClient Lifecycle | Use IHttpClientFactory to prevent socket exhaustion and DNS issues | 🔴 Critical |
| Resilience | Implement retry policies, circuit breakers, and timeouts | 🟠 High |
| Serialization | Use System.Text.Json with consistent options | 🟠 High |
| Cancellation | Support CancellationToken for all async operations | 🟠 High |
| Error Handling | Map HTTP status codes to typed exceptions | 🟡 Medium |
| Authentication | Integrate with DelegatingHandler for token management | 🟡 Medium |
| Logging/Telemetry | Add 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}");
}
✅ Pattern 1: IHttpClientFactory (Recommended)
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<TasksApiClient>().
/// </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
- No external dependencies - Zero third-party packages required
- Complete control - Full access to HTTP request/response pipeline
- Performance - Direct access to
HttpClientoptimizations - Native DI integration - First-class
IHttpClientFactorysupport - Resilience -
Microsoft.Extensions.Http.Resilienceintegration - Security compliance - Easier to pass security audits
- Smaller deployment - No additional package footprint
- Latest features - Immediate access to new .NET HTTP features
- 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
| Scenario | Recommendation |
|---|---|
| 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 endpoints | Consider Refit |
| Need OpenAPI-generated clients | Consider NSwag/Kiota |
| Rapid prototyping | Consider RestSharp |
| Team unfamiliar with HTTP internals | Consider Refit |
Further Reading
Full Sample
See the full sample on GitHub: https://github.com/BenjaminAbt/dotnet.rest-samples