Endpoint Processors
Endpoint processors are hooks that run before or after the handler, enabling cross-cutting concerns like validation, authorization checks, audit logging, and notifications without polluting the handler logic itself. They compose via attributes and execute in a deterministic pipeline.
The Problem
Section titled “The Problem”Endpoints often need logic that runs before the handler (pre-conditions, input enrichment, resource existence checks) or after it (audit logs, metrics, notifications). Without processors, this logic ends up:
- Duplicated across handlers — every endpoint that checks “does the customer exist?” repeats the same code.
- Tangled with business logic — the handler does too many things, violating SRP.
- Hard to reorder — changing the execution order means refactoring the handler.
Processors solve this by externalizing pre/post logic into reusable, ordered, DI-resolved classes.
Pre-Processors
Section titled “Pre-Processors”A pre-processor runs before the endpoint handler. It can inspect the request, modify context, and short-circuit the pipeline by returning an error.
Interface
Section titled “Interface”public interface IEndpointPreProcessor{ ValueTask<PreProcessorResult> ProcessAsync(IEndpointContext context, CancellationToken ct = default);}Typed Variant
Section titled “Typed Variant”When you need access to the endpoint instance (e.g., to read its properties after binding):
public interface IEndpointPreProcessor<TEndpoint> : IEndpointPreProcessor where TEndpoint : class{ ValueTask<PreProcessorResult> ProcessAsync( TEndpoint endpoint, IEndpointContext context, CancellationToken ct = default);}The untyped ProcessAsync has a default implementation that casts context.Endpoint and delegates to the typed method.
PreProcessorResult
Section titled “PreProcessorResult”A readonly struct that determines whether the pipeline continues:
public readonly struct PreProcessorResult{ public bool ShouldContinue { get; } public IError? Error { get; }
public static PreProcessorResult Continue(); public static PreProcessorResult Fail(IError error); public static PreProcessorResult NotFound(string resourceType, string? id = null); public static PreProcessorResult NotFound<TEntity>(string? id = null);}Continue()— proceed to the next processor or handler.Fail(error)— stop the pipeline and return the error as HTTP response.NotFound(...)— shorthand forFail(NotFoundError.Create(...)). Returns HTTP 404.
Example: Validate Customer Exists
Section titled “Example: Validate Customer Exists”public class ValidateCustomerProcessor(ICustomerRepository customers) : IEndpointPreProcessor{ public async ValueTask<PreProcessorResult> ProcessAsync(IEndpointContext context, CancellationToken ct) { var customerId = context.GetRouteValue<Guid>("customerId"); if (customerId == default) return PreProcessorResult.Continue(); // Not relevant for this endpoint
var exists = await customers.ExistsAsync( new CustomerByIdSpec(customerId.Value), ct);
return exists ? PreProcessorResult.Continue() : PreProcessorResult.NotFound<Customer>(customerId.ToString()); }}Example: Typed Pre-Processor
Section titled “Example: Typed Pre-Processor”public class CheckInventoryProcessor : IEndpointPreProcessor<PlaceOrderEndpoint>{ private readonly IInventoryService _inventory;
public CheckInventoryProcessor(IInventoryService inventory) => _inventory = inventory;
public async ValueTask<PreProcessorResult> ProcessAsync( PlaceOrderEndpoint endpoint, IEndpointContext context, CancellationToken ct) { // Access endpoint properties directly (after binding) var available = await _inventory.CheckAvailabilityAsync(endpoint.ProductId, endpoint.Quantity, ct);
return available ? PreProcessorResult.Continue() : PreProcessorResult.Fail(new InsufficientStockError(endpoint.ProductId)); }}Post-Processors
Section titled “Post-Processors”A post-processor runs after the handler, regardless of whether it succeeded or failed. It receives the handler result for inspection.
Interface
Section titled “Interface”public interface IEndpointPostProcessor{ ValueTask ProcessAsync(IEndpointContext context, object? result, CancellationToken ct = default);}Typed Variant
Section titled “Typed Variant”public interface IEndpointPostProcessor<TEndpoint> : IEndpointPostProcessor where TEndpoint : class{ ValueTask ProcessAsync( TEndpoint endpoint, IEndpointContext context, object? result, CancellationToken ct = default);}Example: Audit Logging
Section titled “Example: Audit Logging”public class AuditLogProcessor(IAuditService audit) : IEndpointPostProcessor{ public async ValueTask ProcessAsync(IEndpointContext context, object? result, CancellationToken ct) { await audit.LogAsync(new AuditEntry { Endpoint = context.EndpointName, User = context.User?.Identity?.Name, Timestamp = DateTimeOffset.UtcNow, Success = result is not IError }, ct); }}Example: Notification After Order
Section titled “Example: Notification After Order”public class OrderNotificationProcessor : IEndpointPostProcessor<PlaceOrderEndpoint>{ private readonly INotificationService _notifications;
public OrderNotificationProcessor(INotificationService notifications) => _notifications = notifications;
public async ValueTask ProcessAsync( PlaceOrderEndpoint endpoint, IEndpointContext context, object? result, CancellationToken ct) { if (result is OrderDto order) { await _notifications.SendAsync( $"Order {order.Id} placed for customer {endpoint.CustomerId}", ct); } }}Attaching Processors
Section titled “Attaching Processors”Use [PreProcessor<T>] and [PostProcessor<T>] attributes on endpoint classes:
[Endpoint(HttpVerb.Post, "/api/orders")][PreProcessor<ValidateCustomerProcessor>(Order = 0)][PreProcessor<CheckInventoryProcessor>(Order = 1)][PostProcessor<AuditLogProcessor>][PostProcessor<OrderNotificationProcessor>]public partial class PlaceOrderEndpoint : Endpoint<OrderDto>{ public required Guid CustomerId { get; init; } public required Guid ProductId { get; init; } public required int Quantity { get; init; }
public override async Task<Result<OrderDto>> HandleAsync(CancellationToken ct) { // Only business logic here — pre-conditions handled by processors var order = await PlaceOrder(CustomerId, ProductId, Quantity, ct); return order; }}Non-Generic Variant
Section titled “Non-Generic Variant”When the processor type is not known at compile time:
[PreProcessor(typeof(ValidateCustomerProcessor), Order = 0)][PostProcessor(typeof(AuditLogProcessor))]public partial class PlaceOrderEndpoint : Endpoint<OrderDto> { /* ... */ }Execution Pipeline
Section titled “Execution Pipeline”The complete endpoint execution pipeline with processors:
Request arrives | vModel binding (route, query, body, headers, claims) | v[PreProcessor #1] (Order = 0) → Continue? → [PreProcessor #2] (Order = 1) → Continue? | | | (Fail) | (Fail) v vReturn error response Return error response | v (all Continue)HandleAsync() — the endpoint handler | v[PostProcessor #1] — always runs | v[PostProcessor #2] — always runs | vResponse serialization (Result → HTTP)Ordering Rules
Section titled “Ordering Rules”- Declaration order is the default: processors run in the order they appear as attributes.
Orderproperty overrides declaration order: lower values run first.- Pre-processors short-circuit: if one returns
Fail, subsequent pre-processors and the handler are skipped. - Post-processors always run: they execute even if the handler returned an error.
- DI resolution: processors are resolved from the DI container per request (scoped lifetime).
DI Registration
Section titled “DI Registration”Processors are resolved from DI automatically. Register them in your startup:
builder.Services.AddScoped<ValidateCustomerProcessor>();builder.Services.AddScoped<CheckInventoryProcessor>();builder.Services.AddScoped<AuditLogProcessor>();builder.Services.AddScoped<OrderNotificationProcessor>();Or use the [Service] attribute for auto-registration:
[Service(Lifetime = ServiceLifetime.Scoped)]public class AuditLogProcessor(IAuditService audit) : IEndpointPostProcessor{ // ...}Common Patterns
Section titled “Common Patterns”Resource Existence Check
Section titled “Resource Existence Check”Pre-processor that verifies a resource exists before the handler runs:
public class EnsureProductExistsProcessor(IProductRepository products) : IEndpointPreProcessor{ public async ValueTask<PreProcessorResult> ProcessAsync(IEndpointContext context, CancellationToken ct) { var productId = context.GetRouteValue<Guid>("productId"); if (productId is null) return PreProcessorResult.Continue();
return await products.ExistsAsync(new ProductByIdSpec(productId.Value), ct) ? PreProcessorResult.Continue() : PreProcessorResult.NotFound<Product>(productId.ToString()); }}Metrics Collection
Section titled “Metrics Collection”Post-processor that records endpoint latency:
public class MetricsProcessor(IMeterFactory meterFactory) : IEndpointPostProcessor{ private static readonly Histogram<double> Duration = meterFactory.Create("app").CreateHistogram<double>("endpoint.duration.ms");
public ValueTask ProcessAsync(IEndpointContext context, object? result, CancellationToken ct) { // Note: actual timing would need a Stopwatch started in a pre-processor Duration.Record(1.0, new("endpoint", context.EndpointName)); return ValueTask.CompletedTask; }}Conditional Processing
Section titled “Conditional Processing”Pre-processor that only applies to certain endpoints:
public class TenantIsolationProcessor(ITenantContext tenant) : IEndpointPreProcessor{ public ValueTask<PreProcessorResult> ProcessAsync(IEndpointContext context, CancellationToken ct) { if (!tenant.IsResolved) return ValueTask.FromResult( PreProcessorResult.Fail(new UnauthorizedError("Tenant context required")));
return ValueTask.FromResult(PreProcessorResult.Continue()); }}API Reference
Section titled “API Reference”Attributes
Section titled “Attributes”| Attribute | Target | Properties |
|---|---|---|
[PreProcessor<T>] | Class | Order (int, default 0) |
[PreProcessor(Type)] | Class | ProcessorType, Order |
[PostProcessor<T>] | Class | Order (int, default 0) |
[PostProcessor(Type)] | Class | ProcessorType, Order |
Interfaces
Section titled “Interfaces”| Interface | Method | Returns |
|---|---|---|
IEndpointPreProcessor | ProcessAsync(context, ct) | ValueTask<PreProcessorResult> |
IEndpointPreProcessor<T> | ProcessAsync(endpoint, context, ct) | ValueTask<PreProcessorResult> |
IEndpointPostProcessor | ProcessAsync(context, result, ct) | ValueTask |
IEndpointPostProcessor<T> | ProcessAsync(endpoint, context, result, ct) | ValueTask |
PreProcessorResult
Section titled “PreProcessorResult”| Factory | HTTP Result | When to Use |
|---|---|---|
Continue() | — (pipeline continues) | Validation passed |
Fail(IError) | Error’s StatusCode | Business rule violation |
NotFound(type, id?) | 404 | Resource does not exist |
NotFound<T>(id?) | 404 | Type-safe resource not found |