Refit - The Better Way to Consume APIs in .NET

Introduction
You're integrating with an external API. You create a service, inject HttpClient, build the URL, append query parameters, deserialize the response. It works. You do it again for the next endpoint. And the next. By endpoint number eight, you're copy-pasting the same structure with minor variations, and every new developer on the team has to understand your bespoke HTTP layer just to add a method.
This is the reality for most .NET teams consuming third-party APIs. The Typed HttpClient pattern with IHttpClientFactory is solid and production-proven - but it generates a lot of repetitive code that scales poorly when an API surface grows.
Refit solves this by generating the HTTP service for you at compile time, based on a C# interface you define. You write the contract, Refit writes the implementation.
In this post I'll walk through both approaches using a real OpenWeatherMap integration: first the full typed HttpClient setup with a delegating handler, then the same thing rebuilt with Refit. You'll see exactly where the complexity lives and what disappears.
🎬 Watch the full video here:
Setting Up the Project
The demo project targets .NET 10 and uses following NuGet package:
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
The key package Refit.HttpClientFactory provides the AddRefitClient<T>() extension that integrates cleanly with IHttpClientFactory.
⚙️ Approach 1: Typed HttpClient with IHttpClientFactory
Configuration and Options
Any external API integration starts with configuration. The OpenWeatherMap API needs a base URL, an API key, and a units setting. These go into appsettings.json:
{
"OpenWeatherApi": {
"BaseUrl": "https://api.openweathermap.org/data/2.5",
"ApiKey": "",
"Units": "metric"
}
}
Rather than injecting IConfiguration directly into services, the Options pattern is the right approach. Create a strongly typed options class:
public class OpenWeatherApiOptions
{
public const string OpenWeatherApiOptionsKey = "OpenWeatherApi";
[Required]
[Url]
public string BaseUrl { get; set; } = string.Empty;
[Required]
public string ApiKey { get; set; } = string.Empty;
[Required]
public string Units { get; set; } = string.Empty;
}
Key points:
OpenWeatherApiOptionsKeyis aconst stringused to bind to the correctappsettings.jsonsection- Data annotations (
[Required],[Url]) enable startup validation - Using
string.Emptyas defaults satisfies the nullable reference type compiler
Register the options in Program.cs:
builder.Services.AddOptions<OpenWeatherApiOptions>()
.BindConfiguration(OpenWeatherApiOptions.OpenWeatherApiOptionsKey)
.ValidateDataAnnotations()
.ValidateOnStart();
ValidateOnStart() is important - without it, validation only triggers on first use. With it, a misconfigured API key will fail immediately at startup rather than silently at runtime.
The Service and Interface
Start with the interface that defines the contract:
public interface IOpenWeatherApiService
{
Task<OpenWeatherApiResponse?> GetCurrentWeatherDataAsync(
double lat,
double lon,
CancellationToken ct);
}
And the implementation. Notice how HttpClient is injected via primary constructor and the query parameters are built manually in the URL string:
public class OpenWeatherApiService(HttpClient client) : IOpenWeatherApiService
{
public async Task<OpenWeatherApiResponse?> GetCurrentWeatherDataAsync(
double lat,
double lon,
CancellationToken ct)
{
var response = await client.GetFromJsonAsync<OpenWeatherApiResponse>(
$"weather?lat={lat}&lon={lon}");
return response;
}
}
Key points:
HttpClientis injected byIHttpClientFactory- the base address and shared configuration are set at registration time, not inside the service- The method builds a relative URL with
latandlonas query parameters - Notice what is missing: the API key and units are not here - that's the job of the delegating handler covered next
- Every new endpoint from this API means a new method in this class and a new entry in the interface
The Delegating Handler
Right now the service only appends lat and lon. But every request to OpenWeatherMap also needs appid (the API key) and units. You could add those to every method call - but that means touching every method whenever the API contract changes, and duplicating the same parameter wiring across dozens of endpoints.
A delegating handler solves this cleanly. It sits in the HTTP pipeline and intercepts every outgoing request before it leaves the application. You override SendAsync, mutate the request, and call base.SendAsync to continue the pipeline.
public class OpenWeatherApiDelegatingHandler(IOptions<OpenWeatherApiOptions> options)
: DelegatingHandler
{
private readonly OpenWeatherApiOptions _options = options.Value;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var uri = request.RequestUri!;
var separator = uri.Query.Length > 0 ? "&" : "?";
request.RequestUri = new Uri(
$"{uri}{separator}appid={_options.ApiKey}&units={_options.Units}");
return await base.SendAsync(request, cancellationToken);
}
}
Key points:
- Inherits from
DelegatingHandlerand overridesSendAsync - Detects whether a
?already exists in the query string and appends&or?accordingly - without this check you'd produce malformed URLs likeweather?lat=52&lon=13??appid=... - Reads the API key and units from
IOptions<OpenWeatherApiOptions>- no magic strings anywhere in the pipeline - This is also the right place for
Authorization: Bearer {token}headers for token-authenticated APIs - one handler, all requests covered
The delegating handler makes OpenWeatherApiService completely unaware of authentication or shared parameters. Adding 15 more endpoints to the interface means zero changes to the handler.
Registering Everything in Program.cs
builder.Services.AddTransient<OpenWeatherApiDelegatingHandler>();
builder.Services.AddHttpClient<IOpenWeatherApiService, OpenWeatherApiService>((provider, client) =>
{
var options = provider
.GetRequiredService<IOptions<OpenWeatherApiOptions>>()
.Value;
client.BaseAddress = new Uri(options.BaseUrl);
})
.AddHttpMessageHandler<OpenWeatherApiDelegatingHandler>();
Key points:
- The delegating handler must be registered as
Transientbefore it can be used withAddHttpMessageHandler ConfigureHttpClientresolves options from the DI container to set the base address - this is why the service itself never needs to know the base URLAddHttpMessageHandlerchains the handler into the request pipeline for this specific typed client
✅ Pros
- Full control over every request detail
- No additional dependencies beyond what ships with .NET
- Works well for simple integrations with one or two endpoints
❌ Cons
- Every new endpoint requires a new method in the service class and a matching entry in the interface
- The service, interface, and registration all need to be maintained in sync
- Boilerplate grows linearly with the number of API endpoints
⚡ Approach 2: Refit
Refit takes a different approach entirely. Instead of writing a service class, you declare a C# interface that describes the API contract using attributes. Refit generates the implementation at compile time - the OpenWeatherApiService class disappears completely.
The Interface
The interface that previously only defined the method signature now also carries the full HTTP contract:
public interface IOpenWeatherApiService
{
[Get("/weather")]
Task<OpenWeatherApiResponse?> GetCurrentWeatherDataAsync(
[Query] double lat,
[Query] double lon,
CancellationToken ct = default);
}
Key points:
[Get("/weather")]maps to the HTTP GET method and the relative path - note the leading slash, which Refit requires[Query]attributes map method parameters to URL query parameters automatically - no manual string interpolation- The base URL is still configured at registration time, same as before
- No implementation class is needed - this interface is the entire definition of the integration
Registration
builder.Services.AddTransient<OpenWeatherApiDelegatingHandler>();
builder.Services.AddRefitClient<IOpenWeatherApiService>()
.ConfigureHttpClient((provider, client) =>
{
var options = provider
.GetRequiredService<IOptions<OpenWeatherApiOptions>>()
.Value;
client.BaseAddress = new Uri(options.BaseUrl);
})
.AddHttpMessageHandler<OpenWeatherApiDelegatingHandler>();
The only change from the typed HttpClient registration is AddRefitClient<T>() instead of AddHttpClient<TInterface, TImplementation>(). The delegating handler, options resolution, and base address setup remain identical - Refit slots into the same pipeline.
What Refit Generates
When you set a breakpoint and inspect the injected IOpenWeatherApiService at runtime, you'll see a class like AutoGeneratedIOpenWeatherApiService - produced by Refit's source generator at compile time. It wraps HttpClient, builds the request URL from your [Get] and [Query] declarations, and handles deserialization. You get the same runtime behavior as the hand-written service with zero implementation code on your side.
Adding a new endpoint from the same API now means one new method in the interface. No new class, no new registration, no new URL string to construct.
When to Use Typed HttpClient
- You have one or two endpoints and adding a new NuGet dependency is not justified
- You need fine-grained control over request construction that attributes cannot express
- The API requires complex, dynamic query building that varies significantly per call
When to Use Refit
- You are integrating with an API that has 5 or more endpoints
- You want the interface to serve as living documentation of the external API contract
- You want to reduce boilerplate and keep new endpoint additions to a single line
Comparison Summary
| Aspect | Typed HttpClient | Refit |
|---|---|---|
| Implementation code | Manual service class required | Generated at compile time |
| New endpoint cost | New method in class + interface | One line in the interface |
| DI registration | AddHttpClient<I, Impl>() | AddRefitClient<I>() |
| Delegating handler | Fully compatible | Fully compatible |
| Options pattern | Fully compatible | Fully compatible |
| External dependency | None | Refit.HttpClientFactory |
| URL construction | Manual string interpolation | Attribute-driven, generated |
Key Takeaways
- The Options pattern with
ValidateDataAnnotations()andValidateOnStart()catches misconfiguration at startup, not silently at runtime. - Delegating handlers intercept every outgoing request in the pipeline - use them for API keys, auth headers, or any parameter that repeats across all endpoints.
- Without a delegating handler, shared parameters like
appidandunitsleak into every service method, making future changes expensive. - Refit replaces the service implementation class entirely by generating it from a decorated interface at compile time -
AddRefitClient<T>()is the only registration change needed. - For small integrations with one or two endpoints, typed HttpClient is perfectly fine. Refit pays off as the API surface grows.
- Both approaches work side by side in the same application - mix them based on the complexity of each integration.
Resources