4 minute read

In two previous posts, I explored two related problems in ASP.NET Core Minimal APIs.

The first showed how to discover and map endpoint classes at compile time using ServiceScan.SourceGenerator. The second showed how to move stable service dependencies to the endpoint constructor, making direct handler tests less sensitive to changes in the dependency list.

Each solution leaves one manual step behind.

Source-generated endpoint mapping works naturally with static endpoint classes, but constructor-injected endpoints must also be registered with dependency injection. Registering each class manually brings back the same composition problem that automatic route discovery was meant to remove:

builder.Services.AddScoped<CreateOrderEndpoint>();
builder.Services.AddScoped<GetOrderEndpoint>();
builder.Services.AddScoped<CancelOrderEndpoint>();

var app = builder.Build();

app.MapEndpoints();

The two approaches can be combined. The same compile-time convention can register every endpoint class as a scoped service and generate the calls that map its routes.

A shared endpoint contract

The endpoint contract still exposes only the static mapping method:

public interface IEndpoint
{
    static abstract void MapEndpoint(IEndpointRouteBuilder builder);
}

An endpoint implementation can be a regular class with constructor-injected dependencies while satisfying that static contract:

public sealed class CreateOrderEndpoint(
    IOrderRepository orders,
    ILogger<CreateOrderEndpoint> logger)
    : IEndpoint
{
    public static void MapEndpoint(IEndpointRouteBuilder builder)
    {
        builder.MapPost(
            "/orders",
            static (
                CreateOrderRequest request,
                CreateOrderEndpoint endpoint,
                CancellationToken cancellationToken) =>
                    endpoint.HandleAsync(request, cancellationToken));
    }

    internal 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 static method defines how the route is mapped. The endpoint instance contains the dependencies and handles the request.

ASP.NET Core resolves CreateOrderEndpoint from dependency injection when the route is invoked.

Generating the service registrations

ServiceScan.SourceGenerator can generate ordinary dependency-injection registrations for every type implementing IEndpoint.

The endpoint types must be registered as themselves because the route delegate asks for the concrete endpoint type:

public static partial class HttpEndpointRegistrationExtensions
{
    [GenerateServiceRegistrations(
        AssignableTo = typeof(IEndpoint),
        AsSelf = true,
        Lifetime = ServiceLifetime.Scoped)]
    public static partial IServiceCollection AddEndpoints(
        this IServiceCollection services);
}

At compile time, the generator emits registrations equivalent to:

services
    .AddScoped<CreateOrderEndpoint>()
    .AddScoped<GetOrderEndpoint>()
    .AddScoped<CancelOrderEndpoint>();

No runtime assembly scanning is involved, and adding another IEndpoint implementation automatically adds its service registration.

Generating the route mappings

The same extension class can also generate the endpoint mapping method:

public static partial class HttpEndpointRegistrationExtensions
{
    [GenerateServiceRegistrations(
        AssignableTo = typeof(IEndpoint),
        AsSelf = true,
        Lifetime = ServiceLifetime.Scoped)]
    public static partial IServiceCollection AddEndpoints(
        this IServiceCollection services);

    [ScanForTypes(
        AssignableTo = typeof(IEndpoint),
        Handler = nameof(IEndpoint.MapEndpoint))]
    public static partial IEndpointRouteBuilder MapEndpoints(
        this IEndpointRouteBuilder builder);
}

The second generated method emits calls equivalent to:

CreateOrderEndpoint.MapEndpoint(builder);
GetOrderEndpoint.MapEndpoint(builder);
CancelOrderEndpoint.MapEndpoint(builder);

The two methods scan for the same contract but participate in different phases of application startup.

The resulting composition root

Program.cs now contains one call before building the application and one call after:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpoints();

var app = builder.Build();

app.MapEndpoints();

app.Run();

AddEndpoints makes the endpoint instances available to dependency injection.

MapEndpoints adds their routes to the application.

This distinction matters because the service collection can only be modified before builder.Build(), while routes are mapped on the resulting application.

What happens when a request arrives

The generated code is used only during application composition.

When a request matches /orders, normal Minimal API binding takes over:

  1. ASP.NET Core binds CreateOrderRequest from the request.
  2. It resolves CreateOrderEndpoint from the current dependency-injection scope.
  3. It provides the request cancellation token.
  4. The route delegate forwards the request-bound values to HandleAsync.

The source generator does not instantiate endpoints or resolve their dependencies. It only generates the service registrations and route-mapping calls that would otherwise be written manually.

Testing remains direct

The endpoint can still be instantiated directly in a unit test:

[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 result = await endpoint.HandleAsync(
            new CreateOrderRequest([]),
            CancellationToken.None);

        Assert.That(result.Result, Is.InstanceOf<BadRequest>());
    }
}

The generated composition does not introduce a mediator, dispatcher, or runtime abstraction between the test and the endpoint behavior.

One convention, two responsibilities

Implementing IEndpoint now has two consequences:

  • the concrete endpoint class is registered as a scoped service;
  • its static MapEndpoint method is invoked during route composition.

That is convenient, but it also makes the convention more significant. A type should not implement IEndpoint unless it is intended to participate in both behaviors.

It also means all endpoint implementations use the same lifetime. Scoped is a natural default because endpoint dependencies commonly include scoped services, but the lifetime is now part of the convention and should be documented.

Trade-offs

This pattern removes repetitive registration without hiding work at runtime. The generated code remains ordinary C# and can be inspected in the IDE.

It still introduces some implicit composition:

  • adding an IEndpoint implementation changes both DI registration and route mapping;
  • all discovered endpoint types are registered with the same lifetime;
  • route ordering should not be used to encode application behavior;
  • conditional endpoint registration requires a more explicit mechanism;
  • teams must understand that the static mapper and instance handler serve different roles.

For small applications, explicit calls may remain easier to follow. The pattern becomes more valuable when the application has enough endpoints that manual registration is repetitive and easy to forget.

Recap

Constructor injection and source-generated endpoint discovery solve different parts of the same composition problem.

Constructor injection separates stable service dependencies from request-bound handler parameters. Source generation removes the need to manually register and map every endpoint class.

By generating both AddEndpoints() and MapEndpoints(), an endpoint only needs to implement IEndpoint. The compiler-generated composition registers the class as scoped, maps its static route definition, and leaves ASP.NET Core responsible for constructing the endpoint instance when a request arrives.

The endpoint mapper and handler remain together, tests stay direct, and Program.cs no longer grows with every new endpoint.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!