Minimal API filters: Run code before the endpoint handler

Published: Monday 1 September 2025

So far on my Minimal API discovery, I've found that it has similar functionality to controllers. Whether it's creating a route, adding parameters or returning responses.

But I found something that is much easier to setup in Minimal APIs. Introducing filters.

There is the IActionFilter or IAsyncActionFilter interface in controllers that allows you to write functionality when an endpoint is executing and after it's executed. But how you set up in Minimal APIs blew my mind!

How to set up filters in controllers

Before we look at that, that's look to see how painfully bloated it is to set up a filter in controllers.

First, you have to create a class that implements the IAsyncActionFilter interface.

Before the controller endpoint is run, we have to get the id out of the parameters and check if it's an integer. If those checks pass, we then see if the id is bigger than 9. If it is, we return a NotFoundObjectResult.

// ProductActionFilter.cs
public class ProductActionFilter : IAsyncActionFilter
{
	public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
	{
		if (context.ActionArguments.TryGetValue("id", out var value) && value is int id)
		{
			if (id > 9)
			{
				context.Result = new NotFoundObjectResult("");
				return;
			}
		}

		await next();
	}
}

We then need to register it using the ServiceFilter attribute against the controller endpoint.

// ProductController.cs
[Route("api/[controller]")]
[ApiController]
public class ProductController : ControllerBase
{
	[HttpGet("{id}")]
	[ServiceFilter(typeof(ProductActionFilter))]
	public IActionResult ReturnProduct(int id)
	{
		return Ok(new { Id = id, Name = "My product" });
	}
}

You'd think that would be enough? Oh no. You also have to register the filter in dependency injection.

// Program.cs
builder.Services.AddScoped<ProductActionFilter>();

So much code just to do something before a controller endpoint is executed.

Minimal APIs makes this much more simpler

Then I found that Minimal APIs makes this more simpler to code and read. This is all you need to do for the same functionality:

// Program.cs
app.MapGet("api/product-minimal/{id}",
	(int id) => new { Id = id, Name = "My product "})
		.AddEndpointFilter(async (invocationContext, next) =>
		{
			var id = invocationContext.GetArgument<int>(0);

			if (id > 9)
			{
				return TypedResults.NotFound();
			}

			return await next(invocationContext);
		});

That's it. No extra filter class. No service registering. Just use the AddEndpointFilter when registering your Minimal API endpoint. Either return a response or execute the endpoint handler by returning the next delegate.

Adding extra filters

But wait! There's more. You can add more filters. In fact, you can add as many filters as you want.

// Program.cs
app.MapGet("api/product-minimal/multiple-filters/{id}/{name}",
	(int id, string name) => new { Id = id, Name = name })
		.AddEndpointFilter(async (invocationContext, next) =>
		{
			var id = invocationContext.GetArgument<int>(0);

			if (id > 9)
			{
				return TypedResults.NotFound();
			}

			return await next(invocationContext);
		})
		.AddEndpointFilter(async (invocationContext, next) =>
		{
			var name = invocationContext.GetArgument<string>(1);

			if (name != "My product")
			{
				return TypedResults.NotFound();
			}

			return await next(invocationContext);
		});

The first endpoint filter will run and check if the id is bigger than 9. If it is, it results a NotFound response. Otherwise, it returns the next delegate.

When returning the next delegate, it runs the second endpoint filter. That returns a NotFound response if the name parameter does not equal My product.

The next delegate from the second endpoint filter will then run the endpoint and return the id and name of the product as there are no more endpoint filters to execute.

Running a filter after the endpoint is executed

So far, we are only executing code before the endpoint handler is run. But there is a way you can run code after it has been executed.

You store the next delegate as a variable, execute your code after it and then return that variable.

app.MapGet("api/product-minimal/multiple-filters-executed/{id}/{name}",
	(int id, string name) => new { Id = id, Name = name })
		.AddEndpointFilter(async (invocationContext, next) =>
		{
			var id = invocationContext.GetArgument<int>(0);
			var stopwatch = new Stopwatch();
			stopwatch.Start();

			if (id > 9)
			{
				return Results.NotFound();
			}

			var result = await next(invocationContext);
			stopwatch.Stop();
			Console.WriteLine("First endpoint filter executed in {0} secs", stopwatch.Elapsed.TotalSeconds.ToString("0.000000"));

			return result;
		})
		.AddEndpointFilter(async (invocationContext, next) =>
		{
			var name = invocationContext.GetArgument<string>(1);
			var stopwatch = new Stopwatch();
			stopwatch.Start();

			if (name != "My product")
			{
				return Results.NotFound();
			}

			var result = await next(invocationContext);

			stopwatch.Stop();
			Console.WriteLine("Second endpoint filter executed in {0} secs", stopwatch.Elapsed.TotalSeconds.ToString("0.000000"));

			return result;
		});

