Back to: ASP.NET Core Web API Tutorials
Endpoint Filters in ASP.NET Core Minimal API
In this article, I will discuss how to Implement Endpoint Filters in ASP.NET Core Minimal API with examples. Please read our previous articles discussing ASP.NET Core Minimal API using Entity Framework Core (EF Core) with Examples. We will be working with the same project as we worked so far with ASP.NET Core Minimal API.
Endpoint Filters in ASP.NET Core Minimal API
ASP.NET Core Minimal APIs introduce a lightweight and flexible way to build HTTP APIs with minimal repetitive code. With this new approach, Endpoint Filters were also introduced (starting with .NET 7) as a powerful mechanism to encapsulate common logic that can be executed before and/or after an endpoint handler.
Endpoint Filters enable us to intercept requests and responses around Minimal API endpoints, much like middleware or MVC filters, but at the endpoint level. They provide a clean way to add reusable pre- and post-processing logic, such as validation, authentication, logging, and caching, without cluttering endpoint handlers.
What Are Endpoint Filters?
Endpoint Filters are a feature in ASP.NET Core Minimal APIs that allow us to add custom logic around our endpoint handlers, before, after, or even instead of running the handler code. They act very much like middleware, but instead of being global for the whole app, they are attached to individual endpoints. Think of them as local middleware or action filters (from MVC), but purpose-built for Minimal APIs.
- Per-Endpoint Middleware: While app-level middleware (such as UseAuthentication and UseRouting) applies to every request, endpoint filters only apply to the specific endpoint(s) to which they are attached. This means we can have fine-grained control, one endpoint can have a logging filter, another can have a caching filter, and so on.
- Reusable or Inline: We can write filters as reusable classes, making them easy to share across endpoints, or we can define them inline as delegates or lambdas for quick, one-off use.
- Lifecycle Control: Filters can execute before our endpoint handler (pre-processing), after (post-processing), or even instead of the handler (e.g., short-circuit and return early if some condition fails).
- Access and Modification: They can read and modify endpoint parameters, inspect or modify the response, and even halt further processing—great for validation, error handling, and other purposes.
Real-World Use Cases for Endpoint Filters
Endpoint Filters are ideal for implementing common patterns required across multiple APIs. The following are some real-time use cases of Endpoint Filters in ASP.NET Core Minimal APIs.
- Request Validation: Automatically validate input models using data annotations or custom logic. If validation fails, the filter can return a 400 Bad Request response immediately, preventing the endpoint from running with invalid data.
- Authorization: Check user permissions or roles before allowing access to an endpoint. The filter can short-circuit unauthorized requests by returning 401 or 403 responses without executing the endpoint logic.
- Custom Logging: Log request details (method, path, parameters) and/or response details for auditing, debugging, or performance monitoring. Filters can capture this information systematically on each request.
- Exception Handling: Catch exceptions thrown by the endpoint and transform them into consistent, user-friendly error responses (e.g., JSON problem details), improving API reliability and client experience.
- Performance Measurement: Measure the time taken by the endpoint handler to execute and log or report it. This helps identify slow endpoints and optimize performance.
Scenario 1: Model Validation Filter (Reusable Class)
A Model Validation Filter is designed to validate incoming request data (such as an Employee or Product object) using .NET’s built-in Data Annotations. This ensures the API receives only valid data and responds with meaningful errors if validation fails.
How It Works
- The filter intercepts the request before it reaches your endpoint logic.
- It looks for the model instance (from the request body) among the parameters.
- It checks all data annotation rules (such as [Required], [StringLength], and [Range]).
- If the model is invalid, the filter immediately returns a 400 Bad Request with all validation errors, so your endpoint handler never runs.
- If the model is valid, the filter lets the request proceed to the actual endpoint handler.
So, create a class file named ValidationEndPointFilter.cs within the Models folder and then copy and paste the following code. The following End Point filter will perform model validation based on Data Annotations and short-circuit if the model is invalid.
using System.ComponentModel.DataAnnotations; namespace MinimalAPIDemo.Models { // This generic endpoint filter validates incoming models using Data Annotations. // TModel: The type of the model to validate (e.g., Employee, Product, etc.) public class ValidationEndPointFilter<TModel> : IEndpointFilter where TModel : class { // This method is called for each request to an endpoint that uses this filter // context: Provides access to arguments, HttpContext, etc. // next: Delegate to call the next filter or the endpoint handler itself. public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { // Extract the first argument from the endpoint's parameters that matches type TModel // This assumes the model to validate is passed as a parameter to the endpoint // This is typically the deserialized request body model (e.g., Employee, Product, etc). var model = context.Arguments.OfType<TModel>().FirstOrDefault(); // If no model of the expected type is found in the arguments, // immediately return a 400 Bad Request indicating the payload is missing or invalid. if (model == null) return Results.BadRequest($"Missing payload of type {typeof(TModel).Name}"); // Prepare a list to store any validation errors found during model validation. var validationResults = new List<ValidationResult>(); // Create a validation context describing the model to validate // This provides metadata and contextual information needed for validation var validationContext = new ValidationContext(model); // Use the Validator to check all properties on the model against their Data Annotations. // The 'true' parameter means validate all properties (not just top-level). if (!Validator.TryValidateObject(model, validationContext, validationResults, true)) { // If validation fails, extract all error messages from validationResults var errors = validationResults.Select(r => r.ErrorMessage).ToArray(); // Return HTTP 400 Bad Request with a detailed response object // containing a message and list of validation error messages return Results.BadRequest(new { Message = "Validation failed", Errors = errors }); } // If validation succeeds, invoke the next delegate in the filter pipeline // This calls the actual endpoint handler or next filter return await next(context); } } }
Why Is This Powerful?
- Centralized validation: No need to write the same validation code in every endpoint.
- Keeps handlers clean: Handlers focus only on business logic, not on validation.
- Consistent error response: Clients always receive the same error structure for validation failures.
Scenario 2: Logging Filter with Request Timing
A Logging Filter provides detailed logging for every request made to an endpoint, tracking when a request starts, when it finishes, and how long it takes.
How It Works
- As soon as a request hits the endpoint, the filter logs the HTTP method and path (e.g., GET /employees/1) and starts a stopwatch.
- It calls the endpoint handler (or the next filter).
- After the handler completes, the filter stops the stopwatch and logs the total time taken.
- This filter is ideal for performance monitoring, diagnostics, and auditing.
So, create a class file named LoggingEndPointFilter.cs within the Models folder and then copy and paste the following code.
using System.Diagnostics; namespace MinimalAPIDemo.Models { // This filter logs request details and measures the execution time for Minimal API endpoints. public class LoggingEndPointFilter : IEndpointFilter { // Logger instance injected via constructor, used to write log messages private readonly ILogger<LoggingEndPointFilter> _logger; // Constructor receives an ILogger from dependency injection public LoggingEndPointFilter(ILogger<LoggingEndPointFilter> logger) { _logger = logger; } // This method is called for each request that the filter is applied to public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { // Start a stopwatch to measure how long the endpoint takes to execute. var stopwatch = Stopwatch.StartNew(); // Log the beginning of the request, including HTTP method and endpoint path. _logger.LogInformation($"Starting request {context.HttpContext.Request.Method} {context.HttpContext.Request.Path}"); // Call the next delegate in the pipeline (next filter or endpoint handler) var result = await next(context); // Stop the stopwatch since endpoint processing is done. stopwatch.Stop(); // Log the completion of the request, including HTTP method, path, and elapsed time in milliseconds. _logger.LogInformation($"Finished request {context.HttpContext.Request.Method} {context.HttpContext.Request.Path} in {stopwatch.ElapsedMilliseconds} ms"); // Return the result from the endpoint (or the next filter). return result; } } }
Why Use This?
- Quickly identify slow endpoints and bottlenecks.
- Gain visibility into API usage and performance patterns.
- Essential for troubleshooting and production diagnostics.
Scenario 3: Caching Endpoint Filter for ASP.NET Core Minimal API
A Caching Filter improves performance and reduces server load by storing (caching) responses for frequently repeated requests.
How It Works
- When a request arrives, the filter checks if a cached response exists for that unique request (using a cache key made from HTTP method, path, and query string).
- If found (cache hit): Returns the cached response immediately, skipping endpoint logic and saving time and resources.
- If not found (cache miss): The endpoint handler is executed, the response is cached, and then returned to the client.
- Cached responses expire after a specified duration (e.g., 30 seconds).
So, create a class file named CachingEndPointFilter.cs within the Models folder and then copy and paste the following code. We are using IMemoryCache (built-in ASP.NET Core memory cache) for caching.
using Microsoft.Extensions.Caching.Memory; namespace MinimalAPIDemo.Models { // This endpoint filter adds caching to Minimal API endpoints using in-memory cache. public class CachingEndPointFilter : IEndpointFilter { // Memory cache instance injected from DI container for storing cached data private readonly IMemoryCache _cache; // Duration for which the cached response should be stored private readonly TimeSpan _cacheDuration; // Constructor accepts the memory cache instance and an optional cache duration (defaults to 30 seconds) public CachingEndPointFilter(IMemoryCache cache, TimeSpan? cacheDuration = null) { _cache = cache; // Use provided cache duration or default to 30 seconds if null _cacheDuration = cacheDuration ?? TimeSpan.FromSeconds(30); } // This method intercepts endpoint execution to provide caching behavior public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { // Get the HttpContext to access request details var httpContext = context.HttpContext; // Generate a unique cache key based on HTTP method, request path, and query string // Ensures different requests (e.g., different query params) cache separately var cacheKey = $"{httpContext.Request.Method}:{httpContext.Request.Path}{httpContext.Request.QueryString}"; // Try to get the cached response from the cache using the cache key if (_cache.TryGetValue(cacheKey, out object? cachedResponse)) { // If cached response exists, return it immediately without calling the endpoint handler return cachedResponse; } // If no cached response found, proceed to call the actual endpoint handler (or next filter) var result = await next(context); // If the result is an IResult (typically an API response), cache it. // You can customize this condition if you want more control over what gets cached. if (result is IResult) { // Store the result in memory cache with the computed cache key and expiration duration _cache.Set(cacheKey, result, _cacheDuration); } // Return the fresh result from the endpoint handler return result; } } }
Why Use This?
- Dramatically reduces latency for repeated GET requests.
- Reduces database or resource usage for popular endpoints.
- Ensures clients get fast, consistent responses during the cache duration.
Execution Order of Endpoint Filters
- Filters are invoked in the order you add them.
- The first added filter runs first before the endpoint handler.
- When the endpoint handler completes, filters unwind in reverse order.
Using the Endpoint Filters in Minimal API:
Please modify the Program class as follows to register the Endpoint filters into the dependency injection container and then use endpoint filters with Minimal API endpoints. At the end of the endpoint method, we apply custom endpoint filters using the AddEndpointFilter generic method.
using Microsoft.EntityFrameworkCore; using MinimalAPIDemo.Models; namespace MinimalAPIDemo { public class Program { public static void Main(string[] args) { // Create the WebApplication builder which prepares the app with default configs var builder = WebApplication.CreateBuilder(args); // Configure logging providers builder.Logging.ClearProviders(); // Remove any default logging providers builder.Logging.AddConsole(); // Add console logger (shows logs in terminal) builder.Logging.AddDebug(); // Add debug logger (for IDE/debugging tools) builder.Services.AddMemoryCache(); // Add memory cache service // Configure JSON serialization options for HTTP responses builder.Services.ConfigureHttpJsonOptions(options => { // Disable camelCase conversion; keep property names as declared (PascalCase) options.SerializerOptions.PropertyNamingPolicy = null; }); // Add Swagger/OpenAPI support services for API documentation and UI generation builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // Register your repositories and filters builder.Services.AddScoped<IEmployeeRepository, EmployeeRepository>(); builder.Services.AddScoped<CachingEndPointFilter>(); builder.Services.AddScoped<ValidationEndPointFilter<Employee>>(); builder.Services.AddScoped<LoggingEndPointFilter>(); //Register ApplicationDbContext builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // Register the custom global error handling middleware in the pipeline // It will catch exceptions from downstream middleware/endpoints app.UseMiddleware<ErrorHandlerMiddleware>(); // -------------------- Define Minimal API Endpoints with try-catch -------------------- // GET /employees - Fetch all employees app.MapGet("/employees", async (IEmployeeRepository repo, ILogger<Program> logger) => { logger.LogInformation("Fetching all employees asynchronously"); var employees = await repo.GetAllEmployeesAsync(); return Results.Ok(employees); }) .AddEndpointFilter<LoggingEndPointFilter>() //Log the Request Timing .AddEndpointFilter<CachingEndPointFilter>(); // Cache entire employee list for 30 seconds; // GET /employees/{id} - Fetch employee by ID app.MapGet("/employees/{id}", async (int id, IEmployeeRepository repo, ILogger<Program> logger) => { logger.LogInformation($"Fetching employee with ID: {id} asynchronously"); var employee = await repo.GetEmployeeByIdAsync(id); return employee is not null ? Results.Ok(employee) : Results.NotFound(); }) .AddEndpointFilter<LoggingEndPointFilter>() //Log the Request Timing .AddEndpointFilter<CachingEndPointFilter>(); // Cache individual employee responses; // POST /employees - Create a new employee app.MapPost("/employees", async (Employee newEmployee, IEmployeeRepository repo, ILogger<Program> logger) => { //No need to write the Custom Validation logic here var createdEmployee = await repo.AddEmployeeAsync(newEmployee); logger.LogInformation($"Employee created with ID {createdEmployee.Id} asynchronously"); return Results.Created($"/employees/{createdEmployee.Id}", createdEmployee); }) .AddEndpointFilter<LoggingEndPointFilter>() //Log the Request Timing .AddEndpointFilter<ValidationEndPointFilter<Employee>>(); // Validate input // PUT /employees/{id} - Update an existing employee app.MapPut("/employees/{id}", async (int id, Employee updatedEmployee, IEmployeeRepository repo, ILogger<Program> logger) => { var employee = await repo.UpdateEmployeeAsync(id, updatedEmployee); return employee is not null ? Results.Ok(employee) : Results.NotFound(); }) .AddEndpointFilter<LoggingEndPointFilter>() //Log the Request Timing .AddEndpointFilter<ValidationEndPointFilter<Employee>>(); // Validate input // DELETE /employees/{id} - Delete an employee by ID app.MapDelete("/employees/{id}", async (int id, IEmployeeRepository repo, ILogger<Program> logger) => { var deleted = await repo.DeleteEmployeeAsync(id); return deleted ? Results.NoContent() : Results.NotFound(); }) .AddEndpointFilter<LoggingEndPointFilter>(); //Log the Request Timing app.Run(); } } }
Now, run the application and test the endpoints. It should work as expected.
Why Use Endpoint Filters in ASP.NET Core Minimal API?
Endpoint Filters provide many practical benefits for building clean, maintainable, and efficient APIs:
- Separation of Concerns: Cross-cutting concerns such as validation, logging, authorization, and caching often span multiple endpoints. Instead of repeating that code inside every handler, we can isolate it into filters. This keeps our handlers focused purely on business logic, improving readability and maintainability.
- Reusability: Write a filter once (e.g., a validation filter) and apply it to as many endpoints as you want. This avoids duplication and makes updates easier because you modify the filter in one place.
- Cleaner Handlers: Without filters, you may have repetitive code in each handler for tasks such as checking input, logging, or authorization. Filters clean this up, making handlers simpler and easier to read and test.
- Performance: Since filters run within the endpoint pipeline, they are lightweight and efficient, introducing minimal overhead compared to global middleware or manual checks within handlers.
Endpoint Filters bring the power of cross-cutting concerns, like validation, logging, caching, and more, to Minimal APIs in ASP.NET Core, filling a crucial gap for clean, maintainable, and scalable web APIs. They allow us to write cleaner endpoints, reuse logic, and enforce policies in a composable way. As Minimal APIs become more popular, mastering Endpoint Filters is essential for building production-grade APIs.
In the next article, I will discuss how to implement JWT Authentication in ASP.NET Core Minimal API with Examples. In this article, I explain how to implement Endpoint Filters in ASP.NET Core Minimal API with Examples. I hope you enjoy this article, Endpoint Filters in ASP.NET Core Minimal API.