Elarion

Decorator pipelines

Generated factories wrap each handler in an ordered decorator pipeline declared by assembly, module, or handler attributes.

Elarion registers handlers through generated factories that wrap the handler in an ordered pipeline of decorators. Decorators are how you apply cross-cutting behavior — logging, validation, transactions, idempotency, tenancy — without putting it inside handlers.

Pipeline scopes

Pipeline attributes can be applied at three scopes, from least to most specific:

  1. Assembly
  2. Module class
  3. Handler class

The most specific pipeline wins. This lets you set a default pipeline for the whole application and override it for individual modules or handlers.

Defining named pipelines

The framework does ship built-in decorators for the common cross-cutting concerns — transactions, request validation, idempotency, authorization, feature gating, caching, resilience, tracing, and the async-resolved handler proxy — and the generator auto-attaches them from the handler's own attributes (validation from the attributes on the handler's request type). The one exception is the transaction decorator, which you list once in your [DecoratorList]. You still add your own for app-specific behavior (your logging, your tenancy model): define your decorators in the application and expose named pipeline attributes with [DecoratorList]:

using MyApp.Application.Decorators;
using Elarion.Abstractions.Pipeline;

namespace MyApp.Application.Pipeline;

[DecoratorList(
    typeof(LoggingDecorator<,>),
    typeof(Elarion.Pipeline.TransactionDecorator<,>),
    typeof(DbConstraintDecorator<,>))]
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class)]
public sealed class DefaultPipelineAttribute : Attribute;

// Bulk imports write thousands of rows in one command: the same decorators, plus a longer database
// command timeout for the call (an app decorator that calls db.Database.SetCommandTimeout).
[DecoratorList(
    typeof(LoggingDecorator<,>),
    typeof(Elarion.Pipeline.TransactionDecorator<,>),
    typeof(LongCommandTimeoutDecorator<,>),
    typeof(DbConstraintDecorator<,>))]
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class)]
public sealed class BulkImportPipelineAttribute : Attribute;

Apply the default at the assembly level:

using MyApp.Application.Pipeline;
using Elarion.Abstractions;

[assembly: DefaultPipeline]
[assembly: UseElarion]

And override it on a specific handler:

[BulkImportPipeline]
[Handler("contacts.import")]
public sealed class ImportContacts
    : IHandler<ImportContacts.Command, Result<ImportContacts.Response>> {
    // ...
}

A named pipeline replaces the default rather than adding to it, so it lists every decorator it keeps.

Use one for an axis the request type cannot express. An import is a command like any other, and nothing on its request type says it needs a longer database timeout, so a named pipeline is the right place to say so.

Do not use one for the read/write split. The request kind (IQuery/ICommand) already carries that split. A pipeline named after it ("read-only", "query") becomes a second source of truth: it has to be remembered on every new handler, and it drifts from the type the day someone forgets. Instead, let the decorator itself decide, in one of two ways:

Either way, one default pipeline is correct for commands, queries, and both kinds of event handler.

Writing a decorator

A decorator implements IHandler<TRequest, TResponse> and takes the inner handler as a constructor parameter. Any additional constructor parameters are resolved from DI by the generated factory.

using Microsoft.Extensions.Logging;
using Elarion.Abstractions;

public sealed class LoggingDecorator<TRequest, TResponse>(
    IHandler<TRequest, TResponse> inner,
    ILogger<LoggingDecorator<TRequest, TResponse>> logger
) : IHandler<TRequest, TResponse> {
    public async ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
        logger.LogDebug("Handling {RequestType}", typeof(TRequest).Name);
        var response = await inner.HandleAsync(request, ct);
        logger.LogDebug("Handled {RequestType}", typeof(TRequest).Name);
        return response;
    }
}

Transaction decorator

The framework ships this one for you: Elarion.Pipeline.TransactionDecorator<TRequest, TResponse> commits on success and rolls back on failure, using IResultLike to inspect the outcome without knowing the concrete response type. It wraps the handler in a single IUnitOfWork boundary rather than a raw DbContext, so every feature composes the same unit of work. Wire it with AddElarionUnitOfWork<AppDbContext>() and list it once in your application's [DecoratorList]; its AppliesTo predicate then decides per handler whether it attaches.

