Table of Contents

Messages and handlers

Kind Message implements Handler implements Handlers Response
Query IQuery<TResponse> IQueryHandler<TQuery, TResponse> Exactly one Always
Command ICommand or ICommand<TResponse> ICommandHandler<TCommand> or ICommandHandler<TCommand, TResponse> Exactly one Optional
Notification INotification INotificationHandler<TNotification> Zero or more Never

Every handler exposes a single HandleAsync taking the message and a CancellationToken, returning ValueTask or ValueTask<TResponse>. Handlers may be internal, and are resolved from the current DI scope on every dispatch.

The marker hierarchy

All dispatchable messages implement IMessage. Queries and commands additionally implement IRequest through their IQueryBase and ICommandBase family markers, while notifications implement IMessage directly.

flowchart LR
    M[IMessage] --> R[IRequest]
    M --> N[INotification]
    R --> QB[IQueryBase]
    R --> CB[ICommandBase]
    QB --> Q["IQuery (of TResponse)"]
    CB --> C1["ICommand (resultless)"]
    CB --> C2["ICommand (of TResponse)"]

These markers are what pipeline behaviors constrain against to decide which requests they apply to.

Queries

A query always has a response:

public sealed record GetOrderQuery(Guid Id) : IQuery<Order?>;

internal sealed class GetOrderQueryHandler(OrderStore store)
    : IQueryHandler<GetOrderQuery, Order?>
{
    public ValueTask<Order?> HandleAsync(
        GetOrderQuery query,
        CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(store.Find(query.Id));
}
var order = await queries.QueryAsync(new GetOrderQuery(id), cancellationToken);

Commands

A command may return a response:

public sealed record CreateOrderCommand(string ProductId, int Quantity)
    : ICommand<Guid>;

internal sealed class CreateOrderCommandHandler
    : ICommandHandler<CreateOrderCommand, Guid>
{
    public ValueTask<Guid> HandleAsync(
        CreateOrderCommand command,
        CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(Guid.NewGuid());
}

Or it may be resultless:

public sealed record ClearOrdersCommand : ICommand;

internal sealed class ClearOrdersCommandHandler
    : ICommandHandler<ClearOrdersCommand>
{
    public ValueTask HandleAsync(
        ClearOrdersCommand command,
        CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;
}

Execute both forms with ExecuteAsync:

var orderId = await commands.ExecuteAsync(
    new CreateOrderCommand("keyboard", 2),
    cancellationToken);

await commands.ExecuteAsync(new ClearOrdersCommand(), cancellationToken);

A resultless ICommand is adapted to Unit only inside the pipeline; its public handler and dispatch methods remain resultless.

Notifications

Notifications can have zero or more handlers, and run sequentially in registration order. Publishing one with no handlers is a no-op.

public sealed record OrderCreated(Guid OrderId) : INotification;

internal sealed class RecordOrderCreated
    : INotificationHandler<OrderCreated>
{
    public ValueTask HandleAsync(
        OrderCreated notification,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"Order {notification.OrderId} was created.");
        return ValueTask.CompletedTask;
    }
}
await notifications.PublishAsync(new OrderCreated(orderId), cancellationToken);

Handler shapes

Notification handlers have three useful routing shapes. Given UserCreated : DomainEvent:

Shape Declaration When publishing UserCreated
Exact closed handler INotificationHandler<UserCreated> Selected and invoked
Polymorphic closed handler INotificationHandler<DomainEvent> Invoked only when no exact or more-specific closed handler exists
Constrained open generic handler Handler<TNotification> where TNotification : DomainEvent Closed as Handler<UserCreated> and invoked in addition to the selected closed handler

A closed handler has no remaining generic type parameters. Its handled base class or interface makes it a polymorphic fallback; it is not a generic constraint, and Dispatcher does not broadcast to it when an exact closed handler exists. Use a constrained open generic handler for logging, auditing, or other work that must run for every concrete event in a hierarchy alongside its specific handler.

Open generic handlers

An open generic notification handler can observe every compatible known concrete notification. The constraint determines which notification hierarchy it observes:

// All notifications
internal sealed class AuditHandler<TNotification>
    : INotificationHandler<TNotification>
    where TNotification : INotification
{
    public ValueTask HandleAsync(
        TNotification notification,
        CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;
}

// Constrained observer
public abstract record DomainEvent : INotification;

public sealed record UserCreated(Guid UserId) : DomainEvent;

internal sealed class DomainEventLogger<TNotification>
    : INotificationHandler<TNotification>
    where TNotification : DomainEvent
{
    public ValueTask HandleAsync(
        TNotification notification,
        CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;
}

Reflection assembly scanning discovers this canonical shape automatically. Generated handler registration discovers the same shape at compile time and emits a closed service descriptor for every compatible known concrete notification, including value types. Dispatcher first invokes the one selected closed notification route, then compatible open handlers in registration order, closed over the concrete published type.

If INotificationHandler<UserCreated> is also registered, publishing UserCreated invokes that exact handler first and then DomainEventLogger<UserCreated>. A closed INotificationHandler<DomainEvent> would not run in that case because the exact closed route wins.

Important

Open handlers are registered as their own services and therefore do not appear in IEnumerable<INotificationHandler<TNotification>>. That enumerable remains the closed-handler view.

Missing and duplicate handlers

Queries and commands require exactly one selected handler. A missing handler throws HandlerNotFoundException at dispatch; duplicate handlers for the same handled message type throw DuplicateHandlerException when the registry is created. Notifications have no such requirement.

"Compatible" is broader than an exact type match. See Routing.