Commands & queries
MediatR gives you one IRequest<T>. Mediant keeps it — and adds types that say what a message is.
| Type | Meaning | Handler |
|---|---|---|
ICommand<T> | Changes state | ICommandHandler<TCommand, T> |
ICommand | Changes state, returns Result | ICommandHandler<TCommand> |
IQuery<T> | Reads state, no side effects | IQueryHandler<TQuery, T> |
IRequest<T> | Neither, or a MediatR carry-over | IRequestHandler<TRequest, T> |
IStreamRequest<T> | Async stream of T | IStreamRequestHandler<TRequest, T> |
INotification / IDomainEvent | Fan-out, zero or many handlers | INotificationHandler<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.