Nine Lines Stop a Stack Overflow
Inflow registers every handler by assembly scan and then wraps them in decorators. Those two techniques are incompatible unless something tells the scanner to skip the decorators - and in this codebase that something is a marker attribute with no tests and no comment explaining what it prevents.
Part 8 followed a message to the point where a handler is resolved from the container. This part is about what is wrapped around that handler by the time it runs, and about the smallest file in the shared framework.
Here is the whole file, Inflow.Shared.Infrastructure/DecoratorAttribute.cs:
using System;
namespace Inflow.Shared.Infrastructure;
// Marker
[AttributeUsage(AttributeTargets.Class)]
public class DecoratorAttribute : Attribute
{
}
Nine lines including the comment. It has no properties, no constructor, no behaviour, and no XML documentation. It is applied to six classes in the repository and referenced by four Scrutor scans. Remove it, and the application will StackOverflowException on the first message it handles.
Two techniques that do not compose
Inflow registers handlers by scanning. AddEvents is representative:
public static IServiceCollection AddEvents(this IServiceCollection services, IEnumerable<Assembly> assemblies)
{
services.AddSingleton<IEventDispatcher, EventDispatcher>();
services.Scan(s => s.FromAssemblies(assemblies)
.AddClasses(c => c.AssignableTo(typeof(IEventHandler<>))
.WithoutAttribute<DecoratorAttribute>())
.AsImplementedInterfaces()
.WithScopedLifetime());
return services;
}
“Find every class assignable to IEventHandler<> in these assemblies and register it against its interfaces, scoped.” That is why a module can declare a handler with no registration code at all — the module's Register method never mentions handlers.
Inflow also decorates handlers. AddLoggingDecorators is three lines:
services.TryDecorate(typeof(ICommandHandler<>), typeof(LoggingCommandHandlerDecorator<>));
services.TryDecorate(typeof(IEventHandler<>), typeof(LoggingEventHandlerDecorator<>));
services.TryDecorate(typeof(IQueryHandler<,>), typeof(LoggingQueryHandlerDecorator<,>));
Scrutor's TryDecorate rewrites every existing registration for IEventHandler<T> so that resolving it constructs the decorator, which receives the original implementation through its own constructor.
Now put the two together without the marker. LoggingEventHandlerDecorator<T> is declared as internal sealed class LoggingEventHandlerDecorator<T> : IEventHandler<T>. It is assignable to IEventHandler<>. The scan would find it and register it as an ordinary handler alongside the module's real one. Then TryDecorate would rewrite that registration too — so resolving IEventHandler<CustomerVerified> would construct a LoggingEventHandlerDecorator<CustomerVerified> whose inner _handler is another LoggingEventHandlerDecorator<CustomerVerified>, whose inner handler is another, until the container gives up or the stack does.
.WithoutAttribute<DecoratorAttribute>() on every scan is what prevents it. Four scans — commands, queries, events, domain events — every one carries the filter. Nine lines of attribute and one method call per scan are the entire reason an assembly-scanning container and a decorator chain can coexist in this codebase.
It is a good pattern and I have used it since reading this. It is also completely undefended: there is no test asserting that resolving IEventHandler<T> yields a chain of finite depth, no analyser checking that every [Decorator] class is excluded from every scan, and no comment anywhere except the bare word // Marker. Add a fifth scan next year and forget the filter, and you find out at runtime.
The order is load-bearing too
TryDecorate rewrites the descriptors that exist when it is called, and each subsequent call wraps the previous wrapper. So the order of registration in AddModularInfrastructure is not stylistic:
line 92 services.AddCommands(assemblies); // scan -> scoped
line 93 services.AddQueries(assemblies); // scan -> scoped
line 94 services.AddEvents(assemblies); // scan -> scoped
line 95 services.AddDomainEvents(assemblies);
line 96 services.AddMessaging();
line 100 services.AddLoggingDecorators(); // TryDecorate x3
line 101 services.AddPostgres();
line 102 services.AddOutbox(); // TryDecorate IEventHandler<> when enabled
With the outbox enabled, resolving an event handler yields InboxEventHandlerDecorator<T> → LoggingEventHandlerDecorator<T> → the module's handler. Reverse lines 100 and 102 and you get the opposite nesting. Nothing asserts the order. Nothing comments on it.
That nesting has a consequence nobody wrote down. The inbox decorator checks for a duplicate message and returns early if it finds one — outside the logging decorator. So a suppressed duplicate never reaches "Handling an event", and EfInbox logs the skip at LogTrace, which the shipped logger:level: information filters out. At the shipped log level, a deduplicated message produces no log output at all. An operator watching a message arrive on the wire and nothing happen has no line to grep for. Swapping the two TryDecorate calls would put logging outermost and fix it; whether that is the right call is arguable, but it should have been an argument rather than an accident of line order.
The bigger hole: handlers the scan never sees
Here is the constraint that actually worries me. Startup.ConfigureServices is:
services.AddModularInfrastructure(_assemblies, _modules);
foreach (var module in _modules)
{
module.Register(services);
}
Every module's Register runs after the whole of AddModularInfrastructure, which means after all four scans and after every TryDecorate. A handler registered by hand inside module.Register(services) — services.AddScoped<IEventHandler<Something>, SomethingHandler>() — is added to the collection after the decoration pass and therefore receives no decorators at all. No logging, no correlation id in its log lines (because it has none), no inbox deduplication when the outbox is on.
Nothing catches this. Not an exception, not a warning, not a log line. The handler works perfectly; it is simply invisible and unprotected. The estate is saved only by the fact that every module relies on the scan and none of them registers a handler by hand.
This is the kind of constraint that wants a comment at minimum and an assertion ideally. A defensive version is not hard:
// Fresh illustrative code, not from the repository.
internal static void AssertAllHandlersDecorated(IServiceCollection services)
{
var undecorated = services
.Where(d => d.ServiceType.IsGenericType &&
d.ServiceType.GetGenericTypeDefinition() == typeof(IEventHandler<>))
.Where(d => d.ImplementationType is not null &&
d.ImplementationType.GetCustomAttribute<DecoratorAttribute>() is null)
.Select(d => d.ImplementationType!.FullName)
.ToArray();
if (undecorated.Any())
{
throw new InvalidOperationException(
"Event handlers registered after the decoration pass will not be decorated: " +
string.Join(", ", undecorated));
}
}
Called at the end of ConfigureServices, after the module loop, it converts an invisible gap into a startup failure naming the offending type. Roughly fifteen lines to protect a constraint that spans two files and is stated in neither.
What the chain actually looks like at runtime
It is worth drawing the resolution once, because the number of layers surprises people who have only seen the registration code.
With the outbox enabled, asking the container for IEventHandler<CustomerVerified> inside the Wallets module gives you:
InboxEventHandlerDecorator<CustomerVerified>
-> LoggingEventHandlerDecorator<CustomerVerified>
-> CustomerVerifiedHandler
Three objects constructed per resolution, and the resolution itself happens inside a scope that EventDispatcher created for this one publish. Multiply by receivers: a single CustomerVerified published from Customers fans out to Payments, Wallets and the Saga, so three scopes, nine objects, three DbContext instances if the handlers touch persistence.
That is not expensive in absolute terms — the container is fast and these are small objects — but it is worth knowing that the decorator chain is per resolution, not per registration, and that Scrutor's rewriting means you cannot see the depth by reading any single file. The registration that produces this is services.AddScoped<IEventHandler<CustomerVerified>, CustomerVerifiedHandler>() implied by a scan, plus two TryDecorate calls in two different extension methods called from a third file.
The debugging consequence is the one that bites. If a handler is not being decorated — no logging, no idempotency — there is nothing to look at. IServiceCollection has been rewritten in place, the original descriptor is gone, and the only way to establish what you will actually get is to build the provider and resolve. A one-line diagnostic endpoint that resolves a known handler and walks its private _handler field would tell an operator more about this system's composition than any amount of reading, and this framework — which already exposes GET /modules — has no such thing.
The tell that the two decorator families were written at different times
One small inconsistency, and it is the kind I have learned to read as a timestamp. Every decorator in the repository is internal sealed — the three logging ones, the inbox one, the paged-query one. Two are not: TransactionalCommandHandlerDecorator<T> and TransactionalEventHandlerDecorator<T> are both public and neither is sealed.
Those two are also the only decorators that are never registered. AddTransactionalDecorators() exists at Postgres/Extensions.cs:77 and I grepped the entire repository for callers: there are none. The two classes that would make command handling transactional are compiled, referenced by nothing, and dead — and with them IUnitOfWork, PostgresUnitOfWork<T> and the four module-level *UnitOfWork classes, which are registered by AddUnitOfWork<T>() and never invoked, because the only caller of IUnitOfWork.ExecuteAsync is those two decorators.
To be fair, this is very likely deliberate. A course that has just shown you a decorator chain wants a second worked example you can switch on yourself, and shipping it registered would have added a transaction around every handler in a demo that does not need one. The visibility difference is what makes me confident it is a different writing session rather than an oversight — accessibility conventions are the thing that slips when you come back to a file six months later.
A marker attribute holding up a strategy, with no test, is a load-bearing nine lines. So is a registration order nobody asserted, and a decoration pass that silently skips anything registered after it. All three are one comment away from being obvious and one test away from being safe.
Next, one assignment freezes every trace — the ??= inside the dispatcher loop, and how the mechanism added to carry identity across the async boundary is the mechanism that destroys it.