In this post, I describe a scalable architectural approach to building typed HTTP API clients for ASP.NET Core applications. I discuss how following this approach can contribute to API testability and maintainability. I provide some practical examples, common pitfalls, and possible solutions.
Problem definition
Distributed systems are popular. HTTP APIs are very popular. And .NET is a reasonable tool for implementing both, starting from the classic RESTful APIs and microservices-oriented architecture.
While one of the arguments in favour of microservices is sharing the workload between multiple development teams using different programming languages (and who said anything about
There are several ways to save time and reduce the amount of repeated boilerplate code:
- Generate client code on each consumer using some form of specification from the producer (OpenAPI).
- Reuse existing client code (NuGet).
- Find a middle ground by using a high-level client builder.
And while this post revolves mostly around NuGet, it would be wrong of me not to mention the others.
High-level client builders
There are many third-party REST client builders for .NET on GitHub, and new ones keep popping up for some reason. Some of the alive and popular are:
Part of the community is quite happy using these libraries. However, based on my experience so far, and considering the occasional problems I've encountered (limitations, missing features, or questionable design), I wouldn't make a high-level builder the default choice in a large system. But never say never.
OpenAPI / Swagger
Swagger is a tool set based on the is a detailed list of .NET code generators. Alternatively, we can generate a document from the server code using
- As the root, there is Orders.Client class library containing a typed client and, naturally, all its input/output DTOs. This library is also published as NuGet package.
- Web project depends directly on Orders.Client, and reuses ApiModels as endpoints' inputs/outputs.
- Tests project reuses the typed client in API tests (I explain my definition of "API tests" in the next section).
- Finally, an external consumer of
POST /ordersreferences the Orders.Client NuGet package.
An example of an input/output DTO could be OrderCreateApiModel, into which the POST /orders request body is deserialized. By the way, I prefer public contracts like this one to follow some global naming convention, i.e. "ApiModel" in this case. Such a rule serves well as a big red sign that any thoughtless change of such type can break the API backward comparability.
Typical code for each project might look like this:
Orders.Client:
//omitted for brewity: interfaces, cancellation tokens, attributes etc.
public class OrdersClient(HttpClient httpClient) : IOrdersClient
{
public async Task<OrderApiModel> CreateOrderAsync(
CreateOrderApiModel input);
}
Orders.Web:
public OrderController : ApiController
{
async Task<OrderApiModel> CreateOrderAsync(
CreateOrderApiModel input);
}
Orders.Web.Tests (based on , , i.e. to follow that effectively reduces the cost of client-server emulation, and has full support for typed clients. Let's examine the benefits of reusing a typed client in tests:
Unified codebase. If you don't specify a team-wide convention for declaring a public contract in the form of a well-designed typed HTTP client... Then, it's an invitation to take a quick-and-dirty approach to writing API tests. Such as hardcoded calls to HttpClient, or at best a homemade typed client with potentially wrong serialization settings or a messed up HTTP handler pipeline.
Code coverage insights. Without a facade for making API calls, uncovered endpoints can only be reliably tracked at the coverage analysis stage. This is not bad, but we can do better. If the public method of a typed client has no usages, this usually means that it is not explicitly called in a test. In effect, it means that the referenced API endpoint itself is not covered. These client's unused methods are easy prey for static code analyzers, giving you compile-time test coverage insights before you even run a code coverage collector.
Therefore, even if there are no .NET consumers for your APIs, building a typed client is still recommended.
Giving API tests the right purpose
Then let's talk about what logic API tests should cover. Assuming you have separate sets of:
- End-to-end language-agnostic tests (this is where Swagger can help a lot), ideally run somewhere in a test environment.
- Unit and integration tests that cover business logic and data access layer.
Then there is little reason to play in their domain. On the contrary, at the API testing level, I'd like to have tested only the implementation details of the HTTP APIs:
- Endpoint address.
- (De)serialization of input/output data.
- Application-level error handling (usually implemented via ASP.NET exception handler or middleware).
- Middleware pipeline.
- Authorization policy.
Thus the less business logic involved in API tests, the better. Mock it. Make the tests simple, fast, isolated, and suitable for . Testing a fat controller may be tricky because of one big implicit dependency: HttpContext. In short, I recommend abstaining from manual controller creation in tests.
The other decorating handler
or a custom ), and deserializing it on the client side.
What could go wrong? Well, depending on the validator, we may have just duplicated our business logic to an unknown number of external systems, effectively reducing the effort of building a distributed application almost to zero. While validating the input data makes some sense, business rules (I have covered the difference in more detail .
Things often get ugly in a service that implements patterns:
Of course, you could just update all your (N+1) typed clients at once in case of a breaking change in the core library, but this approach doesn't scale well.
Again, avoid external dependencies unless it absolutely necessary, and think twice before adding anything to the library.
Summary
In this post, I described a scalable architectural approach to building typed HTTP API clients on top of ASP.NET Core applications. I outlined a number of practices that contribute to API testability and maintainability. I advocated the use of NuGet packages over autogenerated client code. I also talked about customizing the HTTP handler pipeline, adding client-side logic, and avoiding the "DLL hell" problem.

SOCIAL SHARE CARD GENERATOR