← Back to all posts
dotnetcsharphttprefitaspnet-core

Using Refit in .NET

Using Refit in .NET

The Problem

Consuming an external REST API from .NET usually starts simple enough. You grab an HttpClient, fire off a request, and deserialize the response:

public async Task<User?> GetUserAsync(string username)
{
    var response = await _httpClient.GetAsync($"/users/{username}");
    response.EnsureSuccessStatusCode();

    var json = await response.Content.ReadAsStringAsync();
    return JsonSerializer.Deserialize<User>(json);
}

As the number of endpoints grows, so does the ceremony around each one. You end up scattered across the codebase with:

  • Magic URL strings that drift away from the real API
  • Repeated serialization and deserialization boilerplate
  • Ad-hoc null checks and status code handling
  • No single place to see what the API surface actually looks like
  • Tricky unit tests that require mocking HttpClient directly

When an API has dozens of endpoints β€” all with their own headers, query parameters, and body shapes β€” this approach becomes expensive to maintain and easy to get wrong.

The Solution

Refit is a type-safe REST library for .NET, heavily inspired by Square’s Retrofit library for Java/Kotlin. It flips the model: instead of writing the HTTP plumbing yourself, you describe the API as a C# interface using attributes, and Refit generates the HttpClient implementation for you at build time using a Roslyn source generator.

The result is a clean, discoverable API contract that is easy to test, easy to read, and hard to misconfigure.

Getting Started

Install the NuGet package:

dotnet add package Refit

If you are using dependency injection (the recommended approach), also add:

dotnet add package Refit.HttpClientFactory

Defining the Interface

Describe your REST API as an interface. Each method maps to an HTTP endpoint using attributes:

public interface IGitHubApi
{
    [Get("/users/{username}")]
    Task<User> GetUserAsync(string username);

    [Get("/users/{username}/repos")]
    Task<List<Repository>> GetRepositoriesAsync(string username);

    [Post("/user/repos")]
    Task<Repository> CreateRepositoryAsync([Body] CreateRepositoryRequest request);

    [Delete("/repos/{owner}/{repo}")]
    Task DeleteRepositoryAsync(string owner, string repo);
}

The {username} placeholder in the URL is automatically filled by the matching method parameter. Any parameter not used in the URL becomes a query string parameter by default.

Registering with Dependency Injection

Refit integrates cleanly with IHttpClientFactory via the AddRefitClient<T> extension:

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddRefitClient<IGitHubApi>()
    .ConfigureHttpClient(c =>
    {
        c.BaseAddress = new Uri("https://api.github.com");
        c.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
    });

You can then inject IGitHubApi anywhere in your application:

public sealed class GitHubService
{
    private readonly IGitHubApi _gitHubApi;

    public GitHubService(IGitHubApi gitHubApi)
    {
        _gitHubApi = gitHubApi;
    }

    public async Task<User> GetUserAsync(string username)
    {
        return await _gitHubApi.GetUserAsync(username);
    }
}

No HttpClient, no JSON handling, no URL construction β€” just a method call.

Passing Query Parameters

Parameters not bound to the URL path are automatically added as query string parameters:

public interface IProductsApi
{
    [Get("/products")]
    Task<List<Product>> GetProductsAsync(string category, int page, int pageSize);
}

// Generates: GET /products?category=electronics&page=2&pageSize=20
var products = await api.GetProductsAsync("electronics", 2, 20);

For more complex query objects, use the [Query] attribute on a POCO:

public class ProductSearchParams
{
    public string? Category { get; set; }
    public decimal? MinPrice { get; set; }
    public decimal? MaxPrice { get; set; }

    [AliasAs("sort")]
    public string SortOrder { get; set; } = "asc";
}

public interface IProductsApi
{
    [Get("/products")]
    Task<List<Product>> SearchAsync([Query] ProductSearchParams parameters);
}

Sending a Request Body

Use the [Body] attribute to serialize an object as the request body:

public interface IOrdersApi
{
    [Post("/orders")]
    Task<Order> CreateOrderAsync([Body] CreateOrderRequest request);

    [Put("/orders/{id}")]
    Task<Order> UpdateOrderAsync(int id, [Body] UpdateOrderRequest request);
}

Refit uses System.Text.Json by default. The object is serialized to JSON automatically.

Adding Headers

Static headers can be applied at the interface or method level:

[Headers("Accept: application/json")]
public interface IPaymentsApi
{
    [Headers("X-Idempotency-Key: static-key")]
    [Post("/payments")]
    Task<Payment> CreatePaymentAsync([Body] PaymentRequest request);
}

For dynamic headers, add a [Header] parameter to the method:

public interface IPaymentsApi
{
    [Post("/payments")]
    Task<Payment> CreatePaymentAsync(
        [Body] PaymentRequest request,
        [Header("X-Idempotency-Key")] string idempotencyKey);
}

Bearer Authentication

For APIs that require a Bearer token, set AuthorizationHeaderValueGetter on RefitSettings and Refit will call it before each request:

builder.Services
    .AddRefitClient<IMyApi>(provider =>
    {
        var tokenService = provider.GetRequiredService<ITokenService>();

        return new RefitSettings
        {
            AuthorizationHeaderValueGetter = (request, cancellationToken) =>
                tokenService.GetAccessTokenAsync(cancellationToken)
        };
    })
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.example.com"));

Because the getter is called per request, token refresh logic lives in one place and applies automatically across all endpoints on the interface.

Handling Errors

By default, Refit throws an ApiException when it receives a non-success status code:

try
{
    var user = await _gitHubApi.GetUserAsync("unknown-user");
}
catch (ApiException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    // 404 β€” user does not exist
}
catch (ApiRequestException ex)
{
    // Request failed before a response was received (e.g. network error)
}

A cleaner alternative is to return IApiResponse<T>, which captures both the result and any error without throwing:

public interface IGitHubApi
{
    [Get("/users/{username}")]
    Task<IApiResponse<User>> GetUserAsync(string username);
}
var response = await _gitHubApi.GetUserAsync("unknown-user");

if (response.IsSuccessful)
{
    Console.WriteLine(response.Content!.Name);
}
else if (response.HasResponseError(out var apiException))
{
    Console.WriteLine($"API error {apiException.StatusCode}: {apiException.Content}");
}

This pattern is particularly useful when a non-2xx response is part of normal business logic and should not be treated as an exceptional case.

Unit Testing

Because the API is declared as an interface, mocking is straightforward with any popular mock library:

public class GitHubServiceTests
{
    [Fact]
    public async Task GetUser_ReturnsUser_WhenApiSucceeds()
    {
        var mockApi = Substitute.For<IGitHubApi>();
        mockApi.GetUserAsync("octocat").Returns(new User { Name = "The Octocat" });

        var service = new GitHubService(mockApi);
        var user = await service.GetUserAsync("octocat");

        Assert.Equal("The Octocat", user.Name);
    }
}

No HttpClient mocking, no fake HttpMessageHandler β€” just the interface.

Summary

Refit removes the mechanical work of writing HTTP clients in .NET. By describing your REST API as a typed interface, you get:

  • A single, readable contract for each external API
  • Automatic URL building, serialization, and deserialization
  • Easy authentication via AuthorizationHeaderValueGetter
  • Trivial unit testing through standard interface mocking
  • First-class integration with IHttpClientFactory and the .NET DI container

The less boilerplate you write, the less boilerplate you maintain.