CQRS and the mediator pattern in .NET are two separate ideas that are usually adopted together: Command Query Responsibility Segregation splits the code that changes state from the code that reads it, and an in-process mediator dispatches each command or query to exactly one handler. This guide is for .NET developers deciding how far to take the split and which dispatch mechanism to use now that MediatR is commercially licensed. It covers command-query separation versus CQRS, handlers and pipeline behaviors, MediatR's 2025 licensing change, Wolverine, the source-generated Mediator library, hand-rolled dispatch, separate read models, and when all of this is overkill.
What Is CQRS? Command-Query Separation vs CQRS#
Command-query separation (CQS) is a method-level principle from Bertrand Meyer: a method either changes state and returns nothing (a command) or returns data without side effects (a query). Asking a question should not change the answer. CQS is a coding discipline, and most well-designed C# classes follow it without anyone naming it.
Command Query Responsibility Segregation (CQRS), a term popularized by Greg Young, applies the same idea one level up. Instead of one model that serves both writes and reads, you build a write model optimized for enforcing business rules and a read model optimized for queries. The two can share a database or live in different stores.
The distinction matters because CQRS is not an application-wide architecture style. Microsoft's microservices guidance states plainly that CQRS and most DDD patterns are architecture patterns rather than architectural styles, and should be applied where they help, typically inside specific bounded contexts. Martin Fowler's writing on CQRS is similarly cautious: for most systems, a full split adds risky complexity.
How CQRS Works: Commands, Queries and Handlers#
A command expresses an intention to change the system: PlaceOrder, CancelSubscription, ApproveInvoice. It is named in the imperative, can be rejected, and returns little or nothing beyond an identifier or a result status. Commands go through the domain model, where invariants are enforced.
A query asks for data shaped for a specific screen or API response: GetOrderSummary, SearchProducts. It must not change state, so it can skip the domain model entirely and project straight from the database with AsNoTracking, raw SQL or Dapper.
Each message has exactly one handler. The mediator is the classic Gang of Four pattern applied to this dispatch: callers send a message to the mediator, which finds the handler, so endpoints depend on message types rather than on handler classes. In-process mediator libraries also offer notifications (one message, many handlers) and pipeline behaviors that wrap every handler with cross-cutting concerns.
| Aspect | Command | Query |
|---|---|---|
| Intent | Change state | Read state |
| Naming | Imperative verb: PlaceOrder | Question or noun: GetOrderSummary |
| Side effects | Yes, inside a transaction | None |
| Return value | Identifier, status or nothing | DTO shaped for the consumer |
| Model used | Domain model and aggregates | Projections, views or SQL |
| Validation | Input rules plus domain invariants | Parameter checks only |
| Caching | Never | Often, safely |
| Scaling | Limited by consistency needs | Easy to scale out with replicas or caches |
In a Clean Architecture solution, commands, queries and handlers live in the Application layer, while the domain model they use comes from the Domain layer.
Getting Started: CQRS with MediatR#
MediatR is the most widely used in-process mediator for .NET. The example below defines a command and a query with their handlers. A small ICommand<T> marker interface lets pipeline behaviors treat commands differently from queries.
using MediatR;
namespace Shop.Application.Orders;
public interface ICommand { }
public interface ICommand<out TResponse> : IRequest<TResponse>, ICommand { }
public sealed record PlaceOrder(Guid CustomerId, IReadOnlyList<OrderLineInput> Lines)
: ICommand<Guid>;
public sealed record OrderLineInput(string Sku, int Quantity);
public sealed class PlaceOrderHandler(ShopDbContext db, IPriceList prices, TimeProvider clock)
: IRequestHandler<PlaceOrder, Guid>
{
public async Task<Guid> Handle(PlaceOrder command, CancellationToken cancellationToken)
{
var order = Order.Create(command.CustomerId, clock.GetUtcNow());
foreach (var line in command.Lines)
{
var price = await prices.GetPriceAsync(line.Sku, cancellationToken);
order.AddLine(line.Sku, line.Quantity, price);
}
order.Submit();
db.Orders.Add(order); // The transaction behavior saves and commits.
return order.Id;
}
}
public sealed record GetOrderSummary(Guid OrderId) : IRequest<OrderSummary?>;
public sealed record OrderSummary(Guid Id, string Status, decimal Total, int LineCount);
public sealed class GetOrderSummaryHandler(ShopDbContext db)
: IRequestHandler<GetOrderSummary, OrderSummary?>
{
public Task<OrderSummary?> Handle(GetOrderSummary query, CancellationToken cancellationToken) =>
db.Orders
.AsNoTracking()
.Where(o => o.Id == query.OrderId)
.Select(o => new OrderSummary(
o.Id,
o.Status.ToString(),
o.Lines.Sum(l => l.UnitPrice * l.Quantity),
o.Lines.Count))
.SingleOrDefaultAsync(cancellationToken);
}Registration scans an assembly for handlers and adds behaviors in the order they should wrap the handler: the first registered behavior is the outermost. Endpoints then depend only on ISender.
builder.Services.AddMediatR(cfg =>
{
cfg.LicenseKey = builder.Configuration["MediatR:LicenseKey"];
cfg.RegisterServicesFromAssemblyContaining<PlaceOrderHandler>();
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});
builder.Services.AddValidatorsFromAssemblyContaining<PlaceOrderValidator>();
var app = builder.Build();
// For brevity the command doubles as the request contract.
app.MapPost("/orders", async (PlaceOrder command, ISender sender, CancellationToken ct) =>
{
var id = await sender.Send(command, ct);
return Results.Created($"/orders/{id}", new { id });
});
app.MapGet("/orders/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
await sender.Send(new GetOrderSummary(id), ct) is { } summary
? Results.Ok(summary)
: Results.NotFound());MediatR's 2025 Licensing Change#
On April 2, 2025, Jimmy Bogard announced that MediatR and AutoMapper would move to a commercial model under his company, Lucky Penny Software, to fund their long-term maintenance. The commercial editions launched on July 2, 2025, with MediatR 13.0 as the first dual-licensed release. The facts that matter for planning:
- Versions 12.5.0 and earlier remain Apache 2.0. Version 12.5.0 shipped on April 1, 2025, just before the announcement.
- Version 13 and later are dual-licensed under the Reciprocal Public License 1.5 (a copyleft license) or a paid commercial license. The current release line is 14.x.
- A free Community edition covers companies and individuals under $5 million in gross annual revenue, non-profits under a $5 million budget, educational use and non-production environments. You still register for a license key.
- Paid tiers are priced by team size: Standard for up to 10 developers, Professional for up to 50, and Enterprise for unlimited developers.
- The key is set in code through
cfg.LicenseKeyor through an environment variable. A missing or expired key produces log warnings rather than disabling features.
For most teams, the decision is economic rather than technical. If you qualify for the Community edition or the license cost is trivial next to your payroll, staying on MediatR is reasonable. If not, you can pin 12.5.0 and accept that it receives no new features, or migrate. Migration is less painful than it sounds because handlers are plain classes; the work is mostly swapping interfaces and registration.
Pipeline Behaviors: Validation, Logging and Transactions#
Pipeline behaviors are the main reason teams adopt a mediator. Each behavior wraps the next one, like ASP.NET Core middleware, so a concern written once applies to every handler. In recent MediatR versions, the next delegate accepts an optional cancellation token.
public sealed class LoggingBehavior<TRequest, TResponse>(
ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var started = Stopwatch.GetTimestamp();
try
{
return await next(cancellationToken);
}
finally
{
logger.LogInformation("{Request} handled in {ElapsedMs:0.0} ms",
typeof(TRequest).Name, Stopwatch.GetElapsedTime(started).TotalMilliseconds);
}
}
}
public sealed 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 cancellationToken)
{
var failures = new List<ValidationFailure>();
foreach (var validator in validators) // sequential: validators may share a DbContext
{
var result = await validator.ValidateAsync(request, cancellationToken);
failures.AddRange(result.Errors);
}
return failures.Count == 0
? await next(cancellationToken)
: throw new ValidationException(failures);
}
}A transaction behavior turns every command into a unit of work. SaveChangesAsync is already atomic on its own; the explicit transaction earns its keep when a command writes to an outbox table, runs raw SQL or saves more than once. The EF Core execution strategy makes the whole block retryable when connection resiliency is enabled, which is the same approach the eShop reference application uses in its Ordering service.
public sealed class TransactionBehavior<TRequest, TResponse>(ShopDbContext db)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Queries and nested commands pass straight through.
if (request is not ICommand || db.Database.CurrentTransaction is not null)
return await next(cancellationToken);
var strategy = db.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
await using var transaction =
await db.Database.BeginTransactionAsync(cancellationToken);
var response = await next(cancellationToken);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return response;
});
}
}Translate ValidationException into a 400 ProblemDetails response with an IExceptionHandler, so endpoints stay free of try/catch blocks. Keep the pipeline short: logging, validation, transactions and perhaps authorization or caching. Every behavior runs for every message, so an expensive behavior multiplies across the whole application.
Alternatives to MediatR#
Source-generated Mediator#
The Mediator library by Martin Othamar keeps an API close to MediatR's but generates the dispatch code at compile time with a Roslyn source generator. It is MIT-licensed, returns ValueTask to reduce allocations, offers distinct ICommand, IQuery and IRequest message types, and documents full Native AOT support. Install Mediator.Abstractions where messages and handlers live, and Mediator.SourceGenerator only in the outermost host project; the README warns that adding the generator to every layer causes errors.
builder.Services.AddMediator((MediatorOptions options) =>
{
// Singleton is fastest, but these handlers depend on a scoped DbContext.
options.ServiceLifetime = ServiceLifetime.Scoped;
});
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TimingBehavior<,>));
public sealed record PlaceOrder(Guid CustomerId, IReadOnlyList<OrderLineInput> Lines)
: ICommand<Guid>;
public sealed class PlaceOrderHandler(ShopDbContext db, IPriceList prices, TimeProvider clock)
: ICommandHandler<PlaceOrder, Guid>
{
public async ValueTask<Guid> Handle(PlaceOrder command, CancellationToken cancellationToken)
{
var order = Order.Create(command.CustomerId, clock.GetUtcNow());
foreach (var line in command.Lines)
order.AddLine(line.Sku, line.Quantity,
await prices.GetPriceAsync(line.Sku, cancellationToken));
order.Submit();
db.Orders.Add(order);
await db.SaveChangesAsync(cancellationToken);
return order.Id;
}
}
public sealed class TimingBehavior<TMessage, TResponse>(
ILogger<TimingBehavior<TMessage, TResponse>> logger)
: IPipelineBehavior<TMessage, TResponse> where TMessage : notnull, IMessage
{
public async ValueTask<TResponse> Handle(TMessage message,
MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken cancellationToken)
{
var started = Stopwatch.GetTimestamp();
var response = await next(message, cancellationToken);
logger.LogInformation("{Message} took {ElapsedMs:0.0} ms",
typeof(TMessage).Name, Stopwatch.GetElapsedTime(started).TotalMilliseconds);
return response;
}
}Because both libraries use names such as IMediator, IRequest and IPipelineBehavior in different namespaces, migrating from MediatR is largely a matter of changing using directives, Task to ValueTask, and the next delegate signature. The source generators guide explains how this compile-time approach works.
Wolverine#
Wolverine, from the JasperFx team behind Marten, is both an in-process mediator and a full message bus with durable inbox and outbox support. It is MIT-licensed and published on NuGet as WolverineFx. Handlers are discovered by convention, need no interfaces, and receive services as method parameters. Middleware such as the [Transactional] attribute integrates with EF Core. The same handler can later process messages from RabbitMQ or Azure Service Bus without changes.
builder.Host.UseWolverine(opts =>
{
// Use Wolverine purely as an in-process mediator for now.
opts.Durability.Mode = DurabilityMode.MediatorOnly;
});
var app = builder.Build();
app.MapPost("/orders", (PlaceOrder command, IMessageBus bus) => bus.InvokeAsync<Guid>(command));
// Discovered by convention: a public class whose name ends in "Handler".
public static class PlaceOrderHandler
{
public static async Task<Guid> Handle(PlaceOrder command, ShopDbContext db,
IPriceList prices, TimeProvider clock, CancellationToken ct)
{
var order = Order.Create(command.CustomerId, clock.GetUtcNow());
foreach (var line in command.Lines)
order.AddLine(line.Sku, line.Quantity, await prices.GetPriceAsync(line.Sku, ct));
order.Submit();
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order.Id;
}
}Hand-rolled dispatch#
You do not need a library to separate commands from queries. Two small interfaces, a decorator and a registration helper give you handlers, a pipeline and full control, with explicit dependencies that are easy to navigate.
public interface ICommandHandler<in TCommand, TResult>
{
Task<TResult> HandleAsync(TCommand command, CancellationToken ct);
}
public interface IQueryHandler<in TQuery, TResult>
{
Task<TResult> HandleAsync(TQuery query, CancellationToken ct);
}
public sealed class LoggingDecorator<TCommand, TResult>(
ICommandHandler<TCommand, TResult> inner,
ILogger<LoggingDecorator<TCommand, TResult>> logger) : ICommandHandler<TCommand, TResult>
{
public async Task<TResult> HandleAsync(TCommand command, CancellationToken ct)
{
using var scope = logger.BeginScope("Command {Command}", typeof(TCommand).Name);
var result = await inner.HandleAsync(command, ct);
logger.LogInformation("Command succeeded");
return result;
}
}
public static class HandlerRegistration
{
public static IServiceCollection AddCommandHandler<TCommand, TResult, THandler>(
this IServiceCollection services)
where THandler : class, ICommandHandler<TCommand, TResult>
{
services.AddScoped<THandler>();
services.AddScoped<ICommandHandler<TCommand, TResult>>(sp =>
ActivatorUtilities.CreateInstance<LoggingDecorator<TCommand, TResult>>(
sp, sp.GetRequiredService<THandler>()));
return services;
}
}
// Program.cs
builder.Services.AddCommandHandler<PlaceOrder, Guid, PlaceOrderHandler>();
app.MapPost("/orders", (PlaceOrder command, ICommandHandler<PlaceOrder, Guid> handler,
CancellationToken ct) => handler.HandleAsync(command, ct));Comparing the options#
| Criterion | MediatR | Mediator (source-generated) | Wolverine | Hand-rolled |
|---|---|---|---|---|
| License | RPL-1.5 or commercial from v13 | MIT | MIT | Your code |
| Dispatch | Runtime, through DI | Generated at compile time | Generated handler adapters | Direct DI injection |
| Handler return type | Task<T> | ValueTask<T> | Any, by convention | Your choice |
| Cross-cutting concerns | Pipeline behaviors | Pipeline behaviors | Middleware and policies | Decorators or endpoint filters |
| Beyond in-process | No | No | Durable messaging, outbox, transports | No |
| Native AOT | Not advertised | Documented support | Check current docs | Yes, if you avoid reflection |
| Best fit | Existing codebases that qualify or pay | Performance, AOT, low-friction migration | Teams that also need messaging | Small apps and explicit code lovers |
Separate Read Models#
Separating reads does not have to mean separate databases. Think of it as a ladder and climb only as far as you need:
- Separate handlers, same model. Queries use
AsNoTrackingprojections over the same tables. This costs almost nothing and is where most applications should stop. - Dedicated read shapes in the same database. Views, indexed views or denormalized tables, often queried with Dapper. Microsoft's eShopOnContainers ordering service used this style: Dapper queries for reads and the EF Core domain model for writes, against one database.
- A separate read store. Projections update a search index, document database or cache asynchronously. Reads scale independently, but they are eventually consistent, and the UI must cope with stale data.
Projections that consume events must be idempotent, because message brokers deliver at least once. The simplest technique is to assign absolute values rather than incrementing counters.
public sealed record OrderSubmitted(
Guid OrderId, Guid CustomerId, decimal Total, DateTimeOffset SubmittedAt) : INotification;
public sealed class OrderSummaryProjection(ReadModelDbContext readDb)
: INotificationHandler<OrderSubmitted>
{
public async Task Handle(OrderSubmitted e, CancellationToken cancellationToken)
{
var view = await readDb.OrderSummaries.FindAsync([e.OrderId], cancellationToken);
if (view is null)
{
view = new OrderSummaryView { OrderId = e.OrderId };
readDb.OrderSummaries.Add(view);
}
// Absolute values, not increments: a redelivered event changes nothing.
view.CustomerId = e.CustomerId;
view.Status = "Submitted";
view.Total = e.Total;
view.SubmittedAt = e.SubmittedAt;
await readDb.SaveChangesAsync(cancellationToken);
}
}When the projection runs in another process, publish the event through a transactional outbox and a broker; the messaging guide compares the options. When the event stream itself is the write model, you have arrived at event sourcing.
Testing CQRS Handlers#
Handlers are plain classes, so unit tests construct them directly without a mediator. Test invariants in domain tests, handler orchestration against fakes or a real database, and the pipeline itself through a few HTTP-level integration tests.
public class PlaceOrderHandlerTests
{
[Fact]
public async Task Places_a_submitted_order_at_catalog_prices()
{
await using var db = TestDatabase.CreateContext(); // for example, backed by Testcontainers
var prices = new FakePriceList(("SKU-1", 12.50m));
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 9, 24, 10, 0, 0, TimeSpan.Zero));
var handler = new PlaceOrderHandler(db, prices, clock);
var id = await handler.Handle(
new PlaceOrder(Guid.NewGuid(), [new OrderLineInput("SKU-1", 2)]),
CancellationToken.None);
await db.SaveChangesAsync(); // normally done by TransactionBehavior
var order = await db.Orders.SingleAsync(o => o.Id == id);
Assert.Equal(OrderStatus.Submitted, order.Status);
}
}The explicit SaveChangesAsync call is a useful reminder: direct handler tests bypass every behavior. Cover validation, transactions and logging once through WebApplicationFactory tests rather than repeating them per handler. FakeTimeProvider comes from the Microsoft.Extensions.TimeProvider.Testing package.
CQRS and Mediators Across .NET Versions#
The patterns are version-independent, but library support is not. MediatR 14 and Mediator 3 target .NET 8 and .NET Standard 2.0, so they run on .NET 8, 9 and 10. Current Wolverine releases target .NET 9 and .NET 10 only, so a .NET 8 application cannot take the latest versions. That is one more reason to move to .NET 10 LTS, because .NET 8 and .NET 9 both reach end of support on November 10, 2026.
ASP.NET Core 10's built-in Minimal API validation handles request-shape rules at the edge, which leaves validation behaviors for rules that need the command's context. If you publish with Native AOT, prefer compile-time dispatch: reflection-based assembly scanning works against trimming. .NET 11, a standard-term release at the release candidate stage, does not change these patterns; confirm library support before upgrading.
When CQRS Is Overkill#
CQRS is overkill for simple CRUD over a handful of tables, for small teams maintaining a single UI, and for domains where reads and writes have the same shape. The Azure Architecture Center's guidance lists simple domains and plain CRUD as cases where the pattern does not fit. A mediator on top of that adds a hop between every endpoint and its logic, which makes "go to definition" land on an interface instead of code.
| Level | What you build | Consistency | Relative cost | Use when |
|---|---|---|---|---|
| CQS in code | Methods that either change or return | Immediate | None | Always |
| Separate handlers | Command and query handlers, one database | Immediate | Low | Most business applications |
| Dedicated read shapes | Views, denormalized tables, Dapper queries | Immediate or near | Medium | Read shapes diverge from the domain |
| Separate read store | Async projections into another store | Eventual | High | Heavy read scale, search, different technology |
| CQRS with event sourcing | Events as the write model | Eventual | Highest | Audit-heavy or temporal domains |
Best Practices#
- Start at the lowest rung. Separate handlers over one database first; add read stores only when metrics demand them.
- Keep commands intention-revealing.
ApproveInvoicebeatsUpdateInvoicebecause it names the rule being exercised. - Return little from commands. An ID or a result status is enough; follow up with a query for display data.
- Let queries skip the domain model. Project directly into DTOs with
AsNoTracking, compiled queries or Dapper. - Keep the pipeline small and ordered deliberately. Logging outermost, then validation, then transactions.
- Make projections idempotent. Assume every event can arrive twice.
- Record your mediator choice in an architecture decision record. Licensing, AOT and messaging needs change over a system's lifetime.
Common Pitfalls#
The mediator sandwich. Endpoint to mediator to handler to service to repository to DbContext: five hops to save a row. If the handler only forwards to another class, delete a layer.
Handlers calling handlers. Sending a command from inside another handler hides control flow and nests transactions. Share logic through the domain model or a domain service instead.
Notifications as a hidden workflow engine. In-process notifications run in the same request and fail together; they are not a substitute for durable messaging.
Separate stores without a consistency plan. Once reads are eventually consistent, the UI needs a strategy, such as returning the new state from the command or showing a pending status.
Ignoring the license. Upgrading MediatR past 12.5.0 without a commercial or Community license means relying on RPL-1.5, whose reciprocal obligations your legal team should review first.
Frequently Asked Questions#
What is the difference between CQS and CQRS?#
CQS is a method-level rule: a method either changes state or returns data, never both. CQRS applies the idea to whole models, with a write model for commands and a separate read model for queries, possibly in different data stores.
Is MediatR still free?#
Versions up to 12.5.0 remain free under Apache 2.0. Since version 13 in July 2025, MediatR is dual-licensed under RPL-1.5 or a commercial license, with a free Community edition for organizations under $5 million in annual revenue, non-profits, education and non-production use.
Do I need a mediator library to implement CQRS?#
No. CQRS is about separating write and read models, not about dispatch. Handlers injected directly into endpoints, plus decorators or endpoint filters for cross-cutting concerns, implement CQRS perfectly well.
Which MediatR alternative should I choose?#
Choose the source-generated Mediator library for the smallest migration effort, compile-time dispatch and Native AOT. Choose Wolverine if you also need durable messaging, an outbox or broker integration. Choose hand-rolled handlers when the application is small and explicitness matters more than uniform pipelines.
Does CQRS require event sourcing or a second database?#
No. Most successful CQRS implementations use one database with separate command and query code paths. Separate stores and event sourcing are optional steps for specific scalability or audit requirements.
Summary#
- CQS is a method-level rule; CQRS separates write and read models and should be applied per bounded context.
- Commands go through the domain model and return little; queries project directly into DTOs.
- MediatR 13 and later require a commercial license or RPL-1.5 compliance; 12.5.0 is the last Apache 2.0 release.
- The source-generated Mediator library, Wolverine and hand-rolled handlers are credible alternatives with different trade-offs.
- Pipeline behaviors handle logging, validation and transactions; keep them few.
- Climb the read-model ladder one rung at a time, and make projections idempotent.