Learnixo
Back to blog
AI Systemsintermediate

MediatR in Vertical Slice Architecture — Commands, Queries, and Pipeline Behaviors

Use MediatR as the backbone of Vertical Slice Architecture: IRequest, IRequestHandler, pipeline behaviors for cross-cutting concerns, notifications for domain events, and registering MediatR in ASP.NET Core.

Asma Hafeez KhanMay 16, 20264 min read
Vertical SliceMediatRCQRSPipeline BehaviorsASP.NET Core.NET
Share:𝕏

MediatR's Role in Vertical Slice

MediatR is a message dispatcher:
  Endpoint sends a Command/Query → MediatR finds the Handler → Handler executes

Benefits in Vertical Slice:
  ✓ Decouples endpoint from handler (no direct method calls)
  ✓ Pipeline behaviors run automatically (logging, validation, performance)
  ✓ One handler per command — no ambiguity about who handles what
  ✓ Easy to test handlers in isolation (no HTTP context needed)
  ✓ ISender injected into endpoints — no service class needed

MediatR does NOT provide:
  ✗ Distributed messaging (use Azure Service Bus, RabbitMQ for that)
  ✗ Retry, dead-letter, guaranteed delivery
  ✗ Cross-process communication
  MediatR is in-process only — it is a design pattern, not a message broker.

IRequest and IRequestHandler

C#
// Command (mutates state, returns Result)
public sealed record CreatePrescriptionCommand(
    Guid   PatientId,
    string MedicationName,
    decimal DoseAmount,
    string  DoseUnit,
    Guid   PrescriberId) : IRequest<Result<Guid>>;

// Handler
public sealed class CreatePrescriptionHandler
    : IRequestHandler<CreatePrescriptionCommand, Result<Guid>>
{
    private readonly ApplicationDbContext _db;
    private readonly IUnitOfWork          _uow;

    public CreatePrescriptionHandler(ApplicationDbContext db, IUnitOfWork uow)
    {
        _db  = db;
        _uow = uow;
    }

    public async Task<Result<Guid>> Handle(
        CreatePrescriptionCommand command, CancellationToken ct)
    {
        var patient = await _db.Patients
            .FirstOrDefaultAsync(p => p.Id == new PatientId(command.PatientId), ct);

        if (patient is null)
            return Result.Failure<Guid>(DomainErrors.Patient.NotFound);

        var prescription = Prescription.Create(
            new PatientId(command.PatientId),
            command.MedicationName,
            new DosageValue(command.DoseAmount, command.DoseUnit),
            new ClinicianId(command.PrescriberId));

        if (prescription.IsFailure)
            return Result.Failure<Guid>(prescription.Error);

        await _db.Prescriptions.AddAsync(prescription.Value, ct);
        await _uow.SaveChangesAsync(ct);

        return Result.Success(prescription.Value.Id.Value);
    }
}

Validation Pipeline Behavior

C#
// SharedKernel/Behaviors/ValidationBehavior.cs
// Runs FluentValidation before every handler — no validation code in handlers
public sealed class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any()) return await next();

        var context = new ValidationContext<TRequest>(request);
        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next();
    }
}

Logging Pipeline Behavior

C#
// SharedKernel/Behaviors/LoggingBehavior.cs
public sealed class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
        => _logger = logger;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        _logger.LogInformation("Handling {Request}", requestName);

        var sw = Stopwatch.StartNew();
        var response = await next();
        sw.Stop();

        _logger.LogInformation(
            "Handled {Request} in {ElapsedMs}ms",
            requestName, sw.ElapsedMilliseconds);

        return response;
    }
}

Registering MediatR and Behaviors

C#
// Program.cs
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);

    // Pipeline behaviors run in registration order
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehavior<,>));
});

// Register all validators from the assembly (FluentValidation)
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

INotification for Domain Events

C#
// Publish a notification after a prescription is created
// Multiple handlers can subscribe — fire-and-forget fanout

public sealed record PrescriptionCreatedNotification(
    Guid PrescriptionId,
    Guid PatientId,
    string MedicationName) : INotification;

// Handler 1: send notification to pharmacy
public sealed class SendPharmacyNotificationHandler
    : INotificationHandler<PrescriptionCreatedNotification>
{
    private readonly IPharmacyNotifier _notifier;

    public Task Handle(PrescriptionCreatedNotification notification, CancellationToken ct)
        => _notifier.SendNewPrescriptionAsync(notification.PrescriptionId, ct);
}

// Handler 2: update patient medication count in cache
public sealed class InvalidatePatientCacheHandler
    : INotificationHandler<PrescriptionCreatedNotification>
{
    private readonly IPatientCache _cache;

    public Task Handle(PrescriptionCreatedNotification notification, CancellationToken ct)
        => _cache.InvalidateAsync(notification.PatientId, ct);
}

// Publishing in the command handler:
await _mediator.Publish(new PrescriptionCreatedNotification(
    prescriptionId, command.PatientId, command.MedicationName), ct);

Production issue I've seen: A team used MediatR INotification for pharmacy alerts with three handlers: SendSMSHandler, SendEmailHandler, and LogAuditHandler. If SendSMSHandler threw an exception, MediatR stopped processing notifications — SendEmailHandler and LogAuditHandler never ran. The audit log was missing for any prescription where the SMS failed. MediatR's Publish() is fail-fast — if one handler throws, subsequent handlers don't execute. Use IMediator.Publish() only for notifications where partial execution is acceptable, or run handlers independently with error isolation.


Key Takeaway

MediatR dispatches commands and queries to their handlers in-process. Use pipeline behaviors for cross-cutting concerns (logging, validation) — they run for every request without repeating code in handlers. Use INotification for domain event fanout, but remember that publish is fail-fast: one handler throwing stops the rest. Register behaviors in the correct order — logging wraps validation wraps the handler. MediatR is not a message broker — it is a local dispatcher pattern.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.