"Should this handler open a unit of work?" is true for commands and false for queries and domain-event handlers (which run inside the publisher's transaction) — a rule no where clause can express. A static bool AppliesTo predicate states it exactly, and also excludes handlers whose pipeline already owns the unit of work — [Idempotent] commands and integration-event consumers under the default-on inbox (only an [AllowDuplicates] opt-out gets the plain transaction back) — so a handler is never wrapped in two nested transactions:

namespace Elarion.Pipeline;

public sealed class TransactionDecorator<TRequest, TResponse>(
    IHandler<TRequest, TResponse> inner,
    IUnitOfWork unitOfWork
) : IHandler<TRequest, TResponse> {
    // Attach only where a new unit of work is needed. The generator calls this once per handler type.
    public static bool AppliesTo(HandlerMetadata handler) {
        if (handler.GetAttribute<IdempotentAttribute>() is not null)
            return false;

        // The default-on inbox owns an integration consumer's unit of work; [AllowDuplicates] opts out of it.
        if (handler.RequestType.IsAssignableTo(typeof(IIntegrationEvent)))
            return handler.GetAttribute<AllowDuplicatesAttribute>() is not null;

        return handler.RequestType.IsAssignableTo(typeof(ICommand));
    }

    public async ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
        await using var scope = await unitOfWork.BeginAsync(UnitOfWorkOptions.Default, ct);
        var response = await inner.HandleAsync(request, ct);

        // The handler already succeeded, so the finalizing commit is deliberately uncancellable.
        if (response is IResultLike { IsSuccess: true }) {
            await scope.CommitAsync(CancellationToken.None);
        } else {
            await scope.RollbackAsync(CancellationToken.None);
        }

        return response;
    }
}
HandlerAppliesToResult
Command (ICommand)trueopens a transaction
Query (IQuery)falsenot attached
Domain-event handler (IDomainEvent, inline in the command)falsenot attached — rides the publisher's transaction
Integration-event handler (IIntegrationEvent, fresh post-commit scope)falsenot attached — the default-on inbox (idempotency decorator) owns the unit of work
[AllowDuplicates] integration-event handlertrueopens its own transaction
[Idempotent] commandfalsenot attached — the idempotency decorator owns the unit of work

No runtime check, no empty transaction on a query, and domain-event handlers get no decorator at all — the chains show exactly what attached. (The one residual wart: a read-only [AllowDuplicates] integration consumer still opens a transaction it never uses — harmless.)

Turning an AppError into TResponse

Generic decorators (like validation) sometimes need to construct a failure TResponse from an AppError. Both Result<T> and the non-generic Result implement IResultFailureFactory<TSelf>, which exposes a static abstract TSelf Failure(AppError). Constrain the decorator's TResponse to that interface and call TResponse.Failure(error) directly — no reflection. The framework's own ValidationDecorator is exactly this shape:

namespace Elarion.Pipeline;

public sealed class ValidationDecorator<TRequest, TResponse>(
    IHandler<TRequest, TResponse> inner,
    IRequestValidator validator
) : IHandler<TRequest, TResponse>
    where TResponse : IResultFailureFactory<TResponse> {
    public async ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
        var errors = await validator.ValidateAsync(typeof(TRequest), request!, ct);
        if (errors is null)
            return await inner.HandleAsync(request, ct);

        var messages = ValidationErrorData.Flatten(errors.FieldErrors);
        return TResponse.Failure(AppError.Validation(string.Join("; ", messages), errors.FieldErrors));
    }
}

The where TResponse : IResultFailureFactory<TResponse> constraint does double duty: it makes the static Failure call type-safe and scopes the decorator at compile time to Result-returning handlers — the generator skips it for any handler whose response does not satisfy the constraint, so you never hit a runtime "this handler doesn't return Result<T>" failure. Prefer this over a reflection-based helper.

Declaring the errors a decorator adds

A decorator that fails a request returns an AppError that no handler can declare, because the handler does not know which decorators wrap it. Put [ProducesError] on the decorator instead: the generator adds the declaration to every operation whose pipeline includes the decorator, so the schema and the generated client describe it and the development-time contract check does not report it.

