Skip to content

Commands & queries

MediatR gives you one IRequest<T>. Mediant keeps it — and adds types that say what a message is.

TypeMeaningHandler
ICommand<T>Changes stateICommandHandler<TCommand, T>
ICommandChanges state, returns ResultICommandHandler<TCommand>
IQuery<T>Reads state, no side effectsIQueryHandler<TQuery, T>
IRequest<T>Neither, or a MediatR carry-overIRequestHandler<TRequest, T>
IStreamRequest<T>Async stream of TIStreamRequestHandler<TRequest, T>
INotification / IDomainEventFan-out, zero or many handlersINotificationHandler<T>

The distinction is not decorative. Some behaviors key off it: [Cacheable] applies to queries only, [Transactional] to commands only. The analyzers enforce that at compile time.

Commands

public record CreateOrder(string UserId, List<OrderItem> Items) : ICommand<Result<Guid>>;
public record CancelOrder(Guid Id) : ICommand<Result>;
public class CreateOrderHandler(IOrderRepository repo)
: ICommandHandler<CreateOrder, Result<Guid>>
{
public async ValueTask<Result<Guid>> Handle(CreateOrder cmd, CancellationToken ct)
{
var order = Order.Create(cmd.UserId, cmd.Items);
await repo.AddAsync(order, ct);
return Result<Guid>.Success(order.Id);
}
}

Queries

public record GetOrderById(Guid Id) : IQuery<Result<Order>>;
public class GetOrderByIdHandler(IOrderRepository repo)
: IQueryHandler<GetOrderById, Result<Order>>
{
public async ValueTask<Result<Order>> Handle(GetOrderById query, CancellationToken ct)
{
var order = await repo.FindAsync(query.Id, ct);
return order is null
? Error.NotFound("Order.NotFound", $"Order {query.Id} was not found.")
: Result<Order>.Success(order);
}
}

Sending

public class OrdersController(IMediator mediator)
{
public async Task<IActionResult> Create(CreateOrder cmd)
{
var result = await mediator.Send(cmd);
return result.Match(id => Created($"/orders/{id}", id), Problem);
}
}

Send returns ValueTask<TResponse> — for the common synchronous-completion case it does not allocate a Task.

Streaming

For results too large to materialize:

public record SearchOrders(string? Status) : IStreamRequest<Order>;
public class SearchOrdersHandler(IOrderRepository repo)
: IStreamRequestHandler<SearchOrders, Order>
{
public IAsyncEnumerable<Order> Handle(SearchOrders q, CancellationToken ct)
=> repo.StreamAsync(q.Status, ct);
}
await foreach (var order in mediator.CreateStream(new SearchOrders("open"), ct))
Console.WriteLine(order.Id);

Streams have their own pipeline hook, IStreamPipelineBehavior<TRequest, TResponse>, so you can wrap a stream without buffering it.

Notifications

Zero, one or many handlers. Nothing is returned.

await publisher.Publish(new OrderCreatedEvent(orderId, userId), ct);

Choose how handlers run:

cfg.NotificationPublishStrategy = NotificationPublishStrategy.Parallel; // or Sequential (default)

Sequential runs handlers one after another and stops at the first exception. Parallel starts them all and aggregates failures — use it when handlers are independent.

With EnablePolymorphicNotifications = true, publishing OrderCreatedEvent also invokes handlers registered for its base types and interfaces. See Domain events.

Fail fast on a missing handler

cfg.ValidateOnStartup = true;

Every request type is checked against the container at boot. A missing or ambiguous handler throws during startup, not on the first production request that needs it.