CQRS with MediatR in ASP.NET Core: A Working Example

Last updated: October 1, 2026 · Tested with .NET 10 (SDK 10.0.201), MediatR 12.5.0, FluentValidation 12.1.1, EF Core 10.0.12

Short answer: CQRS splits your application code into commands that change state and queries that only read it. MediatR is the in-process dispatcher that routes each one to exactly one handler, so an endpoint does nothing but sender.Send(request), and cross-cutting work such as validation and logging is written once as a pipeline behavior. Below is a complete working example on .NET 10 with the real console and HTTP output, what happens when a notification handler throws, what the mediator costs per call, and one thing to know before you install it: MediatR 13 and later are commercially licensed, and that is the version NuGet gives you by default.

What CQRS Means Here

CQRS stands for Command Query Responsibility Segregation. A command expresses an intent to change something (CreateProduct, DeactivateCustomer) and returns little or nothing. A query returns data and changes nothing. Each gets its own request type and its own handler class.

That is all it has to be. You do not need two databases, event sourcing or a message bus to benefit. Separate classes over one database is the version most applications need, and you can split storage later if read load ever demands it.

It earns its place when business rules differ between reads and writes, when several developers need clear boundaries between features, and when you want validation, logging or transactions applied the same way to every use case. For a five-entity CRUD app it is extra files for no gain.

Install MediatR, and Pin the Version

dotnet add package MediatR --version 12.5.0
dotnet add package FluentValidation.DependencyInjectionExtensions

The pin matters. A plain dotnet add package MediatR installed 14.2.0 on my machine. Versions 13 and up are dual-licensed (RPL-1.5 or a commercial licence). Without a licence key the code still runs, but it logs this at startup, which I captured from a real run:

warn: LuckyPennySoftware.MediatR.License[0] You do not have a valid license key for the
Lucky Penny software MediatR. This is allowed for development and testing scenarios. If you
are running in production you are required to have a licensed version.

There is a free Community edition for organisations under $5 million in annual revenue that have not raised more than $10 million, but you still have to register for a key. Version 12.5.0 stays under its original open-source licence, has the same API used in this article, and is what everything below runs on. Choose deliberately rather than by accident.

Registration

builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<Program>();
    cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));      // registered first = outermost
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));   // runs inside logging, before the handler
});

RegisterServicesFromAssemblyContaining scans for every handler in that assembly. Behaviors run in the order you register them, like middleware.

The Write Side: a Command, Its Validator and Its Handler

public record CreateProductCommand(string Name, string Description, decimal Price, string Category)
    : IRequest<int>;

public class CreateProductValidator : AbstractValidator<CreateProductCommand>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Price).GreaterThan(0);
        RuleFor(x => x.Category).NotEmpty();
    }
}

public class CreateProductHandler(AppDbContext db, IPublisher publisher)
    : IRequestHandler<CreateProductCommand, int>
{
    public async Task<int> Handle(CreateProductCommand request, CancellationToken ct)
    {
        var product = new Product
        {
            Name = request.Name, Description = request.Description,
            Price = request.Price, Category = request.Category, CreatedAt = DateTime.UtcNow
        };
        db.Products.Add(product);
        await db.SaveChangesAsync(ct);

        await publisher.Publish(new ProductCreated(product.Id, product.Name), ct);
        return product.Id;
    }
}

The command is a record, so a behavior cannot mutate it on the way through. One command, one handler, one use case: this class is the natural home for the logic, not a three-line forwarder to a service.

The Read Side: Queries That Return DTOs

public record ProductDto(int Id, string Name, decimal Price, string Category);

public record GetProductByIdQuery(int Id) : IRequest<ProductDto?>;

public class GetProductByIdHandler(AppDbContext db) : IRequestHandler<GetProductByIdQuery, ProductDto?>
{
    public Task<ProductDto?> Handle(GetProductByIdQuery request, CancellationToken ct) =>
        db.Products.AsNoTracking()
            .Where(p => p.Id == request.Id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category))
            .FirstOrDefaultAsync(ct);
}

public record GetAllProductsQuery(string? Category = null) : IRequest<List<ProductDto>>;

