.NET  

CQRS Without MediatR: A Lightweight Approach for Modern .NET Applications

Introduction

Command Query Responsibility Segregation (CQRS) is a popular architectural pattern used in modern .NET applications to separate read operations from write operations. By splitting commands and queries into distinct responsibilities, CQRS can improve maintainability, scalability, and code organization.

Many developers implement CQRS using libraries such as MediatR. While MediatR is an excellent tool, it is not a requirement for implementing CQRS. In fact, for many applications, introducing an additional abstraction layer can increase complexity without providing significant benefits.

A lightweight CQRS implementation allows developers to gain the advantages of the pattern while keeping the codebase simple, transparent, and easy to debug.

In this article, you'll learn how CQRS works, why MediatR isn't always necessary, and how to implement CQRS directly in ASP.NET Core applications using built-in dependency injection.

Understanding CQRS

CQRS separates application operations into two categories:

Commands

Commands modify application state.

Examples include:

  • Creating a user

  • Updating a product

  • Deleting an order

  • Processing a payment

Commands typically do not return data beyond success or failure information.

Queries

Queries retrieve data without modifying application state.

Examples include:

  • Getting product details

  • Listing customers

  • Viewing order history

  • Retrieving dashboard metrics

Queries should never change data.

Traditional Approach

Controller
    ↓
Service
    ↓
Database

CQRS Approach

Controller
    ↓
Command Handler
    ↓
Database

Controller
    ↓
Query Handler
    ↓
Database

This separation creates clearer responsibilities and improves maintainability.

Why Developers Use MediatR

MediatR is commonly used to implement CQRS because it provides a mediator pattern that decouples controllers from handlers.

Typical flow:

Controller
    ↓
Mediator
    ↓
Handler
    ↓
Database

Example:

await _mediator.Send(
    new CreateProductCommand());

While convenient, this introduces an additional dependency and abstraction layer.

For smaller and medium-sized applications, direct handler invocation can often be simpler.

Implementing Lightweight CQRS

Instead of routing requests through a mediator, controllers can directly depend on command and query handlers.

The architecture becomes:

Controller
    ↓
Handler
    ↓
Repository
    ↓
Database

This reduces indirection while preserving CQRS principles.

Creating a Command

Let's create a command for adding a product.

public class CreateProductCommand
{
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

The command contains only the data required for the operation.

Creating a Command Handler

Next, implement a dedicated handler.

public class CreateProductHandler
{
    private readonly AppDbContext _context;

    public CreateProductHandler(
        AppDbContext context)
    {
        _context = context;
    }

    public async Task<int> HandleAsync(
        CreateProductCommand command)
    {
        var product = new Product
        {
            Name = command.Name,
            Price = command.Price
        };

        _context.Products.Add(product);

        await _context.SaveChangesAsync();

        return product.Id;
    }
}

The handler contains all business logic related to product creation.

Registering the Handler

Register the handler using ASP.NET Core's built-in dependency injection.

builder.Services.AddScoped<
    CreateProductHandler>();

No mediator library is required.

Using the Command Handler

The controller directly invokes the handler.

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    private readonly CreateProductHandler _handler;

    public ProductsController(
        CreateProductHandler handler)
    {
        _handler = handler;
    }

    [HttpPost]
    public async Task<IActionResult> Create(
        CreateProductCommand command)
    {
        int id =
            await _handler.HandleAsync(command);

        return Ok(id);
    }
}

The request flow remains simple and easy to follow.

Creating a Query

Queries retrieve data without changing state.

Example:

public class GetProductByIdQuery
{
    public int Id { get; set; }
}

Creating a Query Handler

public class GetProductByIdHandler
{
    private readonly AppDbContext _context;

    public GetProductByIdHandler(
        AppDbContext context)
    {
        _context = context;
    }

    public async Task<ProductDto?> HandleAsync(
        GetProductByIdQuery query)
    {
        return await _context.Products
            .Where(p => p.Id == query.Id)
            .Select(p => new ProductDto
            {
                Id = p.Id,
                Name = p.Name,
                Price = p.Price
            })
            .FirstOrDefaultAsync();
    }
}

Notice that the query returns a DTO instead of an entity.

This improves API design and prevents unnecessary data exposure.

Using Separate Read Models

One of the advantages of CQRS is the ability to optimize read models independently.

Instead of returning entities:

return await _context.Products
    .FirstOrDefaultAsync();

Use specialized DTOs:

public class ProductSummaryDto
{
    public int Id { get; set; }

    public string Name { get; set; } =
        string.Empty;
}

This improves performance and reduces payload size.

Organizing Project Structure

A common folder structure looks like:

Features
│
├── Products
│   ├── Commands
│   │   ├── CreateProductCommand.cs
│   │   └── CreateProductHandler.cs
│   │
│   ├── Queries
│   │   ├── GetProductByIdQuery.cs
│   │   └── GetProductByIdHandler.cs
│   │
│   └── DTOs
│
├── Orders
│
└── Customers

This feature-based organization scales well as applications grow.

Benefits of CQRS Without MediatR

Simpler Debugging

The execution flow is explicit.

Developers can easily trace:

Controller
    ↓
Handler
    ↓
Database

No hidden middleware or mediator pipeline exists between components.

Reduced Complexity

Fewer abstractions mean:

  • Easier onboarding

  • Simpler maintenance

  • Less boilerplate code

Fewer Dependencies

The application relies primarily on:

  • ASP.NET Core

  • Dependency Injection

  • Entity Framework Core

This reduces external package management overhead.

Better Performance

Although the performance difference is usually small, direct handler invocation avoids mediator dispatch overhead.

For high-throughput systems, reducing unnecessary layers can be beneficial.

When MediatR May Still Be Useful

Despite the advantages of a lightweight approach, MediatR remains valuable in some scenarios.

Examples include:

  • Large enterprise systems

  • Extensive pipeline behaviors

  • Cross-cutting concerns

  • Request validation pipelines

  • Centralized logging

  • Notification-based workflows

If your application heavily depends on these patterns, MediatR may still be the right choice.

Common Mistakes

Mixing Commands and Queries

Avoid code like:

public async Task<Product> UpdateAndGetProduct()

Commands should modify data.

Queries should retrieve data.

Keep these responsibilities separate.

Business Logic in Controllers

Controllers should remain thin.

Avoid:

[HttpPost]
public async Task<IActionResult> Create()
{
    // Business logic here
}

Business rules belong in handlers.

Returning Entities Directly

Prefer DTOs over EF Core entities to improve API stability and security.

Best Practices

When implementing lightweight CQRS:

  • Keep commands focused on a single action.

  • Keep queries read-only.

  • Use dedicated handlers for each operation.

  • Return DTOs instead of entities.

  • Organize code by feature rather than technical layer.

  • Keep controllers thin.

  • Use dependency injection for handler registration.

  • Apply validation before executing handlers.

  • Use asynchronous database operations.

  • Avoid introducing abstractions that do not provide clear value.

Conclusion

CQRS is a powerful architectural pattern that helps separate read and write responsibilities, leading to cleaner and more maintainable applications. While MediatR is commonly associated with CQRS in the .NET ecosystem, it is not a requirement.

By implementing command and query handlers directly with ASP.NET Core's built-in dependency injection, developers can achieve the benefits of CQRS while keeping their architecture simple and transparent. This lightweight approach reduces dependencies, improves debugging, and minimizes unnecessary complexity.

For many modern .NET applications, especially small and medium-sized systems, CQRS without MediatR offers a practical balance between architectural discipline and development simplicity.