CQRS (Command Query Responsibility Segregation) is one of the most impactful patterns you can adopt for building clean, scalable .NET APIs. In this article, I will walk you through implementing CQRS with the MediatR library in ASP.NET Core (.NET 10), building a complete CRUD API with commands, queries, and real-time notifications.
In this guide, I will build an ASP.NET Core 10 Web API with full CRUD operations using the CQRS Pattern and MediatR 14.1. Of the several design patterns available, CQRS is one of the most commonly used patterns that helps architect solutions following clean architecture principles. I have used this pattern across multiple production projects, and it consistently delivers cleaner code separation and easier testing. Let’s get into it.
What is CQRS?
CQRS (Command Query Responsibility Segregation) is a software architectural pattern that separates the read and write operations of a system into two distinct parts. In a CQRS architecture, the write operations (commands) and read operations (queries) are handled separately, using different models optimized for each operation. The recommended approach for implementing CQRS in .NET 10 is to pair it with the MediatR library, which provides an in-process mediator for request/response and notification patterns.
This pattern originated from the Command and Query Separation (CQS) Principle devised by Bertrand Meyer. It is defined on Wikipedia as follows.
It states that every method should either be a command that acts or a query that returns data to the caller, but not both. In other words, asking a question should not change the answer. More formally, methods should return a value only if they are referentially transparent and hence possess no side effects.
— Wikipedia
Traditional architectural patterns often use the same data model or DTO (Data Transfer Object) for both querying and writing to a data source. While this approach works well for basic CRUD (Create, Read, Update, Delete) operations, it can become limiting when faced with more complex requirements. As applications evolve and requirements grow in complexity, this simplistic approach may no longer be sufficient to handle the intricacies of the system.
In practical applications, there often exists a disparity between the data structures used for reading and writing data. For instance, additional properties may be required for updating data that are not needed for reading. This disparity can lead to challenges such as data loss during parallel operations. Consequently, developers may find themselves constrained to using a single DTO throughout the application’s lifespan, unless they introduce another DTO, which could potentially disrupt the existing application architecture.
The idea with CQRS is to enable an application to operate with distinct models for different purposes. In essence, you have one model for updating records, another for inserting records, and yet another for querying records. This approach provides flexibility in handling diverse and complex scenarios. With CQRS, you’re not limited to using a single DTO for all CRUD operations, allowing for more tailored and efficient data processing.
Here is a diagrammatic representation of the CQRS Pattern.

