Making ASP.NET Core Minimal API handlers easier to test
Minimal APIs make it easy to keep an endpoint close to the code that handles it.
A common approach is to define a static endpoint class with a mapping method and a static handler:
public static class CreateOrderEndpoint
{
public static void MapEndpoint(IEndpointRouteBuilder builder)
{
builder.MapPost("/orders", HandleAsync);
}
public static async Task<Results<Created<OrderResponse>, BadRequest>> HandleAsync(
CreateOrderRequest request,
IOrderRepository orders,
CancellationToken cancellationToken)
{
if (request.Items.Count == 0)
{
return TypedResults.BadRequest();
}
var order = Order.Create(request.Items);
await orders.SaveAsync(order, cancellationToken);
return TypedResults.Created(
$"/orders/{order.Id}",
new OrderResponse(order.Id));
}
}
Unlike an inline lambda, the handler is directly reachable from a test:
[Test]
public async Task Empty_orders_are_rejected()
{
var request = new CreateOrderRequest([]);
var result = await CreateOrderEndpoint.HandleAsync(
request,
A.Fake<IOrderRepository>(),
CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<BadRequest>());
}
There is nothing inherently difficult to test about this shape.
The friction appears later, when the endpoint gains more dependencies.
When dependencies become part of every test call
Suppose the endpoint needs a logger:
public static async Task<Results<Created<OrderResponse>, BadRequest>> HandleAsync(
CreateOrderRequest request,
IOrderRepository orders,
ILogger<CreateOrderEndpoint> logger,
CancellationToken cancellationToken)
Every test that calls HandleAsync must now provide a logger, even when the test has nothing to do with logging:
var result = await CreateOrderEndpoint.HandleAsync(
request,
orders,
NullLogger<CreateOrderEndpoint>.Instance,
CancellationToken.None);
Adding validation, a clock, an event publisher, or another collaborator repeats the same work.
The handler signature is carrying two different kinds of parameters:
- values that belong to the current HTTP request;
- services that belong to the endpoint implementation.
Request values naturally change between calls. Application services usually do not.
MVC controllers separate those concerns through constructor injection. Minimal API endpoints can use the same idea without becoming controllers.
Moving services to the constructor
The endpoint can become a regular class while keeping its mapping method and handler together:
public sealed class CreateOrderEndpoint(
IOrderRepository orders,
ILogger<CreateOrderEndpoint> logger)
{
public static void MapEndpoint(IEndpointRouteBuilder builder)
{
builder.MapPost(
"/orders",
static (
CreateOrderRequest request,
CreateOrderEndpoint endpoint,
CancellationToken cancellationToken) =>
endpoint.HandleAsync(request, cancellationToken));
}
public async Task<Results<Created<OrderResponse>, BadRequest>> HandleAsync(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
if (request.Items.Count == 0)
{
return TypedResults.BadRequest();
}
var order = Order.Create(request.Items);
await orders.SaveAsync(order, cancellationToken);
logger.LogInformation(
"Created order {OrderId}",
order.Id);
return TypedResults.Created(
$"/orders/{order.Id}",
new OrderResponse(order.Id));
}
}
The endpoint remains the unit of organization. The route mapping, dependencies, and application behavior are still in the same class.
Only the dependency model changes.
The route handler now receives:
- the request from the HTTP body;
- the endpoint instance from dependency injection;
- the cancellation token from the current request.
Its only responsibility is to forward the request-bound values to the instance handler.
Registering the endpoint
Because ASP.NET Core resolves the endpoint instance from dependency injection, the class must be registered:
builder.Services.AddScoped<CreateOrderEndpoint>();
The route itself is still mapped through the static method:
CreateOrderEndpoint.MapEndpoint(app);
This can also be combined with automatic endpoint discovery, as described in my post about automatically registering Minimal API endpoints. The testability pattern does not depend on source generation, however. It works equally well with explicit endpoint registration.
In a future article, I will combine the two approaches so that source generation both registers injectable endpoint classes as scoped services and maps their routes.
Testing the instance handler
The test creates the endpoint once and invokes the handler with only the parameters that vary per operation:
[TestFixture]
public class CreateOrderEndpointTests
{
private IOrderRepository orders = null!;
private CreateOrderEndpoint endpoint = null!;
[SetUp]
public void SetUp()
{
orders = A.Fake<IOrderRepository>();
endpoint = new CreateOrderEndpoint(
orders,
NullLogger<CreateOrderEndpoint>.Instance);
}
[Test]
public async Task Empty_orders_are_rejected()
{
var request = new CreateOrderRequest([]);
var result = await endpoint.HandleAsync(
request,
CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<BadRequest>());
}
[Test]
public async Task Valid_orders_are_saved()
{
var request = new CreateOrderRequest(
[
new CreateOrderItemRequest("SKU-123", 2)
]);
await endpoint.HandleAsync(
request,
CancellationToken.None);
A.CallTo(() => orders.SaveAsync(
A<Order>._,
CancellationToken.None))
.MustHaveHappenedOnceExactly();
}
}
Adding another constructor dependency still requires updating the test fixture. It does not, however, require changing every invocation of HandleAsync.
That distinction becomes useful when an endpoint has several tests and its collaborators continue to evolve.
Keeping HTTP concerns at the boundary
The mapped delegate remains the HTTP-facing boundary of the endpoint.
It can receive values through the normal Minimal API binding rules and pass only the values needed by the application behavior:
builder.MapPut(
"/orders/{orderId:guid}",
static (
Guid orderId,
UpdateOrderRequest request,
CreateOrderEndpoint endpoint,
CancellationToken cancellationToken) =>
endpoint.HandleAsync(
orderId,
request,
cancellationToken));
This is particularly useful when the HTTP surface and the handler signature should not be identical.
The delegate can deal with route values, claims, headers, or result marshalling while the instance method exposes a smaller application-oriented API.
Is this still a Minimal API?
Yes.
The endpoint still uses:
MapPost,MapPut, and the other Minimal API mapping methods;- Minimal API parameter binding;
- typed results;
- endpoint metadata and filters;
- the normal ASP.NET Core dependency-injection container.
No controller discovery, action methods, or MVC model is introduced.
The endpoint class simply uses constructor injection for its stable collaborators, much like a controller would.
Trade-offs
This shape adds some ceremony.
The endpoint class must be registered, and ASP.NET Core creates an instance for each scope. The mapped delegate also adds a small adapter between the HTTP runtime and the instance method.
For an endpoint with one dependency and one or two tests, a static handler may remain the clearest solution.
Constructor injection becomes more valuable when:
- the endpoint has several collaborators;
- the endpoint has many direct unit tests;
- dependencies change more often than request parameters;
- keeping the endpoint mapper and handler together is desirable.
A growing constructor is not automatically an improvement either. It may reveal that the endpoint is taking on too many responsibilities and should delegate more behavior to an application service.
Recap
Static Minimal API handlers are already easy to test when they are exposed as methods instead of hidden inside inline lambdas.
The maintenance problem appears when every service dependency becomes part of every test invocation.
Turning the endpoint into an injectable class lets request-bound parameters stay on the handler method while stable collaborators move to the constructor. The endpoint mapper and handler remain together, but tests become less sensitive to changes in the endpoint’s dependency list.
Class-based endpoints are not inherently more testable. They can make an existing suite of direct handler tests easier to maintain as the implementation evolves.
Support this blog
If you liked this article, consider supporting this blog by buying me a pizza!