Mediator

MediatR provides three in-process messaging patterns. In Juice v9 all three can extend MessageBase so every message carries MessageId, CreatedAt, and TenantId automatically.

PatternInterfaceHandler interfaceUse when
Request/ResponseIRequest<TResponse>IRequestHandler<T, TResponse>Command or query — exactly one handler, returns a result
NotificationINotificationINotificationHandler<T>In-process fan-out — multiple handlers, no return value
Stream RequestIStreamRequest<TResponse>IStreamRequestHandler<T, TResponse>Async streaming — handler yields items one at a time

NuGet Package

PackagePurpose
MediatRCore MediatR library
Juice.MediatR.BehaviorsIdempotencyRequestBehavior<,>, TransactionBehavior<,,>
Juice.MediatR.ContractsIIdempotentRequest interface

Request / Response — Commands and Queries

A request routes to exactly one handler and always returns a result. Use it for commands (state-changing operations) and queries (read-only operations).

Define the message:

// Command — modifies state, idempotent (safe to retry)
public record CreateOrderCommand(string OrderId, string CustomerId)
    : MessageBase, IRequest<IOperationResult>, IIdempotentRequest
{
    public string IdempotencyKey => OrderId;
}

// Query — reads state, no side effects
public record GetOrderQuery(string OrderId) : MessageBase, IRequest<OrderDto?>;

Implement the handler:

public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, IOperationResult>
{
    private readonly AppDbContext _db;

    public CreateOrderCommandHandler(AppDbContext db) => _db = db;

    public async Task<IOperationResult> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        _db.Orders.Add(new Order(cmd.OrderId, cmd.CustomerId));
        await _db.SaveChangesAsync(ct);
        return OperationResult.Success();
    }
}

Dispatch:

var result = await mediator.Send(new CreateOrderCommand(orderId, customerId));

Notification — In-Process Events

A notification fans out to zero or more handlers and returns nothing. Use it to broadcast a domain event to multiple in-process listeners without coupling them to the sender.

Define the message:

public record OrderStatusChangedNotification(string OrderId, string NewStatus)
    : MessageBase, INotification;

Implement handlers (as many as needed):

public class SendStatusEmailHandler : INotificationHandler<OrderStatusChangedNotification>
{
    public async Task Handle(OrderStatusChangedNotification n, CancellationToken ct)
    {
        // send email ...
    }
}

public class WriteAuditLogHandler : INotificationHandler<OrderStatusChangedNotification>
{
    public async Task Handle(OrderStatusChangedNotification n, CancellationToken ct)
    {
        // write audit entry ...
    }
}

Dispatch:

await mediator.Publish(new OrderStatusChangedNotification(orderId, "Shipped"));

Sequential execution: Handlers run one after another in registration order by default. An exception in one handler stops the remaining handlers. For fire-and-forget or parallel dispatch, implement a custom INotificationPublisher and register it with MediatR.


Stream Request — Async Streaming

A stream request returns IAsyncEnumerable<TResponse>. The handler yields items one at a time and the caller consumes them as they arrive — ideal for large result sets or server-sent event feeds.

Define the message:

public record GetOrderEventsQuery(string OrderId) : MessageBase, IStreamRequest<OrderEventDto>;

Implement the handler:

public class GetOrderEventsHandler
    : IStreamRequestHandler<GetOrderEventsQuery, OrderEventDto>
{
    private readonly AppDbContext _db;

    public GetOrderEventsHandler(AppDbContext db) => _db = db;

    public async IAsyncEnumerable<OrderEventDto> Handle(
        GetOrderEventsQuery query,
        [EnumeratorCancellation] CancellationToken ct)
    {
        await foreach (var evt in _db.OrderEvents
            .Where(e => e.OrderId == query.OrderId)
            .AsAsyncEnumerable()
            .WithCancellation(ct))
        {
            yield return new OrderEventDto(evt.Type, evt.OccurredAt);
        }
    }
}

Consume the stream:

await foreach (var evt in mediator.CreateStream(new GetOrderEventsQuery(orderId)))
{
    Console.WriteLine($"{evt.Type} at {evt.OccurredAt}");
}

Registration

Register MediatR once, scanning all handler types from your assembly:

services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(MyHandler).Assembly);

    cfg.AddIdempotencyRequestBehavior();                    // deduplication — requires IIdempotentRequest
    cfg.AddOpenBehavior(typeof(AppTransactionBehavior<,>)); // transaction — requires Outbox setup
});

RegisterServicesFromAssembly discovers and registers all three handler types automatically:

Handler interfaceDiscovered automaticallyDI lifetime
IRequestHandler<T, R>YesTransient
INotificationHandler<T>YesTransient
IStreamRequestHandler<T, R>YesTransient

Handlers support full constructor injection. Scoped services such as DbContext and repositories resolve correctly because MediatR resolves handlers within the active DI scope.


See also

  • MessagingIMessage hierarchy, idempotency backends, and MessagingBuilder setup
  • Outbox Setup — transactional staging with TransactionBehavior
  • MediatR v8.5.0 archive — original IRequestManager / IdentifiedCommand pattern