Pipeline Architecture
Every action in Pragmatic.Actions executes through a filter pipeline managed by an invoker. This guide covers how the pipeline works, the built-in filters, and how to write custom filters.
Pipeline Flow
Section titled “Pipeline Flow”DomainAction Pipeline
Section titled “DomainAction Pipeline”The DomainActionInvoker<TAction, TReturn> orchestrates this sequence:
1. InjectDependencies(action) - Generated SetDependencies() resolves private fields from DI
2. PrepareActionAsync(action, ct) - Loads entities declared with [LoadEntity<T>] - Returns EntityNotFoundError if any pre-loaded entity is missing
3. BeforeExecute filters (ascending order by Order) - Each filter can short-circuit by returning a failure - If any filter fails, execution stops immediately
4. ExecuteActionAsync(action, ct) - Calls action.Execute(ct) - Or dispatches to ExecuteV2/V3 for versioned actions
5. AfterExecute filters (descending order by Order) - Cannot modify the result - Used for logging, metrics, cleanup
6. SaveChangesAsync(ct) - Only if the action succeeded - Persists via boundary-keyed IUnitOfWorkVoidDomainAction Pipeline
Section titled “VoidDomainAction Pipeline”Identical to the DomainAction pipeline, except:
- Calls
BeforeExecuteVoidAsync/AfterExecuteVoidAsyncon filters - Returns
VoidResult<IError>instead ofResult<TReturn, IError> - Filters that don’t override the void methods default to no-op
Mutation Pipeline
Section titled “Mutation Pipeline”The MutationInvoker<TMutation, TEntity> follows a different, more structured sequence. See Mutations Guide for full details.
Built-in Filters
Section titled “Built-in Filters”ValidationFilter (Order 100)
Section titled “ValidationFilter (Order 100)”Source: Pragmatic.Actions.Pipeline.Filters.ValidationFilter
Validates action inputs before business logic runs. Behavior depends on attributes:
| Configuration | Sync | Async |
|---|---|---|
| No attribute | Yes | No |
[Validate] | Yes | Yes |
[Validate(AsyncOnly = true)] | No | Yes |
[Validate(Sync = false)] | No | Yes |
[NoValidation] | No | No |
Sync validation: Checks if the action implements ISyncValidator (generated from validation attributes like [Required], [Email]). Also validates nested properties that implement ISyncValidator.
Async validation: Resolves IAsyncValidator<TAction> from DI. Also checks nested reference-type properties for registered IAsyncValidator<TProperty>.
Both sync and async errors are combined into a single ValidationError before short-circuiting.
Performance note: Attribute lookups and compiled property accessors are cached per action type using ConcurrentDictionary. Reflection occurs only on first access.
PermissionAuthorizationFilter (Order 200)
Section titled “PermissionAuthorizationFilter (Order 200)”Source: Pragmatic.Actions.Pipeline.PermissionAuthorizationFilter
Enforces [RequirePermission] and [RequireAnyPermission] attributes.
Behavior:
- Skips if
ActionCallContext.IsInternalCallis true (intra-boundary calls) - Resolves the requirement from cached attribute data
- If no requirement, passes through
- If user is not authenticated, returns
UnauthorizedError - For
[RequirePermission]: checksICurrentUser.Authorization.HasAllPermissions() - For
[RequireAnyPermission]: checksICurrentUser.Authorization.HasAnyPermission() - Returns
ForbiddenErrorif the check fails
DI resolution: Resolves ICurrentUser lazily from IServiceProvider. Falls back to AnonymousUser.Instance if not registered.
PolicyEvaluationFilter (Order 210)
Section titled “PolicyEvaluationFilter (Order 210)”Source: Pragmatic.Actions.Pipeline.PolicyEvaluationFilter
Evaluates [RequirePolicy<T>] attributes where T extends ResourcePolicy.
Behavior:
- Skips if
ActionCallContext.IsInternalCallis true - Scans action type for
RequirePolicyAttribute<T>(cached) - Creates a policy instance via
Activator.CreateInstance - Calls
policy.Evaluate(currentUser) - Returns
ForbiddenErrorif evaluation fails
ResourceAuthorizationFilter (Order 250)
Section titled “ResourceAuthorizationFilter (Order 250)”Source: Pragmatic.Actions.Pipeline.ResourceAuthorizationFilter
Enforces instance-level authorization via IResourceAuthorizer<TAction>.
Behavior:
- Resolves
IResourceAuthorizer<TAction>from DI - If no authorizer is registered, passes through (cached for fast path)
- Calls
authorizer.CanAccessAsync(currentUser, action, actionName, ct) - Returns
ForbiddenErrorif access is denied
This filter handles cases where authorization depends on the specific data being accessed — e.g., “can this user edit THIS invoice?”
LoggingFilter (Order 1000)
Section titled “LoggingFilter (Order 1000)”Source: Pragmatic.Actions.Pipeline.Filters.LoggingFilter
Provides structured logging with zero allocation via [LoggerMessage]:
- BeforeExecute: Logs action name at Debug level
- AfterExecute: Logs success with result type (Information) or failure with error code (Warning)
For security, this filter does NOT log action parameters or result values.
Filter Ordering
Section titled “Filter Ordering”Filters execute in two phases:
- BeforeExecute: ascending order (100, 200, 210, 250, 1000)
- AfterExecute: descending order (1000, 250, 210, 200, 100)
The FilterOrder static class provides named constants:
public static class FilterOrder{ public const int Validation = 100; public const int Authorization = 200; public const int Transaction = 300; public const int Caching = 400; public const int Logging = 1000;}Gaps between values are intentional — place custom filters at intermediate values (e.g., 150, 250, 500).
Custom Filters
Section titled “Custom Filters”Global Filter (All Actions)
Section titled “Global Filter (All Actions)”Implement IActionFilter to create a filter that runs for every action:
public class AuditFilter(IAuditLog auditLog) : IActionFilter{ public int Order => 900; // After business logic, before logging
public Task<VoidResult<IError>> BeforeExecuteAsync<TAction, TReturn>( TAction action, CancellationToken ct) where TAction : DomainAction<TReturn> { // Pre-execution logic return Task.FromResult(VoidResult<IError>.Success()); }
public async Task AfterExecuteAsync<TAction, TReturn>( TAction action, Result<TReturn, IError> result, CancellationToken ct) where TAction : DomainAction<TReturn> { await auditLog.RecordAsync(typeof(TAction).Name, result.IsSuccess, ct); }}Register:
services.AddActionFilter<AuditFilter>();Action-Specific Filter
Section titled “Action-Specific Filter”Implement IActionFilter<TAction> to target a specific action regardless of its return type. Works with both DomainAction<TReturn> and VoidDomainAction:
public class ReservationBusinessRule : IActionFilter<CreateReservation>{ public int Order => 250; // After authorization
public async Task<VoidResult<IError>> BeforeExecuteAsync( CreateReservation action, CancellationToken ct) { // Check business invariants specific to CreateReservation if (action.Request.CheckIn < DateOnly.FromDateTime(DateTime.Today)) return VoidResult<IError>.Failure( new ValidationError("CheckIn", "Check-in date must be in the future"));
return VoidResult<IError>.Success(); }}Register:
services.AddActionFilter<ReservationBusinessRule, CreateReservation>();Typed Filter (With Result Access)
Section titled “Typed Filter (With Result Access)”Implement IActionFilter<TAction, TReturn> when you need access to the typed result in AfterExecute:
public class InvoiceResultLogger : IActionFilter<GetInvoice, InvoiceDto>{ public int Order => 500;
public Task<VoidResult<IError>> BeforeExecuteAsync( GetInvoice action, CancellationToken ct) => Task.FromResult(VoidResult<IError>.Success());
public Task AfterExecuteAsync( GetInvoice action, Result<InvoiceDto, IError> result, CancellationToken ct) { if (result.IsSuccess) Console.WriteLine($"Invoice {result.Value.Id} fetched"); return Task.CompletedTask; }}Register:
services.AddActionFilter<InvoiceResultLogger, GetInvoice, InvoiceDto>();Short-Circuiting
Section titled “Short-Circuiting”Any BeforeExecute filter can short-circuit the pipeline by returning a failure:
public Task<VoidResult<IError>> BeforeExecuteAsync<TAction, TReturn>( TAction action, CancellationToken ct) where TAction : DomainAction<TReturn>{ if (/* condition */) return Task.FromResult(VoidResult<IError>.Failure(new ForbiddenError { /* ... */ }));
return Task.FromResult(VoidResult<IError>.Success());}When a filter short-circuits:
- The action’s
Execute()is NOT called - Remaining
BeforeExecutefilters are NOT called AfterExecutefilters are NOT called- The failure result is returned to the caller
- A
filter_short_circuitsmetric is recorded - An Activity tag is set for distributed tracing
ActionCallContext
Section titled “ActionCallContext”The ActionCallContext is a scoped service that tracks whether the current invocation is an internal (intra-boundary) call.
public sealed class ActionCallContext : ICallContext{ public bool IsInternalCall => _depth > 0; public InternalCallScope EnterInternalCall();}When one action calls another within the same boundary via the generated boundary interface, it wraps the call with EnterInternalCall():
using var scope = callContext.EnterInternalCall();await childInvoker.InvokeAsync(childAction, ct);// scope.Dispose() restores the previous depthNesting is supported — the depth counter increments/decrements correctly for multi-level calls.
The PermissionAuthorizationFilter and PolicyEvaluationFilter both check IsInternalCall and skip authorization when true. This prevents redundant permission checks during action orchestration.
The ICallContext interface (in Pragmatic.Pipeline) allows infrastructure components like InMemoryEventDispatcher to enter internal call mode without depending on Pragmatic.Actions.
Telemetry Integration
Section titled “Telemetry Integration”Every action invocation creates an Activity from ActionsDiagnostics.ActivitySource with the name Action.{ActionName} or Mutation.{MutationType}.
Activity tags follow OpenTelemetry semantic conventions:
| Tag | Description |
|---|---|
action.name | Full type name |
action.kind | "action", "void_action", or "mutation" |
action.result | "success", "failure", or "short-circuited" |
action.filter_count | Number of active filters |
error.code | Error code on failure |
Metrics are recorded via the ActionsDiagnostics.Meter:
pragmatic.actions.invocations(counter)pragmatic.actions.duration(histogram, ms)pragmatic.actions.failures(counter)pragmatic.actions.filter_short_circuits(counter)
Disabling Built-in Filters
Section titled “Disabling Built-in Filters”All built-in filters can be disabled via PragmaticActionsOptions:
services.AddPragmaticActions(options =>{ options.EnableValidationFilter = false; // disable ValidationFilter options.EnableLoggingFilter = false; // disable LoggingFilter options.EnablePermissionFilter = false; // disable PermissionAuthorizationFilter options.EnableResourceAuthorizationFilter = false; // disable ResourceAuthorizationFilter options.EnablePolicyFilter = false; // disable PolicyEvaluationFilter});This is useful for testing or when you want to replace built-in filters with custom implementations.
Filter Execution with Mutation Pipeline
Section titled “Filter Execution with Mutation Pipeline”The mutation pipeline (MutationInvoker) does NOT use the global IActionFilter pipeline directly. Instead, it has its own structured pipeline with explicit phases (see Mutations Guide).
However, mutations DO support IActionFilter<TMutation> — typed action filters that run after L1 validation and before entity loading. This is the extension point for mutation-specific business policies:
public class InvoiceCreationPolicy : IActionFilter<CreateDraftInvoiceMutation>{ public int Order => 200;
public async Task<VoidResult<IError>> BeforeExecuteAsync( CreateDraftInvoiceMutation mutation, CancellationToken ct) { // Business rule: reject invoices over limit if (mutation.TotalAmount > 100_000m) return VoidResult<IError>.Failure(new BusinessRuleError("Invoice exceeds limit"));
return VoidResult<IError>.Success(); }}