
When HTTP integration is “simple” (CRUD, query string filters, typed bodies), writing clients by hand is a waste of time: boilerplate for serialization, error handling, query/route mapping, repetitive headers. Refit solves this problem by defining the API as an interface; the client is generated at compile time (via source generator), works with HttpClientFactory, and doesn’t rely on invasive reflection.
Refit is effective when:
If the API is highly dynamic (arbitrary queries, heterogeneous payloads), or if you need to generate the client from a complex OpenAPI schema, it’s better to consider a custom hand-written wrapper.
Start from the contract. Define routes, queries, bodies, and expected status codes precisely. Avoid ambiguous overloads and keep DTOs nullability-aware (enable nullable reference types).
1// contracts/IPaymentsApi.cs2using Refit;34public interface IPaymentsApi5{6 [Get("/v1/payments/{id}")]7 Task<ApiResponse<PaymentDto>> GetAsync(string id, CancellationToken ct = default);89 [Get("/v1/payments")]10 Task<ApiResponse<Page<PaymentDto>>> ListAsync([Query] int Page, [Query] int PageSize, [Query] PaymentStatus? Status, CancellationToken ct = default);1112 [Post("/v1/payments")]13 Task<ApiResponse<PaymentDto>> CreateAsync([Body] CreatePayment cmd, CancellationToken ct = default);1415 [Get("/v1/reports/{id}")]16 Task<Stream> DownloadReportAsync(string id, CancellationToken ct = default);17}1819public sealed record PaymentDto(string Id, string Status, decimal Amount, string Currency);20public sealed record Page<T>(IReadOnlyList<T> Items, int Page, int PageSize, int Total);21public sealed record CreatePayment(decimal Amount, string Currency, string CustomerId);
Notes:
A sensible registration setup prevents production issues (timeouts, overly aggressive retries, noisy circuit breakers).
Here’s the “standard” version we use as our baseline.
1// Program.cs2var jsonOptions = new JsonSerializerOptions3{4 PropertyNamingPolicy = JsonNamingPolicy.CamelCase,5 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull6};7jsonOptions.Converters.Add(new JsonStringEnumConverter());89var refitSettings = new RefitSettings10{11 ContentSerializer = new SystemTextJsonContentSerializer(jsonOptions)12};1314builder.Services.AddTransient<AuthDelegatingHandler>(); // DelegatingHandler custom1516builder.Services17 .AddRefitClient<IPaymentsApi>(refitSettings)18 .ConfigureHttpClient(c =>19 {20 c.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"]!);21 c.DefaultRequestHeaders.UserAgent.ParseAdd("webion-payments/1.0");22 })23 .AddHttpMessageHandler<AuthDelegatingHandler>()24 .AddStandardResilienceHandler(o =>25 {26 o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10);27 o.Retry.MaxRetryAttempts = 3;28 o.Retry.BackoffType = DelayBackoffType.Exponential;29 o.Retry.UseJitter = true;30 o.CircuitBreaker.MinimumThroughput = 10;31 o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);32 o.CircuitBreaker.FailureRatio = 0.5;33 o.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);34 });
Key details:
Don’t mix authentication logic into Refit methods — introduce a DelegatingHandler instead. This allows for token rotation, request correlation, and consistent metrics across all calls.
1public sealed class AuthDelegatingHandler : DelegatingHandler2{3 private readonly IExampleTokenGenerator _tokenGenerator;45 public AuthDelegatingHandler(IExampleTokenGenerator tokenGenerator)6 {7 _tokenGenerator = tokenGenerator;8 }910 protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)11 {12 // Your logic to retrieve auth token13 var token = _tokenGenerator.GenerateTokenAsync(cancellationToken);1415 request.Headers.Add("Authorization", $"Bearer {token}");16 return await base.SendAsync(request, cancellationToken);17 }18}
When returning Task<T>, Refit throws an ApiException on 4xx/5xx responses.
When using Task<ApiResponse<T>>, it doesn’t throw — instead, it gives you both the status code and the response body.
1// Con Task<ApiResponse<T>>2var res = await _api.GetAsync(id);3if (!res.IsSuccessStatusCode)4{5 // Gestione errore6}78return res.Content;910// Con Task<T>11try12{13 var res = await _api.GetAsync(id, ct);14 return res.Content!;15}16catch (ApiException ex)17{18 // Gestione errore19}
You can choose whichever approach fits best for your scenario.
If the provider uses custom headers, you can set them via an attribute.
1[Headers("Accept: application/json", "X-Api-Version: 1")]2public interface IPaymentsApi { /* ... */ }
For example, if versioning is via header you can set it like this.
If instead the version is in the path (/v2/...), model it directly in the routes.
You can choose not to have a single interface representing the entire API; instead, create a wrapper interface for the client that’s split into the API’s various components, using RestServices.For<T>.
1// ApplicationManagerClient.cs2public sealed class ApplicationManagerClient : IApplicationManagerClient3{4 private readonly HttpClient _client;56 public ApplicationManagerClient(HttpClient client)7 {8 _client = client;9 }1011 public IApplicationsApi Applications => RestService.For<IApplicationsApi>(_client);12 public ISitesApi Sites => RestService.For<ISitesApi>(_client);13 public IAppPoolsApi AppPools => RestService.For<IAppPoolsApi>(_client);14}1516// IApplicationManagerClient.cs17public interface IApplicationManagerClient18{19 public IApplicationsApi Applications { get; }20 public ISitesApi Sites { get; }21 public IAppPoolsApi AppPools { get; }22}2324// program.cs25services.AddHttpClient<IApplicationManagerClient, ApplicationManagerClient>();
Refit enables modeling HTTP integrations as C# contracts, offloading mechanical details like routing, serialization, and query/route mapping to the runtime. With HttpClientFactory and .NET 8’s resilience handlers, you get robust handling for real-world needs (timeouts, jittered retries, circuit breakers). Keep interfaces small, define solid DTOs, and stay on top of error handling and telemetry.