Composing focused Refit clients behind one connector
A few days ago, a coworker was working on a client library built with Refit and ran into a problem I had already faced before.
The backend had grown beyond a handful of endpoints. Keeping everything in one Refit interface would have made that interface a dumping ground, but exposing every focused client independently would have pushed the internal structure of the HTTP client onto every consumer.
I ended up pasting a simplified version of an approach I had used before into the code review.
The idea is simple: the HTTP client can be internally split by responsibility while still exposing one convenient dependency to the application.
Keep the Refit clients focused
Suppose the backend exposes separate areas for orders, customers, and invoices.
I would rather model those as separate Refit clients:
public interface IOrdersClient
{
[Get("/orders")]
Task<IReadOnlyList<Order>> GetOrdersAsync(
CancellationToken cancellationToken);
}
public interface ICustomersClient
{
[Get("/customers/{id}")]
Task<Customer> GetCustomerAsync(
Guid id,
CancellationToken cancellationToken);
}
public interface IInvoicesClient
{
[Get("/invoices/{id}")]
Task<Invoice> GetInvoiceAsync(
Guid id,
CancellationToken cancellationToken);
}
Each interface represents one cohesive endpoint family. The exact grouping depends on the API, but the important part is that adding more endpoints should not slowly turn one interface into a catalogue of unrelated operations.
This keeps the Refit contracts easier to understand and gives them a reason to change that is narrower than “the backend changed somewhere”.
The downside is that the application now has several clients to deal with.
If a page needs orders today and customer data tomorrow, its constructor starts to reflect the internal decomposition of the HTTP layer:
public sealed class OrdersModel(
IOrdersClient orders,
ICustomersClient customers) : PageModel
{
// ...
}
There is nothing technically wrong with that. I just do not want every consumer to have to know how I chose to split the HTTP client.
Compose them behind one connector
Instead, I expose the focused clients through a connector:
public interface IBackendConnector
{
IOrdersClient Orders { get; }
ICustomersClient Customers { get; }
IInvoicesClient Invoices { get; }
}
I keep Client for the actual Refit interfaces. Connector is the object the application uses to connect to the backend as a whole.
A Razor Page can then depend on a single backend-facing type while the call site still preserves the grouping:
public sealed class OrdersModel(IBackendConnector backend) : PageModel
{
public IReadOnlyList<Order> Orders { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
{
Orders = await backend.Orders.GetOrdersAsync(cancellationToken);
}
public async Task<IActionResult> OnPostRefreshCustomerAsync(
Guid customerId,
CancellationToken cancellationToken)
{
var customer = await backend.Customers.GetCustomerAsync(
customerId,
cancellationToken);
// Update the page state with the refreshed customer.
return Page();
}
}
The page has one dependency representing the backend, but using it does not flatten every endpoint into one giant interface.
backend.Orders and backend.Customers still tell me which part of the remote API I am talking to.
That is the part I care about most. The grouping remains visible and discoverable without turning the grouping itself into dependency-management work for every page.
The implementation is deliberately boring
The connector implementation does very little:
internal sealed class BackendConnector(
IOrdersClient orders,
ICustomersClient customers,
IInvoicesClient invoices) : IBackendConnector
{
public IOrdersClient Orders { get; } = orders;
public ICustomersClient Customers { get; } = customers;
public IInvoicesClient Invoices { get; } = invoices;
}
Yes, the constructor grows as more client families are added.
Yes, every new client also requires another property.
I do not particularly enjoy that manual work, but I prefer it to resolving dependencies from IServiceProvider just to avoid writing a few constructor parameters. The dependencies are explicit, and there is no need to introduce a service locator here.
The repetitive code is also confined to one composition point instead of being repeated throughout the application.
Registering the clients
The service registration is equally explicit:
services
.AddRefitClient<IOrdersClient>()
.ConfigureHttpClient(client =>
client.BaseAddress = new Uri(configuration["Backend:BaseUrl"]!));
services
.AddRefitClient<ICustomersClient>()
.ConfigureHttpClient(client =>
client.BaseAddress = new Uri(configuration["Backend:BaseUrl"]!));
services
.AddRefitClient<IInvoicesClient>()
.ConfigureHttpClient(client =>
client.BaseAddress = new Uri(configuration["Backend:BaseUrl"]!));
services.AddScoped<IBackendConnector, BackendConnector>();
There are obvious ways to reduce some of this repetition, especially when all clients share the same base address and handlers.
That is a separate problem, though. I would rather start with explicit registration and improve the boilerplate when it actually becomes painful than hide the dependencies behind clever infrastructure from day one.
When does this become useful?
A single Refit interface with one cohesive endpoint family does not need a connector around it.
The pattern starts earning its keep when you already know the client will have several distinct areas and you do not want that decomposition to leak into every consuming class.
I would not pick an arbitrary threshold such as three or five clients. The direction of the application matters more than the current count.
If the backend is clearly going to grow into orders, customers, invoices, reporting, administration, and other areas, introducing the composition boundary early can be simpler than changing every consumer later.
This is not an abstraction over Refit
There is an important limitation to this approach: it does not hide Refit.
The leaf interfaces are still Refit contracts. They still expose HTTP-shaped operations, DTOs, and whatever error semantics the client library uses.
The connector only changes how those contracts are composed and consumed.
If the goal is to isolate the application from the remote API, this is not enough.
A proper abstraction would instead expose operations in terms of what the application needs. Its interface would not necessarily mirror endpoint families, and its implementation could translate between application concepts and the HTTP API behind it.
That can be the right design when transport independence or strong separation from an external system is important.
It is also more code and a different problem.
For a client whose job is simply to provide a clean .NET API over a backend, I do not want to introduce that extra behavioral layer just to avoid injecting several Refit clients.
The trade-off
This pattern is intentionally small.
It adds another interface and implementation. The connector constructor grows. Registration grows. Adding a new endpoint family means updating the connector as well as registering the new Refit client.
In exchange, the HTTP contracts stay focused and consumers get one dependency representing the backend without losing the useful grouping at the call site.
Recap
The Refit clients and the dependency exposed to the rest of the application solve two different design problems.
I can keep the HTTP contracts split into focused endpoint families without requiring every page to inject those clients individually. A thin connector gives the application one backend-facing dependency while preserving the grouping where it is useful: at the call site.
There is some repetitive wiring involved, and the connector is not an abstraction over Refit. For this problem, that is fine. The goal is not to hide the HTTP client; it is to make a modular HTTP client convenient to consume.
The internal shape of the client does not have to become the dependency structure of the application.
Support this blog
If you liked this article, consider supporting this blog by buying me a pizza!