public class GetAllProductsHandler(AppDbContext db) : IRequestHandler<GetAllProductsQuery, List<ProductDto>>
{
    public Task<List<ProductDto>> Handle(GetAllProductsQuery request, CancellationToken ct)
    {
        var query = db.Products.AsNoTracking();
        if (!string.IsNullOrWhiteSpace(request.Category))
            query = query.Where(p => p.Category == request.Category);
        return query.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category)).ToListAsync(ct);
    }
}

Queries project straight to a DTO with AsNoTracking(). They never load an entity they do not need, and they cannot accidentally save anything. Note that the read side talks to the DbContext directly: with CQRS a repository layer is optional, and for queries it usually gets in the way.

Pipeline Behaviors: Validation and Logging Written Once

public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        var failures = (await Task.WhenAll(validators.Select(v => v.ValidateAsync(request, ct))))
            .SelectMany(r => r.Errors).ToList();
        if (failures.Count > 0) throw new ValidationException(failures);
        return await next();
    }
}

public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        var name = typeof(TRequest).Name;
        Console.WriteLine($"[logging] -> {name}");
        var sw = Stopwatch.StartNew();
        try { return await next(); }
        finally { Console.WriteLine($"[logging] <- {name} ({sw.ElapsedMilliseconds} ms)"); }
    }
}

Every command that has a validator is now validated before its handler runs, and you cannot forget to do it. The same shape gives you caching for queries and a transaction around commands.

What It Does: Real Output

The demo runs against SQLite. Each line is printed by the component named in brackets:

1) Command: create a product
   [logging] -> CreateProductCommand
   [validation] CreateProductCommand ok
   [handler] saved product 1
   [notification] cache invalidated for product 1
   [notification] audit entry written for product 1
   [notification] email queued for product 1
   [logging] <- CreateProductCommand (159 ms)
   returned id = 1

2) Query: read it back as a DTO
   [logging] -> GetProductByIdQuery
   [logging] <- GetProductByIdQuery (200 ms)
   ProductDto { Id = 1, Name = Mechanical Keyboard, Price = 89.9, Category = Peripherals }

3) Invalid command: the validation behavior stops it before the handler runs
   [logging] -> CreateProductCommand
   [validation] rejected CreateProductCommand with 2 error(s)
   [logging] <- CreateProductCommand (7 ms)
   validation: Name: 'Name' must not be empty.
   validation: Price: 'Price' must be greater than '0'.

The 159 ms and 200 ms on the first command and first query are one-time costs: EF Core compiling its model and the first query. The next commands in the same run took 2 ms each.

The Endpoint: Thin, and a Proper 400 for Validation Errors

app.MapPost("/api/products", async (CreateProductCommand command, ISender sender) =>
{
    var id = await sender.Send(command);
    return Results.Created($"/api/products/{id}", new { id });
});

app.MapGet("/api/products/{id:int}", async (int id, ISender sender) =>
    await sender.Send(new GetProductByIdQuery(id)) is { } dto ? Results.Ok(dto) : Results.NotFound());

Inject ISender rather than IMediator when you only send; it says what the endpoint does. The behavior throws ValidationException, so map it to a 400 once with an IExceptionHandler:

public class ValidationExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception exception, CancellationToken ct)
    {
        if (exception is not ValidationException ex) return false;
        http.Response.StatusCode = StatusCodes.Status400BadRequest;
        var details = new ValidationProblemDetails(ex.Errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()))
        { Status = 400, Title = "Validation failed" };
        return await problemDetails.TryWriteAsync(new ProblemDetailsContext { HttpContext = http, ProblemDetails = details });
    }
}

// Program.cs
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();

Real responses from curl against the running app:

POST /api/products  {"name":"Keyboard","price":89.9,"category":"Peripherals"}
HTTP/1.1 201 Created
Location: /api/products/1
{"id":1}

POST /api/products  {"name":"","price":-5,"category":"x"}
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Validation failed","status":400,
 "errors":{"Name":["'Name' must not be empty."],"Price":["'Price' must be greater than '0'."]}}

More on that handler and the rest of the error pipeline is in the global error handling guide.

Notifications, and the Failure Mode Nobody Mentions

A notification is published to zero or more handlers. The command above publishes ProductCreated, and three handlers react: invalidate a cache, write an audit entry, queue an email. Then I made the audit handler throw:

5) A notification handler that throws
   [logging] -> CreateProductCommand
   [validation] CreateProductCommand ok
   [handler] saved product 4
   [notification] cache invalidated for product 4
   [logging] <- CreateProductCommand (1 ms)
   Send threw: audit store is down
   product was saved anyway: True

Three things happened that you need to design for:

  • Handlers run one after another, and the first exception stops the rest. The email handler never ran.
  • The exception travels back through Publish into your command handler and out of Send. The caller gets an error.
  • The product was already saved. The client is told the request failed, retries, and you now have a duplicate.

MediatR 12 ships a second strategy, cfg.NotificationPublisher = new TaskWhenAllPublisher();, which is often described as “run every handler”. I tested all four combinations, and it is only half true:

PublisherHandler throwsDid the email handler run?Did Send throw?
Default (one by one)synchronouslyNoYes
Default (one by one)inside its async taskNoYes
TaskWhenAllPublishersynchronouslyNoYes
TaskWhenAllPublisherinside its async taskYesYes

A handler that throws before it returns its Task (a non-async method with a guard clause, for example) still stops the others even with the “when all” publisher, and in every case the caller still gets the exception after the data was committed. So the real fixes are: publish in a way that cannot fail the request (an outbox), or make each notification handler catch and log its own failures. In-process notifications are a convenience, not a reliable event bus.

What the Mediator Costs

I timed 200,000 trivial requests in a Release build, calling the handler directly and then through Send with the two behaviors above in the pipeline:

PathTotalPer call
Direct handler call3.4 ms17 ns
mediator.Send with 2 behaviors113.1 ms565 ns

This is a stopwatch loop, not BenchmarkDotNet, so treat it as an order of magnitude. About half a microsecond of overhead per request is invisible next to a database call measured in milliseconds, and it matters only if you dispatch through the mediator inside a tight loop. Do not do that.

Best Practices

  • Organise by feature. Keep the command, validator and handler together in Features/Products/CreateProduct.cs, not spread across Commands/, Handlers/ and Validators/. This is vertical slice architecture.
  • Name commands by intent. DeactivateCustomerCommand says more than UpdateCustomerCommand with a status flag.
  • Keep queries free of side effects, and enforce it with AsNoTracking() or a read-only connection.
  • Return DTOs, never entities. Entities are your persistence model. DTOs are your contract.
  • Pass the CancellationToken through. ASP.NET Core cancels it when the client disconnects, which stops wasted database work.

Pitfalls

  • Handlers that send to other handlers. It creates hidden call chains. Extract the shared logic into a plain service that both handlers use.
  • Anemic handlers. If every handler forwards to a service, you added indirection and nothing else.
  • Queries that write. A query that updates a “last viewed” timestamp will surprise someone. Make it a separate command.
  • MediatR for everything. A health check or a static lookup does not need a request and a handler.
  • Treating CQRS as all or nothing. Start with separate classes over one database.
  • Installing the latest package without reading the licence. See the top of this article.

FAQ

Is MediatR still free? Version 12.5.0 and earlier are open source. Version 13 and later need a licence key for production; a free Community edition exists for organisations under $5 million in revenue.

Do I need MediatR to do CQRS? No. CQRS is about separate command and query handlers. You can inject handlers directly or write a 30-line dispatcher. MediatR adds assembly scanning and the behavior pipeline.

What is the difference between Send and Publish? Send goes to exactly one handler and can return a value. Publish goes to any number of notification handlers and returns nothing.

Should I inject IMediator or ISender? ISender when you only send requests, IPublisher when you only publish. IMediator is both.

Where does validation go? In a pipeline behavior with FluentValidation, so it runs before every handler, plus one exception handler that turns ValidationException into a 400.

Does CQRS need two databases? No. That is an optional scaling step, not part of the pattern.

Conclusion

CQRS with MediatR gives you one small class per use case, endpoints that only translate HTTP, and cross-cutting rules that cannot be skipped. The run above also shows where it bites: notifications are sequential and can fail a request that already committed, and the package you get by default now needs a licence. Pin the version you intend to use, keep handlers honest, and structure the rest of the app with clean architecture or vertical slices around it.

References: MediatR licensing and editions · MediatR on GitHub · FluentValidation documentation · Microsoft: CQRS pattern

This article is part of the ASP.NET Core tutorials guide (APIs, security, observability and deployment).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *