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:
- Assembly
- Module class
- 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 not ship concrete decorators or default presets, because decorators usually
depend on app choices (your logging, your DbContext, your tenancy model). Instead, 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(TransactionDecorator<,>),
typeof(DbConstraintDecorator<,>),
typeof(ValidationDecorator<,>))]
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class)]
public sealed class DefaultPipelineAttribute : Attribute;
[DecoratorList(typeof(ValidationDecorator<,>))]
[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]
[RpcMethod("clients.search")]
public sealed class SearchClients
: IHandler<SearchClients.Query, Result<SearchClients.Response>> {
// ...
}A read-only query gets just validation; a command gets the full transactional pipeline. 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
A transaction decorator commits on success and rolls back on failure, using IResultLike to inspect
the outcome without knowing the concrete response type. "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, so the
decorator body stays trivial:
public sealed class TransactionDecorator<TRequest, TResponse>(
IHandler<TRequest, TResponse> inner,
DbContext db
) : 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(Type request) =>
request.IsAssignableTo(typeof(ICommand)) || request.IsAssignableTo(typeof(IIntegrationEvent));
public async ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
await using var transaction = await db.Database.BeginTransactionAsync(ct);
var response = await inner.HandleAsync(request, ct);
if (response is IResultLike { IsSuccess: true }) {
await transaction.CommitAsync(ct);
} else {
await transaction.RollbackAsync(ct);
}
return response;
}
}| Handler | AppliesTo | Result |
|---|---|---|
Command (ICommand) | true | opens a transaction |
Query (IQuery) | false | not attached |
Domain-event handler (IDomainEvent, inline in the command) | false | not attached — rides the publisher's transaction |
Integration-event handler (IIntegrationEvent, fresh post-commit scope) | true | opens its own transaction |
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. Keep that helper local to your application — it is intentionally not part of the
framework API yet:
internal static class ResultFactory {
public static TResponse Failure<TResponse>(AppError error) {
var responseType = typeof(TResponse);
if (responseType.IsGenericType &&
responseType.GetGenericTypeDefinition() == typeof(Result<>)) {
var failureMethod = responseType.GetMethod(
nameof(Result<object>.Failure),
BindingFlags.Static | BindingFlags.Public)!;
return (TResponse)failureMethod.Invoke(null, [error])!;
}
throw new InvalidOperationException(
$"Cannot map AppError to {responseType.Name}. Handler must return Result<T>.");
}
}Order matters
Decorators run in the order listed in [DecoratorList], outermost first. A pipeline of
[Transaction, DbConstraint, Validation] validates innermost (closest to the handler) and wraps the
transaction outermost — so validation failures never open a transaction. Choose the order
deliberately; the generated code makes it explicit and inspectable.
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 ever get an audit entry; queries skip it entirely.
public sealed class AuditDecorator<TRequest, TResponse>(
IHandler<TRequest, TResponse> inner,
IAuditTrail audit
) : IHandler<TRequest, TResponse>
where TRequest : ICommand {
public async ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
var response = await inner.HandleAsync(request, ct);
if (response is IResultLike { IsSuccess: true }) audit.Record(typeof(TRequest).Name);
return response;
}
}A query handler never has AuditDecorator 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(System.Type request);When present, the decorator is attached only to handlers whose request type makes it return true. The
predicate is called, not parsed, so any C# is allowed — including reflection over the request type.
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, and the emitted code is a direct
static call plus typeof, so it stays NativeAOT- and trim-safe. It composes with where: the
constraint governs what the body may call; AppliesTo governs whether to attach. See
ADR-0003.
The method must be public static, return bool, and take one System.Type parameter; a non-public
AppliesTo is reported as ELPIPE001 (a build error). Because it is plain runnable C#, you can
unit-test it directly: TransactionDecorator<,>.AppliesTo(typeof(CreateOrder.Command)).
Because the predicate is called rather than interpreted, it can check anything reflection exposes — for
example a custom [Transactional] attribute on the request type:
public static bool AppliesTo(Type request) =>
request.IsDefined(typeof(TransactionalAttribute), inherit: true);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.
Validators
Validators use FluentValidation, are grouped by module namespace, and are registered by the generator.
Cross-module communication
Direct, synchronous module-to-module calls go through a published [ModuleContract]; an analyzer keeps modules honest, and an optional generated typed in-process API lets a module call its own handlers by name.