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). 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(DbConstraintDecorator<,>))]
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class)]
public sealed class DefaultPipelineAttribute : Attribute;

[DecoratorList(typeof(LoggingDecorator<,>))]
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class)]
public sealed class ReadOnlyPipelineAttribute : 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:

[ReadOnlyPipeline]
[Handler("clients.search")]
public sealed class SearchClients
    : IHandler<SearchClients.Query, Result<SearchClients.Response>> {
    // ...
}

A read-only query gets just logging; a command additionally translates database constraint violations. Named pipelines suit per-handler overrides and axes the request type can't express. For the common read/write split you usually don't need a second pipeline at all: make the decorator itself decide — either by a compile-time generic constraint (Filtering by request kind) or, for richer cases like the transaction decorator, by an AppliesTo predicate so one decorator 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.Abstractions.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 it auto-attaches from the request markers — no [DecoratorList] entry needed.

"Should this handler open a unit of work?" is true for commands and integration-event handlers and false for queries and domain-event handlers (which run inside the publisher's transaction) — a union no where clause can express. A static bool AppliesTo predicate states it exactly, and also excludes [Idempotent] handlers, which own their unit of work already (so a handler is never wrapped in two nested transactions):

namespace Elarion.Abstractions.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) =>
        (handler.RequestType.IsAssignableTo(typeof(ICommand)) ||
         handler.RequestType.IsAssignableTo(typeof(IIntegrationEvent)))
        && handler.GetAttribute<IdempotentAttribute>() is null;

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

        if (response is IResultLike { IsSuccess: true }) {
            await scope.CommitAsync(ct);
        } else {
            await scope.RollbackAsync(ct);
        }

        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)trueopens 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 integration consumer is still an IIntegrationEvent, so it 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.Abstractions.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.

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 gates keep a fixed position outside your list — observability (tracing + context enrichment) → audit (outcome observer) → authorization → feature gate → validation → your [DecoratorList] decorators → audit (success recorder, inside your transaction decorator) → handler — so a validation failure never reaches your decorators or opens a transaction, denied attempts are still audited, and a success audit record commits atomically with the handler's writes.

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.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> {
    public ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
        var required = metadata.GetAttribute<RequirePermissionAttribute>();
        if (required is not null && !user.HasPermission(required.Permission))
            throw new UnauthorizedAccessException(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 or integration events" — 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