In this example, the second endpoint filter console log would be written before the first. This is because after the endpoint handler has been executed, it will go back in reverse order.

There's a lot of code in Program.cs

Not for the first time, I'm concerned about the amount of code in Program.cs. Here is what it looks like with all endpoints and their filters:

// Program.cs
app.MapGet("api/product-minimal/{id}",
	(int id) => new { Id = id, Name = "My product "})
		.AddEndpointFilter(async (invocationContext, next) =>
		{
			var id = invocationContext.GetArgument<int>(0);

			if (id > 9)
			{
				return TypedResults.NotFound();
			}

			return await next(invocationContext);
		});

app.MapGet("api/product-minimal/multiple-filters/{id}/{name}",
	(int id, string name) => new { Id = id, Name = name })
		.AddEndpointFilter(async (invocationContext, next) =>
		{
			var id = invocationContext.GetArgument<int>(0);

			if (id > 9)
			{
				return TypedResults.NotFound();
			}

			return await next(invocationContext);
		})
		.AddEndpointFilter(async (invocationContext, next) =>
		{
			var name = invocationContext.GetArgument<string>(1);

			if (name != "My product")
			{
				return TypedResults.NotFound();
			}

			return await next(invocationContext);
		});

app.MapGet("api/product-minimal/multiple-filters-executed/{id}/{name}",
	(int id, string name) => new { Id = id, Name = name })
		.AddEndpointFilter(async (invocationContext, next) =>
		{
			var id = invocationContext.GetArgument<int>(0);
			var stopwatch = new Stopwatch();
			stopwatch.Start();

			if (id > 9)
			{
				return Results.NotFound();
			}

			var result = await next(invocationContext);
			stopwatch.Stop();
			Console.WriteLine("First endpoint filter executed in {0} secs", stopwatch.Elapsed.TotalSeconds.ToString("0.000000"));

			return result;
		})
		.AddEndpointFilter(async (invocationContext, next) =>
		{
			var name = invocationContext.GetArgument<string>(1);
			var stopwatch = new Stopwatch();
			stopwatch.Start();

			if (name != "My product")
			{
				return Results.NotFound();
			}

			var result = await next(invocationContext);

			stopwatch.Stop();
			Console.WriteLine("Second endpoint filter executed in {0} secs", stopwatch.Elapsed.TotalSeconds.ToString("0.000000"));

			return result;
		});

All of a sudden, my ambition to use filters to make it more readable in Minimal APIs was falling flat on it's face.

There must be a way this can be added in a class? Well there is, all you need to do is to create a class that implements the IEndpointFilter interface and then call the InvokeAsync method:

// ProductMinimalEndpointFilter.cs
public class ProductMinimalEndpointFilter : IEndpointFilter
{
	public async ValueTask<object?> InvokeAsync(
		EndpointFilterInvocationContext invocationContext, 
		EndpointFilterDelegate next)
	{
		var id = invocationContext.GetArgument<int>(0);

		if (id > 9)
		{
			return TypedResults.NotFound();
		}

		return await next(invocationContext);
	}
}

You can then use it when calling the AddEndpointFilter extension method for your route:

// Program.cs
app.MapGet("api/product-minimal/standalone-filters/{id}",
    (int id) => new { Id = id, Name = "My product" })
        .AddEndpointFilter<ProductMinimalEndpointFilter>();

Much better!

Watch the video

When you watch this video, you'll see me try out writing each of the different ways that you can add filters to Minimal APIs and decide which way is the best way for you:

Before you watch the video, download the code example if you want to follow along with what I'm doing and not have to type any code.

Final thoughts

Filters is a game changer with Minimal APIs. It works so much better than filters with controllers and is much easier to follow.

As is common with Minimal APIs, there are concerns that the Program.cs file will become too big! But adding your filters into separate classes will calm these worries.

And one more benefit with Minimal API filters. When you register them, they are compiled on build. Not only will this mean that your app will start quicker, you will get any exceptions at build time.