For instance, in a CRUD Application, the API operations split into two sets:
- Commands - Write, Update, Delete
- Queries - Get, List
Let’s say a CREATE operation comes in. It would trigger the handler that is meant to handle the create command. This handler would contain the persistence logic to write to the data source and would return the created Entity ID. The incoming command would be transformed into the required domain model, and written to the database.
In the case of querying, the query handler will come into action. The entity model returned from the database will be projected to a DTO (or a different model as designed), and be returned to the client. In case certain properties should not be exposed to the consumer, this is a very efficient approach.
You can also write to a different database and read from a different database if required using this pattern. According to Microsoft’s official CQRS documentation, this is particularly useful in event-sourced systems. But in this article, I will keep it simple and just rely on an In-Memory database for the demo.
This way, you are logically separating the workflows for writing and reading from a data source.
What Are the Benefits of CQRS?
There are quite a lot of advantages to using the CQRS Pattern for your application. A few of them are as follows.
Streamlined Data Transfer Objects
The CQRS pattern simplifies your application’s data model by using separate models for each type of operation, which enhances flexibility and reduces complexity.
Scalability
By segregating read and write operations, CQRS enables easier scalability. You can independently scale the read and write sides of your application to handle varying loads efficiently. In most production APIs, read operations account for 80-90% of total traffic, so being able to scale the read side independently (with read replicas, caching layers, or CDN) without touching the write side is a significant operational advantage.
Performance Enhancement
Since read operations typically outnumber write operations, CQRS allows you to optimize read performance by implementing caching mechanisms like Redis. This pattern inherently supports such optimizations, making it easier to enhance overall performance.
Improved Concurrency and Parallelism
With dedicated models for each operation, CQRS ensures that parallel operations are secure, and data integrity is maintained. This is especially beneficial in scenarios where multiple operations need to be performed concurrently.
Enhanced Security
CQRS’s segregated approach helps in securing data access. By defining clear boundaries between read and write operations, you can implement more granular access control mechanisms, improving overall application security.
What Are the Downsides of CQRS?
Increased Complexity and Code Volume
Implementing the CQRS pattern often results in a significant increase in the amount of code required. This complexity arises from the need to manage separate models and handlers for read and write operations, which can be challenging to maintain and debug.
But, given the advantages of this pattern, the additional code complexity can be justified. By segregating read and write operations, CQRS enables developers to optimize each side independently, leading to a more efficient and maintainable system in the long run.
When to Use CQRS (and When Not To)
This is a judgment call I have made across several projects, and here is my take: CQRS is not for every application. The mistake I see most developers make is reaching for CQRS when a simple service layer would do the job.
Here is a decision matrix that I use to determine whether CQRS is the right fit:
| Criteria | Use CQRS | Skip CQRS |
|---|---|---|
| Read/Write asymmetry | Reads outnumber writes 10:1 or more | Reads and writes are roughly equal |
| Model complexity | Read and write models differ significantly | Same model works for both |
| Team size | Multiple developers working on the same domain | Solo developer or small team |
| Scalability needs | Read and write sides need independent scaling | Single-server deployment |
| Testing requirements | Need isolated unit tests per operation | Integration tests are sufficient |
| Domain complexity | Complex business rules for writes, simple reads | Simple CRUD operations |
My recommendation: If your API has fewer than 10 endpoints and the read/write models are identical, skip CQRS. You are adding ceremony without benefit. But once your application grows beyond simple CRUD, say you need different DTOs for create vs update vs read, or you want to add cross-cutting concerns like validation and caching per handler, CQRS with MediatR becomes the cleanest way to organize that complexity.
For reference, here is how the three common architectural approaches compare:
| Approach | Best For | Complexity | Scalability |
|---|---|---|---|
| Traditional CRUD | Small APIs, simple domains | Low | Limited |
| CQRS + MediatR | Medium-to-large APIs with distinct read/write patterns | Medium | High |
| CQRS + Event Sourcing | Complex domains requiring full audit trails | High | Very High |
In the projects I have worked on, CQRS + MediatR (without Event Sourcing) hits the sweet spot for most .NET Web APIs. It gives you clean separation without the overhead of managing event stores.
Without CQRS vs With CQRS
Before jumping into the implementation, let me show you the difference CQRS makes. Here is a typical endpoint without CQRS - everything crammed into a single endpoint handler:
// Bad - All logic in the endpoint, no separationapp.MapPost("/products", async (CreateProductRequest request, AppDbContext context) =>{ // Validation, mapping, persistence, notification - all mixed together if (string.IsNullOrEmpty(request.Name)) return Results.BadRequest(); var product = new Product { Name = request.Name, Description = request.Description, Price = request.Price }; context.Products.Add(product); await context.SaveChangesAsync(); // Need to add logging? Caching? Validation? This endpoint keeps growing. return Results.Created($"/products/{product.Id}", product);});This works for small APIs, but once you have 20+ endpoints with validation, caching, audit logging, and different read/write models, these handlers become unmanageable. In a project I worked on with 45 endpoints, the service classes averaged 500+ lines each.
Now here is the CQRS approach - the same endpoint with MediatR:
// Better - Endpoint is a thin routing layer, logic lives in focused handlersapp.MapPost("/products", async (CreateProductCommand command, ISender mediatr, CancellationToken ct) =>{ var productId = await mediatr.Send(command, ct); return Results.Created($"/products/{productId}", new { id = productId });});The endpoint is just 3 lines. The command handler, validation, caching, and notifications are all separate, focused classes. Each handler averages 15-25 lines and does exactly one thing. That is the power of CQRS with MediatR.
CQRS Pattern with MediatR in ASP.NET Core 10 Web API
Let’s build an ASP.NET Core 10 Web API to showcase the implementation and better understand the CQRS Pattern. I will push the implemented solution over to GitHub, to the .NET Series Repository. Would appreciate it if you star this repository.
I will have a set of Minimal API endpoints that do full CRUD operations for a Product Entity - Create, Read, Update, and Delete product records from the Database. Here, I use Entity Framework Core (EF Core 10) as the ORM (Object-Relational Mapper) to access data. For this demonstration, I will not plug into an actual database but use the InMemory Database provider.
PS - I will not be using any advanced architectural patterns, but let’s try to keep the code clean. The IDE I use is Visual Studio 2022 Community.
Setting Up the Project
Open up Visual Studio and create a new ASP.NET Core Web API Project targeting .NET 10.
Installing the Required Packages
Install the following packages to your API project via the terminal.
dotnet add package Microsoft.EntityFrameworkCore --version 10.0.0dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 10.0.0dotnet add package MediatR --version 14.1.0Solution Structure
I am not going to create separate assemblies to demonstrate clean architecture, but I will cleanly organize the code within the same assembly. The CRUD operations will be separated via folders. This approach is almost a minimal Vertical Slice Architecture (VSA).
Domain
First up, let’s create the domain model. Add a new folder named Domain, and create a C# class named Product.
namespace CqrsMediatr.Api.Domain;
public class Product{ public Guid Id { get; init; } public string Name { get; private set; } = default!; public string Description { get; private set; } = default!; public decimal Price { get; private set; }
// Parameterless constructor for EF Core private Product() { }
public Product(string name, string description, decimal price) { Id = Guid.NewGuid(); Name = name; Description = description; Price = price; }
public void Update(string name, string description, decimal price) { Name = name; Description = description; Price = price; }}This is a simple entity with a parameterized constructor that takes in the name, description, and price of the product. Notice the property accessors: Id uses init (set once during construction, never again), while Name, Description, and Price use private set so they can only be modified through the Update method. This encapsulation ensures that external code cannot mutate the entity’s state directly - domain models should own their mutation logic. EF Core works seamlessly with private set properties.
EF Core DbContext
Coming to the data part, as mentioned I will be using EF Core 10 and InMemory Database. Since I have already installed the required packages, let’s create the DbContext, so that I can interact with the data source. I will also take care of inserting some seed data into the InMemory database as soon as the application boots up.
Create a new folder named Persistence and add a new class, AppDbContext.
namespace CqrsMediatr.Api.Persistence;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options){ public DbSet<Product> Products { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>().HasKey(p => p.Id); modelBuilder.Entity<Product>().HasData( new Product("iPhone 16 Pro", "Apple's flagship smartphone with A18 Pro chip and titanium design", 1199.99m), new Product("Dell XPS 16", "Dell's high-performance laptop with a 4K OLED InfinityEdge display", 1999.99m), new Product("Sony WH-1000XM5", "Sony's premium wireless noise-canceling headphones with improved ANC", 399.99m) ); }}Here I am using a primary constructor to inject the DbContextOptions directly. In the OnModelCreating method, I specify the Primary Key of the Product table and add seed data using the HasData extension.
Registering the Entity Framework Core Database Context
Let’s register the DbContext to the DI (Dependency Injection) Container. Open up Program.cs and add in the following.
builder.Services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("codewithmukesh"));Since I am using the InMemory database with HasData seed data, I also need to call EnsureCreated after building the app. This ensures the seed data gets loaded when the application starts.
var app = builder.Build();
// Seed the InMemory databaseusing (var scope = app.Services.CreateScope()){ var context = scope.ServiceProvider.GetRequiredService<AppDbContext>(); context.Database.EnsureCreated();}What is the Mediator Pattern?
In ASP.NET Core applications, Minimal API endpoints should ideally focus on handling incoming requests, routing them to the appropriate services or business logic components, and returning the responses. Keeping endpoints slim and focused helps in maintaining a clean and understandable codebase.
It’s a good practice to offload complex business logic, data validation, and other heavy lifting to separate handler classes. This separation of concerns improves the maintainability, testability, and scalability of your application.
By following this approach, you can also adhere to the Single Responsibility Principle (SRP) and keep your endpoints clean, focused, and easier to maintain.
The Mediator pattern plays a crucial role in reducing coupling between components in an application by facilitating indirect communication through a mediator object. This pattern promotes a more organized and manageable codebase by centralizing communication logic. According to Microsoft’s Microservices Architecture documentation, the Mediator pattern is a recommended approach for implementing command handlers in .NET applications.
In the context of CQRS, the Mediator pattern is particularly beneficial. CQRS separates the read and write operations of an application, and the Mediator pattern can help in coordinating these operations by acting as a bridge between the command (write) and query (read) sides.
What is MediatR?
MediatR is a popular library created by Jimmy Bogard that helps implement the Mediator Pattern in .NET. It’s an in-process messaging system that supports requests/responses, commands, queries, notifications, and events. As of version 14.1.0 (the latest at the time of writing), MediatR works seamlessly with .NET 10. With over 350 million NuGet downloads, it is the most widely adopted mediator library in the .NET ecosystem.
Important: MediatR is now a commercially licensed library. Starting from version 13.0, MediatR moved from the Apache license to a dual commercial/open-source model under Lucky Penny Software. It remains free for open-source projects, individuals, non-profits, and companies under $5M gross annual revenue - but you will need to register for a free license key at mediatr.io. For larger commercial use, a paid license is required. Check the MediatR GitHub repository for the latest licensing details. A missing license key does not break your application - it only emits log warnings at startup.
That said, CQRS as a pattern does not require MediatR. You can implement it with:
- Mediator - A source-generated, high-performance alternative that generates the mediator implementation at compile time, resulting in zero runtime overhead
- A custom implementation - The mediator pattern is straightforward enough to implement yourself with just an interface and a DI-based dispatcher. If you are interested in building your own lightweight mediator from scratch, drop a comment below and I will cover it in a dedicated article.
For this guide, I will use MediatR since it is the most documented and widely used option, and the concepts transfer directly to any alternative.
Registering MediatR
As I have already installed the required package, let’s register MediatR handlers to the application’s DI Container. Open up Program.cs file.
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));This will register all the MediatR handlers that are available in the current assembly. When you expand your projects to have multiple assemblies, you will have to provide the assembly where you place your handlers. In Clean Architecture solutions, these handlers would ideally be located at the Application layer.
Implementing the CRUD Operations
CRUD essentially stands for Create, Read, Update, and Delete. These are the core components of RESTful APIs. Let’s see how I can implement them using the CQRS approach. Create a folder named Features/Products in the root directory of the Project and subfolders for Queries, DTOs, and Commands.