[ProducesError("db.constraint", ErrorKind.Conflict, typeof(ConstraintViolation))]
public sealed class DbConstraintDecorator<TRequest, TResponse>(IHandler<TRequest, TResponse> inner)
    : IHandler<TRequest, TResponse>
    where TRequest : ICommand
    where TResponse : IResultFailureFactory<TResponse> {
    // Translates a unique-key violation into the declared conflict.
}

The generator resolves the decorator list exactly as it builds the pipeline, so a decorator excluded by its where constraints (here: queries) adds nothing. An AppliesTo predicate runs at runtime, so the errors of a conditional decorator are declared for every operation it can wrap. A handler's own declaration of the same code wins.

Order matters

Decorators run in the order listed in [DecoratorList], outermost first. A pipeline of [Logging, DbConstraint] logs outermost and translates constraint violations closest to the handler. Choose the order deliberately; the generated code makes it explicit and inspectable. The framework's auto-attached decorators keep a fixed position around your list. Outermost first, each attached only when its trigger applies:

  1. Observability — tracing + context enrichment (always).
  2. Audit outcome observer — [Auditable].
  3. Authorization — [Require*] or [ElarionAuthorizationDefaults] in scope.
  4. Feature gate — [FeatureGate].
  5. Validation — DataAnnotations on the request graph.
  6. Cache invalidation — [CacheInvalidate].
  7. Resilience — [Resilient].
  8. Idempotency — [Idempotent] commands (owns the unit of work), or the inbox for integration-event consumers.
  9. Your [DecoratorList] decorators, in list order.
  10. Audit success recorder — [Auditable], inside your transaction decorator.
  11. Cache — [Cacheable].
  12. The handler.

So a denied, gated, or invalid request never reaches your decorators or opens a transaction, a denied attempt is still audited, a replayed idempotent request never claims a key, a success audit record commits atomically with the handler's writes, and an [Auditable] cached query still records the read on a cache hit.

Inspecting the resolved pipeline

The decorators that actually wrapped a handler in the current process are available at run time on HandlerMetadata.Pipeline — an IHandlerPipeline exposing Steps (each a PipelineStep(Type Decorator, bool Conditional), outermost first) and Contains(typeof(SomeDecorator<,>)). It reflects the resolved pipeline, including decorators that attach only when a backing service is registered (audit, the idempotency inbox) or when an AppliesTo predicate matched — those are flagged Conditional. It is empty until the handler is first resolved from DI (the composition is only known then), and reflects one composition per process. The framework surfaces the same list on every handler span as the elarion.handler.pipeline tag, so "why didn't caching apply here?" is answerable from a trace.

Reading the handler's attributes (do not use inner.GetType())

A common need is a decorator that reads an attribute off the handler — for example an [RequirePermission(...)] for an authorization decorator. It is tempting to reach for inner.GetType().GetCustomAttribute<RequirePermissionAttribute>(), but this is a fail-open footgun. Because decorators wrap innermost-first, inner is the concrete handler only when the decorator is the innermost wrapper (last in [DecoratorList]). At any other position — including the intuitive outermost "check authorization first" spot — inner is the next decorator, the attribute is invisible, and an authorization check silently passes.

Never read the handler's attributes from inner.GetType(). It only sees the handler when the decorator happens to be innermost; positioned anywhere else it reads a decorator wrapper instead, so an attribute-driven check fails open. The failure is silent — there is no compiler error.

Instead, declare a HandlerMetadata constructor parameter. The generator supplies it with the concrete handler type, so the decorator reads the handler's attributes correctly regardless of its position in the chain. (The generated factory passes the inner handler first, then any DI dependencies and the HandlerMetadata singleton in declaration order.)

using Elarion.Abstractions;
using Elarion.Abstractions.Authorization;
using Elarion.Abstractions.Identity;
using Elarion.Abstractions.Pipeline;

// Position-independent: works whether this decorator is outermost, innermost, or anywhere between.
public sealed class AuthorizationDecorator<TRequest, TResponse>(
    IHandler<TRequest, TResponse> inner,
    HandlerMetadata metadata,
    ICurrentUser user
) : IHandler<TRequest, TResponse>
    where TResponse : IResultFailureFactory<TResponse> {
    public ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
        var required = metadata.GetAttribute<RequirePermissionAttribute>();
        if (required is not null && !user.HasClaim("permission", required.Permission))
            return ValueTask.FromResult(TResponse.Failure(AppError.Forbidden(required.Permission)));
        return inner.HandleAsync(request, ct);
    }
}

