Three Marker Interfaces and a Service Scope
Convey's CQRS layer is small enough to read in one sitting — commands, queries and events as empty interfaces, Scrutor scans to find handlers, and a scope-per-dispatch decision with consequences worth knowing before production.
Every .NET team that adopts CQRS-ish structure faces the same fork in the road: take MediatR, or write the sixty lines yourself. Convey, the open-source DevMentors toolkit this series dissects, chose to write the sixty lines — and having read them all, I think the most valuable thing I can do is show you exactly what those sixty lines commit you to. Twice now I have debugged production surprises that trace back to a single using statement in this package.
The vocabulary: three empty interfaces
Convey splits its CQRS spine across three NuGet packages — Convey.CQRS.Commands, Convey.CQRS.Queries, Convey.CQRS.Events — and the entire contract vocabulary is this:
public interface ICommand { } // do something (one handler)
public interface IQuery<TResult> : IQuery { } // ask something (one handler, typed result)
public interface IEvent { } // something happened (0..N handlers)
Markers, nothing more. A message is a plain class:
public class CreateParcel : ICommand
{
public Guid ParcelId { get; }
public string Recipient { get; }
public CreateParcel(Guid parcelId, string recipient)
{
ParcelId = parcelId == Guid.Empty ? Guid.NewGuid() : parcelId;
Recipient = recipient;
}
}
Handlers implement the matching generic interface (ICommandHandler<CreateParcel>, IQueryHandler<GetParcel, ParcelDto>, IEventHandler<ParcelShipped>) with a single HandleAsync. No base classes, no attributes, no pipeline behaviours. The semantic split — command has exactly one handler and returns nothing, query has exactly one handler and returns a value, event has any number of handlers — is enforced not by types but by how each dispatcher resolves from DI. Keep that in mind; it is the source of both the elegance and the traps.
Discovery: an assembly scan with one exclusion
You never register a handler by hand. Each package's Add*Handlers() runs a Scrutor scan (Convey.CQRS.Commands/Extensions.cs):
builder.Services.Scan(s =>
s.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies())
.AddClasses(c => c.AssignableTo(typeof(ICommandHandler<>))
.WithoutAttribute(typeof(DecoratorAttribute)))
.AsImplementedInterfaces()
.WithTransientLifetime());
Three details deserve attention. First, the scan covers every assembly currently loaded in the AppDomain — not “your” assembly. That's generous and usually fine. Second, everything registers transient, as its implemented interfaces. Third, classes marked [Decorator] are skipped — that little attribute (defined in the core package) is the hook that lets Convey's logging decorators wrap handlers without being registered as handlers, and it gets its own article next week.
The generosity of AppDomain.CurrentDomain.GetAssemblies() hides the nastiest footgun in the package: the CLR loads assemblies lazily, so an assembly not yet touched when AddCommandHandlers() executes simply isn't scanned. Handlers vanish without an error — you find out at runtime, when dispatch throws (commands) or silently does nothing (events). If your handlers live in a separate assembly from Program.cs, reference a type from it before wiring Convey, or you will meet this in your first integration environment.
Dispatch: what using var scope actually decides
All three dispatchers are singletons with the same skeleton. Here is the command one, complete (Dispatchers/CommandDispatcher.cs):
public async Task SendAsync<T>(T command, CancellationToken cancellationToken = default)
where T : class, ICommand
{
using var scope = _serviceProvider.CreateScope();
var handler = scope.ServiceProvider.GetRequiredService<ICommandHandler<T>>();
await handler.HandleAsync(command, cancellationToken);
}
GetRequiredService enforces “exactly one handler” — zero registered handlers throws immediately. The event dispatcher swaps in GetServices (plural) and a foreach, which encodes “zero-to-many”: handlers run sequentially, in registration order, and — worth engraving somewhere — fail-fast. If handler two of five throws, three through five never run, and there is no aggregation, isolation, or parallelism. In-process events in Convey are a convenience, not a delivery guarantee.
But the line I want you to stare at is using var scope. Every dispatch creates a fresh DI scope and disposes it after the handler finishes. For messages arriving off a RabbitMQ subscription this is exactly right — there is no ambient scope, so the dispatcher makes one. Inside an HTTP request, though, it means your handler does not share the request's scope. The scoped DbContext your middleware started a transaction on? The handler gets a different instance. Your scoped tenant-context service, populated from the JWT by a filter? Fresh, empty copy. Nothing crashes; your data is just quietly not what you think it is. Both of my production surprises were exactly this shape. MediatR, for contrast, resolves from whatever IServiceProvider you hand it — usually the request scope — which is why teams migrating between the two get burned.
There's a second, smaller trap: SendAsync<T> binds the handler lookup to the compile-time type T. Dispatch a command you're holding as ICommand and the container is asked for ICommandHandler<ICommand> — which doesn't exist. Only the query dispatcher solves this, and its solution is worth reading (Dispatchers/QueryDispatcher.cs):
public async Task<TResult> QueryAsync<TResult>(IQuery<TResult> query, CancellationToken cancellationToken = default)
{
using var scope = _serviceProvider.CreateScope();
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(query.GetType(), typeof(TResult));
var handler = scope.ServiceProvider.GetRequiredService(handlerType);
return await (Task<TResult>) handlerType
.GetMethod(nameof(IQueryHandler<IQuery<TResult>, TResult>.HandleAsync))?
.Invoke(handler, new object[] {query, cancellationToken});
}
query.GetType() closes the generic at runtime, so you can hold queries polymorphically — this is what lets Convey's HTTP layer bind a query from a route and dispatch it without knowing its concrete type. The price: MakeGenericType + GetMethod + Invoke on every call, with no MethodInfo caching. There's also a companion overload, QueryAsync<TQuery, TResult>, that skips reflection entirely when you can name both types. On a hot path, prefer it; in one service I measured the reflective overload at roughly triple the dispatch overhead — microseconds either way, but microseconds you're paying per query for no benefit when the type is statically known.
The quiet gem: PagedResult
The queries package smuggles in the most reused types in the whole toolkit: PagedQueryBase (Page, Results, OrderBy, SortOrder) and PagedResult<T>, which carries items plus paging metadata and a tidy projection combinator:
public PagedResult<U> Map<U>(Func<T, U> map)
=> PagedResult<U>.From(this, Items.Select(map));
Query the repository for entities, Map to DTOs, and the paging envelope survives the projection — small thing, removes a whole category of hand-rolled mapping code. One behaviour to know about (PagedResultBase.cs): the constructor silently clamps CurrentPage to TotalPages when a caller asks for a page past the end. Request page 50 of 3 and you get page 3's data back, labelled page 3 — not an empty page, not an error. Defensible UX; miserable to debug if your API contract implied out-of-range means empty.
Where I've landed
The whole spine is maybe two hundred lines, and having lived with it: the marker-interface vocabulary and the Scrutor scan are worth copying wholesale — they give you MediatR's ergonomics with zero dependencies you don't understand. The scope-per-dispatch choice is the one to make consciously rather than inherit: it's correct for message-driven work and surprising for request-driven work, and Convey uses one dispatcher for both. If I were building on it today I'd wrap dispatch in an interface that accepts an optional ambient scope — a ten-line change that would have saved me two incident retrospectives.
And that [Decorator] exclusion in the scan? That's the seam the next part climbs through: how Convey wraps every handler you wrote with logging you didn't write — using reflection against Scrutor's own internals to do it.