Each of these folders will house the required classes and services.
Feature Folder: Vertical Slice Architecture
This is a minimal demonstration of VSA (Vertical Slice Architecture), where I organize features by folders. Everything related to Product Creation belongs to the Features/Products/Commands/Create folder, and so on. This approach makes it easier to locate and maintain code related to specific features, as all related functionality is grouped. This can lead to improved code organization, readability, and maintainability, especially in larger projects.
DTO
The Query APIs - Get and List - would return records related to the following DTO. Under Features/Products/DTOs, create a new class named ProductDto.
namespace CqrsMediatr.Api.Features.Products.DTOs;
public record ProductDto(Guid Id, string Name, string Description, decimal Price);Quick Tip: It’s recommended to use records to define Data Transfer Objects, as they are immutable by default!

Queries
First up, let’s focus on building the queries and query handlers. As mentioned, there will be 2 parts for this: the Get and List endpoint. The Get endpoint would take in a particular GUID and return the intended Product DTO object, whereas the List operation would return a list of Product DTO objects.
List All Products
Under the Features/Products/Queries/List/ folder, create 2 classes named ListProductsQuery and ListProductsQueryHandler.
namespace CqrsMediatr.Api.Features.Products.Queries.List;
public record ListProductsQuery : IRequest<List<ProductDto>>;Every Query or Command object inherits from the IRequest<T> interface of the MediatR library, where T is the object to be returned. In this case, I will return List<ProductDto>.
Next, I need handlers for the Query. This is where the ListProductsQueryHandler comes into the picture. Whenever the LIST endpoint is hit, this handler will be triggered.
namespace CqrsMediatr.Api.Features.Products.Queries.List;
public class ListProductsQueryHandler(AppDbContext context) : IRequestHandler<ListProductsQuery, List<ProductDto>>{ public async Task<List<ProductDto>> Handle( ListProductsQuery request, CancellationToken cancellationToken) { return await context.Products .Select(p => new ProductDto(p.Id, p.Name, p.Description, p.Price)) .ToListAsync(cancellationToken); }}To the primary constructor of this handler, I inject the AppDbContext instance for data access. All handlers implement IRequestHandler<T, R> where T is the incoming request (the Query itself), and R is the response (a list of products).
This interface requires implementing the Handle method. I use the DbContext to project the Product domain into a list of DTOs with ID, Name, Description, and Price. Note that I pass the CancellationToken to ToListAsync - this is important for proper request cancellation support in .NET 10.
Once I have created all the handlers, I will wire them up with the Minimal API endpoints.
Get Product By ID
Under the Features/Products/Queries/Get/ folder, create 2 classes named GetProductQuery and GetProductQueryHandler.
namespace CqrsMediatr.Api.Features.Products.Queries.Get;
public record GetProductQuery(Guid Id) : IRequest<ProductDto?>;This record query has a GUID parameter which will be passed on by the client. This ID will be used to query for products in the database.
namespace CqrsMediatr.Api.Features.Products.Queries.Get;
public class GetProductQueryHandler(AppDbContext context) : IRequestHandler<GetProductQuery, ProductDto?>{ public async Task<ProductDto?> Handle( GetProductQuery request, CancellationToken cancellationToken) { var product = await context.Products.FindAsync([request.Id], cancellationToken); if (product is null) { return null; } return new ProductDto(product.Id, product.Name, product.Description, product.Price); }}In the Handle method, I use the ID to get the product from the database. If the result is empty, null is returned, indicating a not found result. Otherwise, I project the product data to a ProductDto object and return it.
Commands
Now that the queries and query handlers are in place, let’s build the commands.
Create New Product
Under the Features/Products/Commands/Create/ folder, create the following 2 files.
namespace CqrsMediatr.Api.Features.Products.Commands.Create;
public record CreateProductCommand(string Name, string Description, decimal Price) : IRequest<Guid>;The command takes in Name, Description, and Price. This Command object is expected to return the newly created product’s ID.
namespace CqrsMediatr.Api.Features.Products.Commands.Create;
public class CreateProductCommandHandler(AppDbContext context) : IRequestHandler<CreateProductCommand, Guid>{ public async Task<Guid> Handle( CreateProductCommand command, CancellationToken cancellationToken) { var product = new Product(command.Name, command.Description, command.Price); await context.Products.AddAsync(product, cancellationToken); await context.SaveChangesAsync(cancellationToken); return product.Id; }}The handler creates a Product Domain Model from the incoming command and persists it to the database. Finally, it returns the newly created product’s ID. Note that the GUID generation is handled as part of the Domain Object’s constructor.
Update Product
Under the Features/Products/Commands/Update/ folder, create the following files.
namespace CqrsMediatr.Api.Features.Products.Commands.Update;
public record UpdateProductCommand(Guid Id, string Name, string Description, decimal Price) : IRequest<bool>;The Update command takes in the product ID along with the updated Name, Description, and Price. It returns a boolean indicating whether the update was successful.
namespace CqrsMediatr.Api.Features.Products.Commands.Update;
public class UpdateProductCommandHandler(AppDbContext context) : IRequestHandler<UpdateProductCommand, bool>{ public async Task<bool> Handle( UpdateProductCommand command, CancellationToken cancellationToken) { var product = await context.Products.FindAsync([command.Id], cancellationToken); if (product is null) return false; product.Update(command.Name, command.Description, command.Price); await context.SaveChangesAsync(cancellationToken); return true; }}The handler fetches the existing product, calls the domain model’s Update method to apply the changes, and persists the update. If the product is not found, it returns false. This approach keeps the mutation logic inside the domain model, which is a cleaner pattern than setting properties directly in the handler.
Delete Product By ID
Next, I will have a feature to delete the product by specifying the ID. Create the following classes under Features/Products/Commands/Delete/.
namespace CqrsMediatr.Api.Features.Products.Commands.Delete;
public record DeleteProductCommand(Guid Id) : IRequest;The Delete Handler takes in the ID, fetches the record from the database, and tries to remove it. If the product with the mentioned ID is not found, it simply exits out of the handler code.
namespace CqrsMediatr.Api.Features.Products.Commands.Delete;
public class DeleteProductCommandHandler(AppDbContext context) : IRequestHandler<DeleteProductCommand>{ public async Task Handle( DeleteProductCommand request, CancellationToken cancellationToken) { var product = await context.Products.FindAsync([request.Id], cancellationToken); if (product is null) return; context.Products.Remove(product); await context.SaveChangesAsync(cancellationToken); }}Minimal API Endpoints
Now that all the required commands, queries, and handlers are in place, let’s wire them up with actual API endpoints. For this demonstration, I will use Minimal API endpoints.
Open up Program.cs and add in the following endpoint mappings.
app.MapGet("/products/{id:guid}", async (Guid id, ISender mediatr, CancellationToken ct) =>{ var product = await mediatr.Send(new GetProductQuery(id), ct); if (product is null) return Results.NotFound(); return Results.Ok(product);});
app.MapGet("/products", async (ISender mediatr, CancellationToken ct) =>{ var products = await mediatr.Send(new ListProductsQuery(), ct); return Results.Ok(products);});
app.MapPost("/products", async (CreateProductCommand command, ISender mediatr, CancellationToken ct) =>{ var productId = await mediatr.Send(command, ct); if (Guid.Empty == productId) return Results.BadRequest(); return Results.Created($"/products/{productId}", new { id = productId });});
app.MapPut("/products/{id:guid}", async (Guid id, UpdateProductCommand command, ISender mediatr, CancellationToken ct) =>{ if (id != command.Id) return Results.BadRequest(); var result = await mediatr.Send(command, ct); return result ? Results.NoContent() : Results.NotFound();});
app.MapDelete("/products/{id:guid}", async (Guid id, ISender mediatr, CancellationToken ct) =>{ await mediatr.Send(new DeleteProductCommand(id), ct); return Results.NoContent();});The important thing to note here is that I am using the ISender interface from MediatR to send the commands/queries to their registered handlers. Alternatively, you can also use the IMediator interface, but ISender is far more lightweight, and you don’t always need the full IMediator interface. I would use IMediator only when the endpoint needs to perform more than simple request-response messaging, like publishing notifications.
Also notice that every endpoint injects a CancellationToken ct parameter and passes it to mediatr.Send(). ASP.NET Core automatically binds this to the request’s cancellation token, so if a client disconnects mid-request, the cancellation propagates all the way through MediatR into your handlers and EF Core queries. This is a best practice you should always follow in .NET 10.
Let’s explore the endpoints one by one.
- GET
/products/{id}- Takes a GUID parameter. I create a Query object with this ID and pass it through the MediatR pipeline. If the result is empty, a 404 Not Found status is returned. Otherwise, the valid product is returned. - GET
/products- Returns all products available in the database. You can build on this API by introducing parameters like page size and number for pagination and searching. - POST
/products- Creates a new product. It accepts theCreateProductCommand, which is passed to the handler via the MediatR pipeline. If the returning product ID is empty, a Bad Request is returned. Otherwise, a 201 Created response is returned. - PUT
/products/{id}- Updates an existing product. It validates that the route ID matches the command ID, sends the command to the handler, and returns NoContent on success or NotFound if the product doesn’t exist. - DELETE
/products/{id}- Sends the ID to theDeleteProductCommandHandlerand returns a NoContent response.
Testing the Endpoints via Scalar
I am done with the implementation. Now let’s test it using Scalar! Build and run your ASP.NET Core 10 application, and navigate to the Scalar API reference page at /scalar/v1.
Scalar is the modern replacement for Swagger UI in .NET 10. It provides a cleaner, faster API documentation interface with built-in request testing. If you are still using Swagger, I recommend switching to Scalar.

