Six Decorators and One Marker Attribute
Trill wraps every command handler in logging, metrics, tracing and an outbox using Scrutor's TryDecorate - and the whole arrangement only works because a single attribute keeps the decorators out of the assembly scan that registers handlers.
Cross-cutting concerns in .NET usually arrive as one of three things: a middleware, which only sees HTTP; an interceptor, which needs a proxy library; or a try/finally copy-pasted into every handler, which is what most estates actually have. Part 4 followed a pipeline that was built and never connected. This part reads the one in Trill's Stories service that is fully wired, correct, and quietly teaches two things about the Decorator pattern that most tutorials skip: what determines the nesting order, and why the whole scheme collapses without a marker attribute.
Six classes, one shape
Trill.Services.Stories.Infrastructure/Decorators/ contains six files:
| Class | Decorates | Registered |
|---|---|---|
MetricsCommandHandlerDecorator<T> |
ICommandHandler<> |
if prometheus.enabled |
TracingCommandHandlerDecorator<T> |
ICommandHandler<> |
if jaeger.enabled |
LoggingCommandHandlerDecorator<T> |
ICommandHandler<> |
always |
LoggingEventHandlerDecorator<T> |
IEventHandler<> |
always |
OutboxCommandHandlerDecorator<T> |
ICommandHandler<> |
always |
OutboxEventHandlerDecorator<T> |
IEventHandler<> |
always |
Every one is the same shape — an open generic that implements the interface it decorates, takes the inner handler as its first constructor parameter, and carries [Decorator]:
[Decorator]
internal sealed class LoggingCommandHandlerDecorator<TCommand> : ICommandHandler<TCommand>
where TCommand : class, ICommand
{
private readonly ICommandHandler<TCommand> _handler;
private readonly ICorrelationIdFactory _correlationIdFactory;
private readonly ILogger<ICommandHandler<TCommand>> _logger;
public async Task HandleAsync(TCommand command)
{
var correlationId = _correlationIdFactory.Create();
using (LogContext.PushProperty("CorrelationId", correlationId))
{
var name = command.GetType().Name.Underscore();
_logger.LogInformation($"Handling a command: '{name}'...");
await _handler.HandleAsync(command);
}
}
}
using (LogContext.PushProperty(...)) is the interesting line: every log statement written by the inner handler, and by anything it calls, is enriched with the correlation id for the duration of the await. That is Serilog's ambient context doing the work an explicit parameter would otherwise do through six call frames.
The registration, and why the order matters
Infrastructure/Extensions.cs, lines 74 to 89:
if (builder.GetOptions<PrometheusOptions>("prometheus").Enabled)
{
builder.Services.TryDecorate(typeof(ICommandHandler<>), typeof(MetricsCommandHandlerDecorator<>));
}
if (builder.GetOptions<JaegerOptions>("jaeger").Enabled)
{
builder.Services.TryDecorate(typeof(ICommandHandler<>), typeof(TracingCommandHandlerDecorator<>));
}
builder.Services.TryDecorate(typeof(ICommandHandler<>), typeof(LoggingCommandHandlerDecorator<>));
builder.Services.TryDecorate(typeof(IEventHandler<>), typeof(LoggingEventHandlerDecorator<>));
builder.Services.TryDecorate(typeof(ICommandHandler<>), typeof(OutboxCommandHandlerDecorator<>));
builder.Services.TryDecorate(typeof(IEventHandler<>), typeof(OutboxEventHandlerDecorator<>));
TryDecorate is Scrutor's — Convey 0.5.518 takes a package dependency on Scrutor 3.3.0. Its semantics are the important part: each call finds the existing registration for the service type and replaces it with one that constructs the decorator around whatever was registered before. So each successive call wraps the previous result, and the last decorator registered is the outermost one at resolution time.
Which means the runtime pipeline for a command in production is:
Outbox → Logging → Tracing → Metrics → SendStoryHandler
Read that as an operator, not as a developer, and it is backwards. OutboxCommandHandlerDecorator is the idempotency wrapper:
public Task HandleAsync(TCommand command)
=> _enabled
? _outbox.HandleAsync(_messageId, () => _handler.HandleAsync(command))
: _handler.HandleAsync(command);
IMessageOutbox.HandleAsync(messageId, action) is Convey's inbox-side deduplication: if this message id has been processed before, the action is skipped. Because the outbox sits outermost, a deduplicated redelivery skips everything inside it — including the “Handling a command” log line, including the Prometheus counter, including the Jaeger span. The one event you most want to see in your telemetry — “we received this message again and dropped it” — is the one the pipeline is arranged to hide. Moving the two outbox calls above the logging calls is a two-line change with no other consequence.
To be fair, this is a genuinely easy mistake. Scrutor's decoration order is inside-out relative to how the calls read, and nothing in the source of Extensions.cs hints at it. The estate's other services make the same arrangement — Ads and Users both register logging then outbox, in that order.
TryDecorate, and why the call order in Program.cs is load-bearing
The Try prefix is not decoration. Scrutor's Decorate throws MissingTypeRegistrationException when the service it is asked to wrap has no registration; TryDecorate returns false and does nothing. Which means a decoration that runs before its handlers are registered is a silent no-op — the code compiles, the app starts, and the wrapper never exists.
Trill gets this right, and it gets it right by an ordering in a different file. Api/Program.cs:
services
.AddConvey()
.AddWebApi()
.AddApplication()
.AddInfrastructure()
.Build();
AddApplication() is where AddCommandHandlers() and AddEventHandlers() live; AddInfrastructure() is where the six TryDecorate calls live. Swap those two lines — which a reader would reasonably assume is a stylistic choice, since neither method's name suggests a dependency on the other — and every decorator silently disappears. No logging, no metrics, no tracing, no outbox, no error, no warning.
The Users service has the same coupling inside a single method: AddCommandHandlers() at Core/Extensions.cs:63, TryDecorate at :90. Twenty-seven lines apart in one file, with the ordering constraint written nowhere. Decoration is the one DI operation where registration order changes behaviour rather than just which implementation wins, and it is the one the container will not warn you about. If you use it, a startup assertion that the resolved handler is not the concrete type — Assert.IsNotType<SendStoryHandler>(sp.GetRequiredService<ICommandHandler<SendStory>>()) — is worth its four lines.
The pipeline changes shape between environments
The two if blocks are the second thing worth noticing. appsettings.json has jaeger.enabled: true and prometheus.enabled: true; appsettings.development.json overrides both to false, and sets outbox.enabled: false as well. So the same code base produces two different object graphs:
- Production:
Outbox(Logging(Tracing(Metrics(handler))))— four wrappers, two of which allocate a span and a counter per command. - Development:
Outbox(Logging(handler))— two wrappers, and the outbox one is a pass-through because its_enabledfield is false.
A latency problem, an ordering problem, or an exception-propagation problem that lives in the tracing decorator is therefore structurally invisible on a developer's machine. That is not an argument for running Jaeger locally; it is an argument for keeping the shape constant and the behaviour configurable — register all four unconditionally and let each one no-op on a flag, exactly as the outbox decorator already does.
The attribute that makes it possible
Now the piece that is easy to read past. Every decorator carries [Decorator] — Convey.Types.DecoratorAttribute — and nothing in Trill's own source reads it. It matters because of how the handlers get registered in the first place. Application/Extensions.cs calls:
return builder
.AddCommandHandlers()
.AddEventHandlers()
.AddInMemoryCommandDispatcher()
.AddInMemoryEventDispatcher();
AddCommandHandlers() is a Convey extension that performs a Scrutor assembly scan. Reading the metadata of Convey.CQRS.Commands.dll 0.5.518, the symbols it uses are AssignableTo, WithoutAttribute, AsImplementedInterfaces and WithTransientLifetime — that is, it registers every concrete type assignable to ICommandHandler<>, as its implemented interfaces, transiently, excluding types carrying DecoratorAttribute.
Take the attribute off and the scan finds LoggingCommandHandlerDecorator<> too, because it is an ICommandHandler<>. You then have two registrations for the same closed interface, TryDecorate decorates both, and the container is asked to build a LoggingCommandHandlerDecorator<SendStory> whose inner ICommandHandler<SendStory> resolves to — depending on descriptor order — either a second decorator or itself. In the best case you get one spurious log line per command; in the worst you get a stack overflow at first resolution.
A four-character attribute is the only thing separating “wraps the handler” from “wraps itself”, and neither the compiler nor the container will tell you which one you built. It is a good pattern and worth stealing: if you write open-generic decorators and register handlers by convention, the convention needs an escape hatch, and an attribute is the cheapest one that survives a rename.
The one decorator I would keep verbatim
MetricsCommandHandlerDecorator classifies its failures:
catch (Exception exception)
{
var exceptionType = exception switch
{
DomainException _ => "domain",
AppException _ => "app",
_ => "system"
};
FailedCommandRequests.WithLabels(commandName, _service, exceptionType).Inc();
throw;
}
Three labels — command name, service, exception class — and the classification is by exception base type, which the estate already uses as its layering marker. That gives you a single Prometheus query that separates “users are sending us invalid input” from “our dependencies are down”, per command, with no per-handler code. Most estates I have seen either count all failures together or count nothing. This is eight lines and it is the right eight lines.
The rest of the service is less even. Users, the flat-assembly sibling from part 1, has four decorators rather than six — no metrics, no tracing — despite referencing both Convey.Metrics.Prometheus and Convey.Tracing.Jaeger in its .csproj and configuring both in its appsettings.json. The packages are there, the config is there, and no command in that service is ever counted or traced. Convey's own take on this seam, and its version of the outbox, are covered in the decorator seam and the outbox that keeps your events honest; I will not re-derive them here.
Next, down a layer, to the two indexes this service creates at startup and the one query neither of them can serve: an index with its keys backwards.