The evolution of minimal APIs in 2026

15 Feb 2026 5 min See Original (spanish)

It has been more than four years since Microsoft released Minimal APIs with the main idea of reducing the boilerplate code that C# is so well known for. 

In this post, we are going to look at the current state and what we can do today with this feature, which is becoming more popular every day.

 

 

1 - The first implementations

 

When it all started, the implementations were not pretty: all Minimal APIs had to live in Program.cs, or you had to do juggling to place them in other files. We already covered this in a video in 2021, but we are in 2026 now and things have changed.

 

Microsoft has been "taking inspiration" from other open-source libraries that were very popular, such as FastAPI or Nancy, to keep improving the implementation and make it more developer-friendly. 

 

 

1.1 - The classic Controllers structure

 

Let’s use as an example the code from my book The complete full stack guide with .NET, where I have a core-driven structure using classic controllers.

This means I have a controller that acts as a proxy between the HTTP call and the use case:

[Authorize]
[Route("api/[controller]")]
[ApiController]
[ProducesResponseType(typeof(ProblemDetails),StatusCodes.Status500InternalServerError)]
public class FlagsController(FlagsUseCases flagsUseCases) : ControllerBase
{
	[ProducesResponseType(typeof(ResultDto<bool>), StatusCodes.Status200OK)]
	[ProducesResponseType(typeof(ResultDto<bool>), StatusCodes.Status404NotFound)]
	[HttpGet("{flagname}")]
	public async Task<IActionResult> GetSingleFlag(string flagname)
		=> await flagsUseCases.Get
			.Execute(flagname)
			.Map(a => a.IsEnabled)
			.ToActionResult();
		
	[ProducesResponseType(typeof(ResultDto<bool>), StatusCodes.Status200OK)]
	[ProducesResponseType(typeof(ResultDto<bool>), StatusCodes.Status404NotFound)]
	[HttpDelete("{flagname}")]
	public async Task<IActionResult> Delete(string flagname)
		=> await flagsUseCases.Delete
			.Execute(flagname)
			.ToActionResult();
		