Let’s test each endpoint. First up, the LIST endpoint. This can be tested to verify if the initial seed data is as expected.
[ { "id": "c2537bef-235d-4a72-9aaf-f5cf1ff2d080", "name": "iPhone 16 Pro", "description": "Apple's flagship smartphone with A18 Pro chip and titanium design", "price": 1199.99 }, { "id": "93cfebdb-b3fb-415d-9aba-024cad28df5c", "name": "Dell XPS 16", "description": "Dell's high-performance laptop with a 4K OLED InfinityEdge display", "price": 1999.99 }, { "id": "2fde15c1-48cb-4154-b055-0a96048fa392", "name": "Sony WH-1000XM5", "description": "Sony's premium wireless noise-canceling headphones with improved ANC", "price": 399.99 }]Next, let me create a new Product. Here is the product payload.
{ "name": "Tesla Model Y", "description": "Tesla's best-selling electric SUV with full self-driving capability", "price": 45000}And here is the response.
{ "id": "4eb60a75-1dfa-401d-8f65-c4750457d19d"}Let’s use this Product ID to test the Get By ID endpoint.
{ "id": "4eb60a75-1dfa-401d-8f65-c4750457d19d", "name": "Tesla Model Y", "description": "Tesla's best-selling electric SUV with full self-driving capability", "price": 45000}As you can see, I got the expected response. You can also test the UPDATE endpoint by sending a PUT request with the updated product details, and the DELETE endpoint to remove a product.
Now, let’s explore a few more important features of the MediatR library.
MediatR Notifications - Decoupled Event-Driven Systems
Up to now, I have looked into the request-response pattern of MediatR, which involves a single handler per request. But what if your requests need multiple handlers? For instance, every time you create a new product, you need logic to add/set stock, and another handler to perform audit logging. To build a stable and reliable system, you need to make sure that these handlers are decoupled and executed individually.
This is where notifications come in. Whenever you need multiple handlers to react to an event, notifications are your best option! According to the MediatR documentation, notifications enable a publish/subscribe pattern within your application process.
I will tweak the existing code to demonstrate this. I will add this functionality within the CreateProductCommandHandler to publish a notification, and register two other handlers against the new notification.
First up, create a new folder called Notifications, and add the following record.
namespace CqrsMediatr.Api.Features.Products.Notifications;
public record ProductCreatedNotification(Guid Id) : INotification;Note that this record inherits from INotification of the MediatR library.
I will create two handlers that subscribe to this notification. Under the same folder, add these files.
namespace CqrsMediatr.Api.Features.Products.Notifications;
public class StockAssignedHandler(ILogger<StockAssignedHandler> logger) : INotificationHandler<ProductCreatedNotification>{ public Task Handle( ProductCreatedNotification notification, CancellationToken cancellationToken) { logger.LogInformation( "Handling notification for product creation with id: {ProductId}. Assigning stocks.", notification.Id); return Task.CompletedTask; }}namespace CqrsMediatr.Api.Features.Products.Notifications;
public class AuditLogHandler(ILogger<AuditLogHandler> logger) : INotificationHandler<ProductCreatedNotification>{ public Task Handle( ProductCreatedNotification notification, CancellationToken cancellationToken) { logger.LogInformation( "Handling notification for product creation with id: {ProductId}. Writing audit log.", notification.Id); return Task.CompletedTask; }}For demo purposes, I have just added simple log messages denoting that the handlers are triggered. Notice I am using structured logging with {ProductId} placeholder instead of string interpolation - this is a best practice for structured logging in .NET 10.
So, the idea is that, whenever a new product is created, a notification will be sent across, which would trigger the StockAssignedHandler and AuditLogHandler.
To publish the notification, I will modify the POST endpoint code as follows.
app.MapPost("/products", async (CreateProductCommand command, IMediator mediatr, CancellationToken ct) =>{ var productId = await mediatr.Send(command, ct); if (Guid.Empty == productId) return Results.BadRequest(); await mediatr.Publish(new ProductCreatedNotification(productId), ct); return Results.Created($"/products/{productId}", new { id = productId });});At Line #5, I use the IMediator interface (not ISender) to create a new notification of type ProductCreatedNotification and publish it in-process. This will in turn trigger all handlers registered against this notification. Note the switch from ISender to IMediator here - the Publish method for notifications is only available on IMediator.
If you run the API and create a new product, you will be able to see the following log messages on the server.

