Learnixo
Back to blog
AI Systemsintermediate

Endpoint Filters — Cross-Cutting Concerns in Minimal APIs

Build reusable endpoint filters in ASP.NET Core Minimal APIs: validation filters, logging filters, rate limit filters, filter pipelines, and how they replace action filters from MVC.

Asma Hafeez KhanMay 16, 20265 min read
Minimal APIsFiltersASP.NET Core.NETMiddleware
Share:𝕏

What Endpoint Filters Are

Endpoint filters wrap an endpoint handler, similar to middleware but scoped to specific endpoints. They run before and after the handler, with access to parameters and the result.

Request → Middleware → Router → [Filter A] → [Filter B] → Handler → [Filter B] → [Filter A] → Response

Middleware: applied globally
Endpoint Filters: applied per-endpoint or per-group
Action Filters (MVC): endpoint filters are the Minimal API equivalent

IEndpointFilter Interface

C#
// IEndpointFilter — the interface to implement
public interface IEndpointFilter
{
    ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next);
}

The filter calls await next(context) to pass control to the next filter (or the handler). It can inspect and modify the arguments before calling next, and inspect and modify the result after.


Validation Filter

C#
// Automatically validate FluentValidation rules before the handler runs
public sealed class ValidationFilter<TRequest> : IEndpointFilter
{
    private readonly IValidator<TRequest> _validator;

    public ValidationFilter(IValidator<TRequest> validator)
        => _validator = validator;

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        // Find the request DTO in the arguments
        var request = context.Arguments.OfType<TRequest>().FirstOrDefault();
        if (request is null) return await next(context);

        var result = await _validator.ValidateAsync(request);
        if (!result.IsValid)
        {
            return Results.ValidationProblem(result.ToDictionary());
        }

        return await next(context);
    }
}

// Apply to endpoint
app.MapPost("/patients", CreatePatient)
    .AddEndpointFilter<ValidationFilter<CreatePatientDto>>();

// Apply to group — all endpoints in the group get validation
patients.AddEndpointFilter<ValidationFilter<CreatePatientDto>>();

Audit Log Filter

C#
// Log who accessed what and when — applied to all clinical endpoints
public sealed class AuditLogFilter : IEndpointFilter
{
    private readonly IAuditLogger _audit;
    private readonly ILogger<AuditLogFilter> _logger;

    public AuditLogFilter(IAuditLogger audit, ILogger<AuditLogFilter> logger)
        => (_audit, _logger) = (audit, logger);

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var user       = context.HttpContext.User;
        var userId     = user.FindFirstValue(JwtRegisteredClaimNames.Sub);
        var endpoint   = context.HttpContext.GetEndpoint()?.DisplayName;
        var method     = context.HttpContext.Request.Method;
        var path       = context.HttpContext.Request.Path;
        var requestId  = context.HttpContext.TraceIdentifier;

        _logger.LogInformation(
            "Audit: {UserId} accessed {Method} {Path} [{RequestId}]",
            userId, method, path, requestId);

        var result = await next(context);

        var statusCode = context.HttpContext.Response.StatusCode;
        await _audit.RecordAsync(new AuditEntry(
            UserId:     userId ?? "anonymous",
            Endpoint:   endpoint ?? path,
            Method:     method,
            StatusCode: statusCode,
            Timestamp:  DateTime.UtcNow));

        return result;
    }
}

// Apply to clinical route group
var clinicalRoutes = app.MapGroup("/api/clinical")
    .RequireAuthorization("ClinicalStaff")
    .AddEndpointFilter<AuditLogFilter>();

Filter Factory — Access Endpoint Metadata

C#
// Filter factory: access the endpoint being filtered before any request
public static class EndpointFilters
{
    public static TBuilder AddValidation<TBuilder, TRequest>(this TBuilder builder)
        where TBuilder : IEndpointConventionBuilder
    {
        builder.AddEndpointFilterFactory((context, next) =>
        {
            // context.MethodInfo — the handler method
            // context.EndpointMetadata — attributes on the handler
            var isSkipped = context.EndpointMetadata
                .OfType<SkipValidationAttribute>()
                .Any();

            if (isSkipped)
                return invocationContext => next(invocationContext);

            var validator = context.ApplicationServices
                .GetService<IValidator<TRequest>>();

            if (validator is null)
                return invocationContext => next(invocationContext);

            return async invocationContext =>
            {
                var req    = invocationContext.GetArgument<TRequest>(0);
                var result = await validator.ValidateAsync(req);
                if (!result.IsValid)
                    return Results.ValidationProblem(result.ToDictionary());
                return await next(invocationContext);
            };
        });

        return builder;
    }
}

Filter Pipeline — Order Matters

C#
// Filters run in the order they are added (outer to inner for pre, inner to outer for post)
patients.MapPost("", CreatePatient)
    .AddEndpointFilter<RequestLoggingFilter>()   // runs first before, last after
    .AddEndpointFilter<ValidationFilter<CreatePatientDto>>()
    .AddEndpointFilter<AuditLogFilter>();         // runs last before, first after

// Execution order for a request:
// RequestLoggingFilter.Before → ValidationFilter.Before → AuditLogFilter.Before
//   → Handler
// AuditLogFilter.After → ValidationFilter.After → RequestLoggingFilter.After

Short-Circuiting in Filters

C#
// Return early without calling the handler
public sealed class MaintenanceModeFilter : IEndpointFilter
{
    private readonly IMaintenanceService _maintenance;

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        if (await _maintenance.IsActiveAsync())
        {
            // Short-circuit — handler never runs
            return Results.Problem(
                statusCode: 503,
                title:      "Service Unavailable",
                detail:     "Scheduled maintenance in progress. Expected end: 02:00 UTC.");
        }

        return await next(context);  // proceed normally
    }
}

Inline Filter Delegate

C#
// Quick filter without a class — for simple cases
app.MapPost("/prescriptions", CreatePrescription)
    .AddEndpointFilter(async (context, next) =>
    {
        // Log request timing
        var sw     = Stopwatch.StartNew();
        var result = await next(context);
        sw.Stop();

        context.HttpContext.Response.Headers["X-Elapsed-Ms"] =
            sw.ElapsedMilliseconds.ToString();

        return result;
    });

Filters vs Middleware

Use Middleware when:
  ✓ Applies to all requests (logging, CORS, authentication)
  ✓ Needs to intercept responses before routing
  ✓ Operates at the raw HttpContext level

Use Endpoint Filters when:
  ✓ Applies to specific endpoints or groups
  ✓ Needs access to route parameters and handler arguments
  ✓ Needs to inspect the handler's return value
  ✓ Replaces controller action filters

Use both when:
  ✓ Global middleware for cross-cutting concerns
  ✓ Filters for endpoint-specific behavior (validation, auditing)

Red Flag / Green Answer

Red Flag: "We validate DTOs inside every handler: if (!ModelState.IsValid) return BadRequest(ModelState)."

Validation logic duplicated in every handler. When a new validation rule is added, every handler that uses that DTO must be updated. One validation filter with IValidator<T> validates before the handler runs — handlers focus on business logic only.

Green Answer:

ValidationFilter<TRequest> on the group. Handlers receive already-validated requests. FluentValidation rules in one place. Change a validation rule — the filter picks it up everywhere.


Key Takeaway

Endpoint filters are Minimal API's answer to MVC action filters: cross-cutting concerns (validation, logging, auditing) extracted from handlers. They apply per-endpoint or per-group via AddEndpointFilter. Filters run in added order (outermost-first before the handler, innermost-first after). Short-circuit by returning a result without calling next. Use filters to keep handlers focused on business logic — not infrastructure concerns.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.