Skip to content

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.

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 IUnitOfWork

Identical to the DomainAction pipeline, except:

  • Calls BeforeExecuteVoidAsync / AfterExecuteVoidAsync on filters
  • Returns VoidResult<IError> instead of Result<TReturn, IError>
  • Filters that don’t override the void methods default to no-op

The MutationInvoker<TMutation, TEntity> follows a different, more structured sequence. See Mutations Guide for full details.


Source: Pragmatic.Actions.Pipeline.Filters.ValidationFilter

Validates action inputs before business logic runs. Behavior depends on attributes:

ConfigurationSyncAsync
No attributeYesNo
[Validate]YesYes
[Validate(AsyncOnly = true)]NoYes
[Validate(Sync = false)]NoYes
[NoValidation]NoNo

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.

Source: Pragmatic.Actions.Pipeline.PermissionAuthorizationFilter

Enforces [RequirePermission] and [RequireAnyPermission] attributes.

Behavior:

  1. Skips if ActionCallContext.IsInternalCall is true (intra-boundary calls)
  2. Resolves the requirement from cached attribute data
  3. If no requirement, passes through
  4. If user is not authenticated, returns UnauthorizedError
  5. For [RequirePermission]: checks ICurrentUser.Authorization.HasAllPermissions()
  6. For [RequireAnyPermission]: checks ICurrentUser.Authorization.HasAnyPermission()
  7. Returns ForbiddenError if the check fails

DI resolution: Resolves ICurrentUser lazily from IServiceProvider. Falls back to AnonymousUser.Instance if not registered.

Source: Pragmatic.Actions.Pipeline.PolicyEvaluationFilter

Evaluates [RequirePolicy<T>] attributes where T extends ResourcePolicy.

Behavior:

  1. Skips if ActionCallContext.IsInternalCall is true
  2. Scans action type for RequirePolicyAttribute<T> (cached)
  3. Creates a policy instance via Activator.CreateInstance
  4. Calls policy.Evaluate(currentUser)
  5. Returns ForbiddenError if evaluation fails

Source: Pragmatic.Actions.Pipeline.ResourceAuthorizationFilter

Enforces instance-level authorization via IResourceAuthorizer<TAction>.

Behavior:

  1. Resolves IResourceAuthorizer<TAction> from DI
  2. If no authorizer is registered, passes through (cached for fast path)
  3. Calls authorizer.CanAccessAsync(currentUser, action, actionName, ct)
  4. Returns ForbiddenError if access is denied

This filter handles cases where authorization depends on the specific data being accessed — e.g., “can this user edit THIS invoice?”

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.


Filters execute in two phases:

  1. BeforeExecute: ascending order (100, 200, 210, 250, 1000)
  2. 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).


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>();

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>();

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>();

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 BeforeExecute filters are NOT called
  • AfterExecute filters are NOT called
  • The failure result is returned to the caller
  • A filter_short_circuits metric is recorded
  • An Activity tag is set for distributed tracing

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 depth

Nesting 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.


Every action invocation creates an Activity from ActionsDiagnostics.ActivitySource with the name Action.{ActionName} or Mutation.{MutationType}.

Activity tags follow OpenTelemetry semantic conventions:

TagDescription
action.nameFull type name
action.kind"action", "void_action", or "mutation"
action.result"success", "failure", or "short-circuited"
action.filter_countNumber of active filters
error.codeError 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)

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.


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();
}
}