The Decorator Seam: Logging Handlers You Didn't Write
How Convey wraps every command and event handler with template-driven logging using one attribute, a Scrutor decoration — and a reflection trick against Scrutor itself that I admire and would never ship.
There are two schools of handler logging. School one puts _logger.LogInformation at the top and bottom of every HandleAsync — honest, greppable, and by handler thirty a copy-paste liturgy nobody reviews. School two says logging is a cross-cutting concern and reaches for a pipeline: MediatR behaviours, middleware, an IL weaver if morale is low.
Convey — the open-source DevMentors toolkit this series is working through — picks school two, but with the lightest machinery I've seen: the plain Gang-of-Four decorator pattern, wired through DI. The package is Convey.Logging.CQRS, it is genuinely small, and it contains simultaneously one of my favourite designs in the toolkit and the single most audacious hack in the codebase. Both in about 120 lines.
The seam was cut in part 3
Recall the Scrutor scan that registers handlers: it excludes classes bearing [Decorator]. That exclusion is the whole trick. The logging package ships classes like this (Decorators/CommandHandlerLoggingDecorator.cs):
[Decorator]
internal sealed class CommandHandlerLoggingDecorator<TCommand> : ICommandHandler<TCommand>
where TCommand : class, ICommand
{
private readonly ICommandHandler<TCommand> _handler; // the one you wrote
...
}
It is an ICommandHandler<TCommand> — so the scan would happily register it as one, creating a handler that wraps nothing or, worse, a circular resolution. The attribute keeps it out of the scan; Scrutor's Decorate machinery then re-registers your real handler inside it. When the dispatcher asks for ICommandHandler<CreateParcel>, DI hands back the decorator holding your handler. Your code changes not at all. That's the seam: one marker attribute agreed between two packages that otherwise don't know each other.
Templates, not format strings
What the decorator does with the seam is the design I like. Instead of hardcoding log messages, it asks an interface you implement:
public interface IMessageToLogTemplateMapper
{
HandlerLogTemplate Map<TMessage>(TMessage message) where TMessage : class;
}
A HandlerLogTemplate has three slots: Before, After, and OnError — a dictionary from exception Type to template. Your mapper is a catalogue of what each message means in operational language:
public class MessageTemplateMapper : IMessageToLogTemplateMapper
{
private static readonly Dictionary<Type, HandlerLogTemplate> Templates = new()
{
[typeof(CreateParcel)] = new HandlerLogTemplate
{
Before = "Creating a parcel with id: {ParcelId}...",
After = "Created a parcel with id: {ParcelId}.",
OnError = new Dictionary<Type, string>
{
[typeof(RecipientBlockedException)] =
"Cannot create parcel {ParcelId}: recipient is blocked."
}
}
};
public HandlerLogTemplate Map<TMessage>(TMessage message) where TMessage : class
=> Templates.TryGetValue(message.GetType(), out var template) ? template : null;
}
Those placeholders aren't string.Format indices — they're SmartFormat named placeholders, resolved against the message object itself: Smart.Format(template.Before, command). The decorator's whole hot path is: map the message; if the template is null, invoke the inner handler and get out of the way; otherwise log Before, invoke, log After, and on exception look up the type-specific OnError template, log it as an error, and re-throw. Logging never swallows.
Two defensive touches make it safe to adopt incrementally. The mapper is resolved with GetService (not GetRequiredService), falling back to a private empty mapper that returns null for everything — so wiring the decorators without a mapper produces a zero-cost pass-through, not a crash. And a null template per-message means you opt messages in one at a time. The result reads like a runbook: every significant command's lifecycle described in one place, in operator language, out of the handlers' way. I ported this exact shape into a chassis at a previous engagement and on-call engineers specifically praised the logs.
The hack: reflecting into Scrutor to find TryDecorate
Now the audacious part. Wiring the decorators means calling Scrutor's TryDecorate(serviceType, decoratorType) for each handler interface. Convey's AddCommandHandlersLogging() does this (Extensions.cs):
handlers.ForEach(ch => GetExtensionMethods()
.FirstOrDefault(mi => !mi.IsGenericMethod && mi.Name == "TryDecorate")?
.Invoke(builder.Services, new object[]
{
builder.Services,
ch.GetInterfaces().FirstOrDefault(),
decoratorType.MakeGenericType(ch.GetInterfaces().FirstOrDefault()?.GenericTypeArguments.First())
}));
Read it twice, because yes: GetExtensionMethods() enumerates Scrutor's own assembly (via typeof(ReplacementBehavior).Assembly), collects every static extension method whose first parameter is IServiceCollection, and string-matches "TryDecorate" to find the right one to invoke. Convey references Scrutor directly — TryDecorate is right there, callable — and instead locates it by reflection at runtime.
Why? My best archaeology: pinning behaviour across Scrutor versions whose overload sets differed, while FirstOrDefault(...)?.Invoke guaranteed the wiring never throws at startup. But look at the failure mode that buys. If a Scrutor upgrade renames the method or reorders overloads, FirstOrDefault returns null, ?. swallows it, and your logging silently stops being wired. No exception, no warning — you discover it the day you're grepping logs during an incident and the Before lines aren't there. A compile-time call would have turned that into a build error. This is the trade I want you to take from the whole article: defensive reflection converts loud, cheap failures into quiet, expensive ones. I admire the ingenuity and I would never let it through review.
One more subtlety in the same method: the assembly to scan for handlers defaults to Assembly.GetCallingAssembly(). Call AddCommandHandlersLogging() from your service and that's your service assembly — fine. Wrap it in your own convenience extension in a shared library, and the calling assembly is now your library, which contains no handlers, and decoration quietly applies to nothing. Pass the assembly explicitly; the parameter exists.
The sharp edges in daily use
Three more things I learned operating this package rather than reading it.
OnError matches exception types exactly. GetExceptionTemplate is a dictionary TryGetValue on ex.GetType() — no inheritance walk. A template registered for DomainException will not fire for RecipientBlockedException : DomainException. You either register every concrete type or accept that subclasses fall through to no error template (the re-throw still happens; you just lose the tailored message).
SmartFormat runs inside your success path. Smart.Format executes between your handler completing and control returning to the dispatcher. A typo'd placeholder or a property that throws on access can therefore throw after the handler succeeded — the command's effects are committed, but the caller sees an exception. Templates are unvalidated strings; treat them like code in review, and prefer dumb properties on your messages.
Queries get nothing. The package decorates command and event handlers only — there is no QueryHandlerLoggingDecorator. Given part 3 showed the query dispatcher resolving handlers reflectively, decoration would work fine; it's simply not implemented. The asymmetry is defensible (queries are read-only and chatty) but it surprises teams who assume symmetric coverage. Rolling your own from the command decorator is an hour's work — the seam is already there.
What the seam teaches
Strip away the SmartFormat and the Scrutor spelunking and the durable lesson is architectural: one attribute, agreed between packages, turned an assembly scan into an extension point. The CQRS packages didn't need to know logging would exist; they just promised to skip [Decorator] classes. Any cross-cutting concern can climb through the same gap — validation, metrics, idempotency guards, authorization. When I design internal toolkits now, I design that seam on day one, even with nothing to plug into it yet.
Next part, we leave the message bus for the front door: Convey's controller-less HTTP layer — the endpoint DSL that looks remarkably like Minimal APIs, shipped years before them.