Skip to content
Kumar Chandrachooda
.NET

Pre-Processors, Post-Processors, and the Exception That Waits

FastEndpoints' processors are its cross-cutting hook - but their exact pipeline position, the shared state trick, and a captured exception that waits in a finally block are what make them genuinely useful. Part 6 of FastEndpoints in Depth.

By Kumar Chandrachooda 12 Jan 2026 4 min read
Before and after the handler - and the captured exception waiting at the end

Every framework needs a place for the code that isn't the handler: audit logging, security stamping, response enrichment, domain-exception translation. MVC has action filters; Minimal APIs have endpoint filters. FastEndpoints has processors — and after reading their implementation, I think their most valuable feature is one almost nobody talks about: a captured exception that waits in a finally block for a post-processor to claim it.

The shape

A pre-processor sees the request before the handler; a post-processor sees the aftermath:

public class AuditPreProcessor<TReq> : IPreProcessor<TReq>
{
    public Task PreProcessAsync(IPreProcessorContext<TReq> ctx, CancellationToken ct)
    {
        var log = ctx.HttpContext.Resolve<ILogger<AuditPreProcessor<TReq>>>();
        log.LogInformation("Handling {Type} for {User}",
            typeof(TReq).Name, ctx.HttpContext.User.Identity?.Name);
        return Task.CompletedTask;
    }
}

Attach per endpoint in Configure() (PreProcessors(new AuditPreProcessor<CreateOrderRequest>())), or globally via IGlobalPreProcessor/IGlobalPostProcessor registered in the endpoint Configurator. The context object carries the DTO, the HttpContext, and — importantly — the same ValidationFailures list the binder and validator have been filling since Part 4.

Position is everything

Recall the pipeline order from Part 3: bind → validate → pre-processors → handler → auto-send → post-processors. Two consequences of that placement decide what processors are for.

Pre-processors are not middleware. They run after binding and validation. You cannot use one to influence how the DTO binds, and you cannot use one to skip validation. What you can do is exactly what the position enables: inspect a fully bound, already validated DTO and make a cross-cutting decision with real data. A tenant-consistency check ("does the TenantId in this DTO match the tenant claim?") is a perfect pre-processor; tenant resolution that binding depends on is middleware's job. Getting this boundary wrong is, in my experience, the number one source of FastEndpoints confusion.

A pre-processor can short-circuit by writing a response directly:

public class MaintenanceGate : IGlobalPreProcessor
{
    public async Task PreProcessAsync(IPreProcessorContext ctx, CancellationToken ct)
    {
        if (await ctx.HttpContext.Resolve<IMaintenanceState>().IsReadOnly(ct)
            && ctx.HttpContext.Request.Method != "GET")
        {
            await ctx.HttpContext.Response.SendStringAsync(
                "Read-only maintenance window.", 503, cancellation: ct);
        }
    }
}

The pipeline checks whether a response has started immediately after the pre-processor stage and stops cleanly if so — the handler never runs. (The source checks its own flag rather than Response.HasStarted, thanks to an AWS Lambda incompatibility the authors met before we did.)

One subtlety from the source that is easy to miss: when binding or validation fails, pre-processors still run — the pipeline explicitly invokes them (once — a flag prevents double execution) before sending the error response. So a logging pre-processor sees failed requests too, with the failure list populated. Tests in the repository pin this behaviour down; it is intended, not incidental.

Shared state without a grab bag

Processors and handler often need to pass each other data. The library's answer is ProcessorState<TState> — a typed object lazily created in HttpContext.Items and shared by everyone in the request:

public class RequestTimer : IGlobalPreProcessor
{
    public Task PreProcessAsync(IPreProcessorContext ctx, CancellationToken ct)
    {
        ctx.HttpContext.ProcessorState<Telemetry>().Started = Stopwatch.GetTimestamp();
        return Task.CompletedTask;
    }
}

// in the endpoint:
var t = ProcessorState<Telemetry>();

It is a small thing — HttpContext.Items with a type key and lazy construction — but it replaces the stringly-typed Items["timer"] pattern with something refactorable, and endpoints can declare their state type via Endpoint<TReq, TRes>.ProcessorState<T>() so tests can reach it too.

The exception that waits

Now the good part. Here is the tail of the execution pipeline, condensed:

// Src/Library/Endpoint/Endpoint.cs (FastEndpoints, MIT)
catch (Exception x)
{
    edi = ExceptionDispatchInfo.Capture(x); // let post-procs handle "uncaught" exceptions
}
finally
{
    await RunPostProcessors(Definition.PostProcessorList, req, _response, HttpContext,
                            edi, ValidationFailures, ct);

    if (edi is not null && !HttpContext.EdiIsHandled())
        edi.Throw();
}

Read that carefully, because it encodes three guarantees:

  1. Post-processors always run. Success, validation failure, handler explosion — the finally doesn't care. Your audit trail is complete.
  2. Post-processors see the exception. The context carries the ExceptionDispatchInfo, so a post-processor can pattern-match domain exceptions and translate them into proper responses — catch-free exception mapping, with the request DTO and the partially built response in scope. Call MarkExceptionAsHandled() and the story ends there.
  3. Unclaimed exceptions survive intact. If nobody handles it, edi.Throw() rethrows with the original stack trace preserved — so UseDefaultExceptionHandler() or your own exception middleware sees the real failure, not a laundered wrapper. The source comment says exactly why: without the rethrow, exceptions would be “silently swallowed” and downstream handlers useless.

This turns post-processors into the natural home for the “domain exception to HTTP status” mapping that otherwise lives in a middleware class far from the endpoints it serves:

public class DomainErrorMapper<TReq, TRes> : IPostProcessor<TReq, TRes>
{
    public async Task PostProcessAsync(IPostProcessorContext<TReq, TRes> ctx, CancellationToken ct)
    {
        if (ctx.ExceptionDispatchInfo?.SourceException is InsufficientStockException ex
            && !ctx.HttpContext.ResponseStarted())
        {
            ctx.MarkExceptionAsHandled();
            await ctx.HttpContext.Response.SendStringAsync(ex.Message, 409, cancellation: ct);
        }
    }
}

The one rule: check whether the response already started before writing. A post-processor runs even when the handler already sent a response — that is the point — so unconditional writes are a bug.

For completeness, the library also ships IResponseInterceptor — a hook that runs just before a response body is written through the Send.* path, able to veto or mutate at the last moment. I have needed it once (response signing); processors cover everything else.

Ordering and layering

Multiple processors run in declaration order; globals and endpoint-level processors interleave according to a documented Order option (globals can be declared to run before or after endpoint-level ones). My working convention after a few projects: globals for platform concerns (correlation IDs, timing, exception mapping), endpoint-level for feature concerns (idempotency-key extraction, entity-level audit), and never more than two of each — past that, the pipeline reads like middleware soup again and you have recreated the problem the library solved.

Processors also compose with everything earlier in the series in one pleasant way: because they are types discovered like everything else and attachable via the global Configurator, adding a platform-wide post-processor is a one-line change in one file — no per-endpoint edits, no base-class inheritance tax.

Next we leave the request pipeline for the walls around it: how Roles(), Permissions() and Claims() in Configure() become real ASP.NET Core authorization policies — one policy per endpoint, built with closures at startup. Part 7: A Policy Per Endpoint.