	[ProducesResponseType(typeof(Result<FlagDto>), StatusCodes.Status200OK)]
	[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
	[HttpPut("{flagName}")]
	public async Task<IActionResult> Update(UpdateFlagDto flagDto,  string flagName)
		=> await flagsUseCases.Update
			.Execute(new FlagDto()
			{
				Name = flagName,
				IsEnabled = flagDto.IsEnabled,
			})
			.ToValueOrProblemDetails();
		
	[ProducesResponseType(typeof(Result<FlagDto>), StatusCodes.Status200OK)]
	[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
	[HttpPost]
	public async Task<IActionResult> Create(FlagDto flagDto)
		=> await flagsUseCases.Add
			.Execute(flagDto.Name, flagDto.IsEnabled)
			.ToValueOrProblemDetails();
}

This architecture relies on two main pillars:

- Single Responsibility Principle (SRP): One file, one action.

- CQRS: A clear separation between reads and writes

 

This means that to do a GET by ID we have an endpoint that acts as a proxy to a class containing the logic.

To create, we have an endpoint that acts as a proxy to a file containing the logic.

The same goes for updating, or for performing the different actions that the design of our API allows. 

 

Also, each controller is decorated with a set of attributes that can be configured either at controller level or at a specific endpoint level. 

And as we saw in the example, since controllers act only as a proxy, they are quite clean.

 

 

2 - Structuring Minimal APIs with IEndpointRouteBuilder

 

This is where the IEndpointRouteBuilder type comes in. My opinion about Minimal APIs changed thanks to it. This type allows us, through extension methods, to structure routes in a static and organized way, removing that "hack" we had in the early versions.

 

This way, the result is very similar to what we had before:

public static class FlagsApiEndpoints
{
    public static IEndpointRouteBuilder MapFlagsApiEndpoints(this IEndpointRouteBuilder endpoints)
    {
        var group = endpoints.MapGroup("/api/flags-minimal")
                .RequireAuthorization()
            ;

        group.MapGet("/{flagname}", async (string flagname, GetSingleFlagUseCase getSingleFlag)
                => await getSingleFlag
                    .Execute(flagname))
            .WithName("FlagsApiGetSingle")
            .WithSummary("Whatever")
            .Produces<ResultDto<bool>>(StatusCodes.Status200OK)
            .Produces<ResultDto<bool>>(StatusCodes.Status404NotFound);

        group.MapDelete("/{flagname}", async (string flagname, DeleteFlagUseCase deleteFlagUseCase)
                => await deleteFlagUseCase
                    .Execute(flagname))
            .WithName("deleteFlag")
            .Produces<ResultDto<bool>>(StatusCodes.Status200OK)
            .Produces<ResultDto<bool>>(StatusCodes.Status404NotFound);

        group.MapPut("/{flagName}",
                async (UpdateFlagDto flagDto, string flagname, UpdateFlagUseCase updateFlagUseCase)
                    => await updateFlagUseCase
                        .Execute(new FlagDto()
                        {
                            Name = flagname,
                            IsEnabled = flagDto.IsEnabled,
                        }))
            .WithName("updateFlag")
            .Produces<ResultDto<bool>>(StatusCodes.Status200OK)
            .Produces<ProblemDetails>(StatusCodes.Status404NotFound);

        group.MapPost("", async (FlagDto flagDto, AddFlagUseCase createFlagUseCase)
                => await createFlagUseCase
                    .Execute(flagDto.Name, flagDto.IsEnabled))
            .WithName("create flag")
            .Produces<ResultDto<bool>>(StatusCodes.Status200OK)
            .Produces<ProblemDetails>(StatusCodes.Status409Conflict);

        return endpoints;
    }
}

If you look closely, in both cases we have the same requirements. The way we declare them changes, but they all have routes, they all have authorization, they all specify the response type, etc. 

 

So, it seems the change is not that big, it is practically the same but, if we can create this information in a single file, what is stopping us from separating it a bit more? 

 

 

 

2.1- Using Handlers in Minimal APIs

 

Minimal APIs allow us to use a handler (a delegate or method) that separates the declaration of the API route from the logic itself. This is useful when the API layer needs to do things like quickly validate something or transform data before touching the business logic.

public static class DeleteFlagAlmostExtreme
{
    public static RouteGroupBuilder DeleteFlagEndpoint(this RouteGroupBuilder group)
    {
        group.MapDelete("/{flagname}", HandleAsync)
            .WithName("deleteFlag")
            .Produces<ResultDto<bool>>(StatusCodes.Status200OK)
            .Produces<ResultDto<bool>>(StatusCodes.Status404NotFound);

        return group;
    }

    private static async Task<IResult> HandleAsync(string flagname, [FromServices] DeleteFlagUseCase deleteFlagUseCase)
    {
        return (IResult)await deleteFlagUseCase
            .Execute(flagname)
            .ToValueOrProblemDetails();
    }
}

As we can see in the example, the API declaration is in one method, while the logic of what would be the controller is in another method that we call from it. This can be very useful if you have Minimal APIs and you also have logic in the controller, because this way everything becomes much cleaner.

 

 

3 - Extreme simplification of .NET APIs with the REPR pattern

 

As always, things can be taken to the extreme, and this is where I want to get with this post, because we can bring Minimal APIs, CQRS, and SRP, all of it, into the same file. 

First we need a parent class that will be in charge of invoking everything.

public static class FlagsApiEndpointsExtreme
{
    public static IEndpointRouteBuilder MapFlagsApiEndpointsExtreme(this IEndpointRouteBuilder endpoints)
    {
        return endpoints.MapGroup("/api/flags-minimal-extreme")
            .RequireAuthorization()
            .AddFlagEndpoint()
            .GetFlagEndpoint()
            .DeleteFlagEndpoint();
    }
}

 

Then what we are going to do is create both the endpoint and the use case we need in the same file. This would be very similar to an implementation of the REPR (Request-Endpoint-Response) pattern, which you can also do with other libraries.  

public static class GetFlagExtreme
{
    public static RouteGroupBuilder GetFlagEndpoint(this RouteGroupBuilder group)
    {
        group.MapGet("/{flagname}", async (string flagname, GetSingleFlagUseCase getSingleFlag)
                => await getSingleFlag
                    .Execute(flagname))
            .WithName("FlagsApiGetSingle")
            .WithSummary("Whatever")
            .Produces<ResultDto<bool>>(StatusCodes.Status200OK)
            .Produces<ResultDto<bool>>(StatusCodes.Status404NotFound);

        return group;
    }
    
    public class GetSingleFlagUseCase(ApplicationDbContext applicationDbContext)
    {
        public async Task<Result<FlagDto>> Execute(string flagName)
            => await GetFromDb(flagName)
                .Bind(flag => flag ?? Result.NotFound<FlagEntity>("FlagDoes not exist"))
                .Map(x => x.ToDto());

        private async Task<Result<FlagEntity?>> GetFromDb(string flagname)
            => await applicationDbContext.Flags
                .Where(a => a.Name.Equals(flagname, StringComparison.InvariantCultureIgnoreCase))
                .AsNoTracking()
                .FirstOrDefaultAsync();
    }
}

 

Does this break SRP?

You might think so, but technically we are separating the transport definition (static class) from the execution of the logic (instance). It is extremely high functional cohesion. Also, if tomorrow you need to trigger that logic from a Worker or a bus event, nothing stops you from moving the Handler to the Application layer while keeping the IEndpointRouteBuilder interface in the API layer.

 

What we gain is brutal development speed and ease of maintenance: if the "Get User" fails, you go to the GetUser.cs file and there you have everything (the route, the permissions, and the logic).

This post was translated from Spanish. You can see the original one here.
If there is any problem you can add a comment bellow or contact me in the website's contact form

© copyright 2026 NetMentor | Todos los derechos reservados | RSS Feed

Buy me a coffee Invitame a un café