Refit - The Better Way to Consume APIs in .NET
Stop writing the same HttpClient boilerplate for every external API endpoint - there’s a cleaner way.
Connect with me:
Want to sponsor this newsletter? Let’s work together →
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 for Refit is Refit.HttpClientFactory - it 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.jsonsectionData annotations (
[Required],[Url]) enable startup validationUsing
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 any shared configuration are set at registration time, not inside the serviceThe method builds a relative URL with
latandlonas query parametersNotice 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 overridesSendAsyncDetects 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 pipelineThis 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 withAddHttpMessageHandlerConfigureHttpClientresolvesIOptions<OpenWeatherApiOptions>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 interpolationThe 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
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
https://github.com/reactiveui/refit
Connect with me:
Want to sponsor this newsletter? Let’s work together →