metadata.HandlerType is the true handler type; GetAttribute<T>()/GetAttributes<T>() read its attributes. Attribute reads still use reflection, but on a single known Type, which is AOT/trim-safe as long as the attribute type is preserved. The HandlerMetadata singleton is generated once per handler (no per-request allocation). The seam offers a correct path but does not prevent the unsafe inner.GetType() pattern from compiling — prefer HandlerMetadata whenever a decorator depends on the handler's own type or attributes.

Filtering by request kind

A decorator can constrain its TRequest type parameter, and the generator applies it only to handlers whose request satisfies the constraint — skipping it for the rest at compile time, with no runtime check and no allocation in the chains it doesn't apply to. This is the tool for a decorator that is meaningful for exactly one request kind, using the ICommand/IQuery markers:

// Compile-time filtered: only command handlers are throttled; queries skip it entirely.
public sealed class ThrottleDecorator<TRequest, TResponse>(
    IHandler<TRequest, TResponse> inner,
    ICommandThrottle throttle
) : IHandler<TRequest, TResponse>
    where TRequest : ICommand {
    public async ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
        using var lease = await throttle.AcquireAsync(typeof(TRequest).Name, ct);
        return await inner.HandleAsync(request, ct);
    }
}

A query handler never has ThrottleDecorator in its generated chain — the filtering is resolved when the pipeline is emitted. Decorators with no constraints (the common case) apply to every handler.

A where clause expresses a single bound (an AND of "implements X"). When attachment is a union or a negation — like the transaction decorator's "commands, but not [Idempotent] ones" — reach for AppliesTo below.

Conditional attachment with AppliesTo

A decorator may declare a static predicate that the generator calls once at pipeline-build time to decide attachment per handler:

public static bool AppliesTo(HandlerMetadata handler);

When present, the decorator is attached only to handlers for which it returns true. The predicate is called, not parsed, so any C# is allowed — including reflection. The generator evaluates it once per closed handler type (caching the result in a generated static readonly bool initialized at type init), never per request, so it stays NativeAOT- and trim-safe. It composes with where: the constraint governs what the body may call; AppliesTo governs whether to attach.

The predicate receives HandlerMetadata — the concrete HandlerType, RequestType, ResponseType, and GetAttribute<T>()/GetAttributes<T>() — so it can attach based on the request type, the response type, or the handler's own attributes. That is the same handler-attribute-driven attachment the framework's built-in decorators use; there is no privileged generator capability a custom decorator can't replicate. Attach on the request kind:

public static bool AppliesTo(HandlerMetadata handler) =>
    handler.RequestType.IsAssignableTo(typeof(ICommand)) ||
    handler.RequestType.IsAssignableTo(typeof(IIntegrationEvent));

…or on a custom handler attribute:

public static bool AppliesTo(HandlerMetadata handler) =>
    handler.GetAttribute<ThrottledAttribute>() is not null;

There is a single supported signature on purpose. It must be public static bool AppliesTo(HandlerMetadata); a non-public one is ELPIPE001, and a differently-shaped AppliesTo (e.g. the older AppliesTo(System.Type)) is ELPIPE002 rather than silently ignored. Because it is plain runnable C#, you can unit-test it directly: TransactionDecorator<,>.AppliesTo(new HandlerMetadata(typeof(CreateOrder), typeof(CreateOrder.Command), typeof(Result<CreateOrder.Response>))).

AppliesTo works for decorators shipped in referenced packages too — a public static method is callable across the metadata boundary even though its body isn't readable as source. Attachment is a runtime decision, so the decorator type is referenced (behind the cached if) in every in-scope handler's chain rather than compile-time elided. When the rule is a single interface bound and you want elision/trimming, prefer a where constraint.

Built-in caching and resilience are themselves decorators (CacheDecorator, ResilienceDecorator) inserted into this same pipeline by their attributes — so they compose with your application decorators in a deterministic order.

On this page