As you see, both handlers are triggered. This can be super important while building decoupled event-based systems.
Key Takeaways
- CQRS separates reads from writes - commands handle mutations, queries handle data retrieval, each with their own optimized model
- MediatR provides the in-process mediator - it routes requests to handlers, keeping endpoints thin and business logic isolated
- Use
ISenderfor request/response,IMediatorwhen you also need to publish notifications - Notifications enable fan-out - one event, multiple handlers, fully decoupled
- Don’t use CQRS for simple CRUD apps - the pattern adds value only when read/write models diverge or cross-cutting concerns (validation, caching, logging) are needed per handler
What’s Next?
In the next article, I cover Pipeline Behaviors with MediatR and FluentValidation to build automatic input validation for every command that flows through the pipeline. I also have a guide on Response Caching with MediatR that shows how to cache query results using pipeline behaviors.
If you found this helpful, share it with your colleagues - and if there’s a topic you’d like to see covered next, drop a comment and let me know.
Happy Coding :)
Troubleshooting
MediatR handler not found - “No handler registered for request”
Cause: The handler assembly is not registered with MediatR. Ensure builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())) is called in Program.cs. If your handlers are in a different project, pass that project’s assembly instead.
Notification handlers not being triggered
Cause: You are using ISender.Send() instead of IMediator.Publish(). Notifications require the Publish method, which is only available on the IMediator interface, not ISender. Also verify your notification handlers implement INotificationHandler<T>, not IRequestHandler<T>.
DbContext disposed error in handlers
Cause: The AppDbContext is registered as Scoped by default, but you are trying to use it outside the request scope. Ensure your handlers are also registered as Scoped (which MediatR does automatically). If you are publishing notifications after SaveChangesAsync, the DbContext should still be available within the same request scope.
EF Core InMemory database not persisting data between requests
Cause: Each request creates a new InMemory database instance. Ensure you are using the same database name in UseInMemoryDatabase("codewithmukesh") and that AddDbContext is called with the correct configuration. For production applications, switch to a real database provider like SQL Server or PostgreSQL.
CancellationToken not propagating to EF Core queries
Cause: You are not passing the CancellationToken from the Handle method to EF Core methods like ToListAsync, FindAsync, and SaveChangesAsync. Always forward the cancellation token to support proper request cancellation.
What does CQRS stand for?
CQRS stands for Command Query Responsibility Segregation. It is a design pattern that separates the read operations (queries) and write operations (commands) of an application into distinct models, each optimized for its specific purpose.
Does implementing CQRS slow down an application?
No, implementing CQRS does not inherently slow down an application. While it introduces more code and indirection, when implemented correctly it can improve performance by allowing you to optimize read and write paths independently. For example, you can add caching to query handlers without affecting command handlers.
Is CQRS the same as Event Sourcing?
No, CQRS and Event Sourcing are separate patterns that are often used together but are not dependent on each other. CQRS separates reads from writes. Event Sourcing stores state changes as a sequence of events instead of the current state. You can use CQRS without Event Sourcing, which is the approach demonstrated in this article using MediatR.
What is the difference between ISender and IMediator in MediatR?
ISender is a lightweight interface that only supports sending requests (commands and queries) to a single handler. IMediator extends ISender and adds the Publish method for broadcasting notifications to multiple handlers. Use ISender for endpoints that only send commands or queries, and IMediator when you also need to publish notifications.
Can I use CQRS with traditional MVC Controllers instead of Minimal APIs?
Yes, CQRS with MediatR works with both Minimal APIs and traditional MVC Controllers. The pattern is framework-agnostic. Simply inject ISender or IMediator into your controller constructor and call Send or Publish in your action methods. The handlers remain identical regardless of the endpoint style.
When should I NOT use CQRS?
Skip CQRS for simple CRUD applications with fewer than 10 endpoints where read and write models are identical. The pattern adds ceremony and boilerplate that is not justified when your application is straightforward. CQRS shines when you have complex business logic, different read and write models, or need cross-cutting concerns like validation and caching per handler.
How does MediatR handle dependency injection for handlers?
When you call AddMediatR and specify the assembly, MediatR automatically scans the assembly for all classes implementing IRequestHandler and INotificationHandler interfaces and registers them with the DI container. Handlers are registered as Transient by default, meaning a new instance is created for each request.
Is CQRS a scalable design pattern?
Yes, CQRS is designed with scalability in mind. By separating read and write operations, you can independently scale each side. For example, you can add read replicas and caching for the query side while keeping the command side optimized for writes. This makes CQRS suitable for applications that need to scale efficiently under varying loads.



What's your Feedback?
Do let me know your thoughts around this article.