8 Steps to Implement CQRS in ASP.NET Core Without MediatR: A Complete Clean Architecture Guide

CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates application operations into two different models:

  • Commands → Operations that modify data (Create, Update, Delete)
  • Queries → Operations that only read data

Many ASP.NET Core developers implement CQRS using libraries like MediatR, but CQRS itself does not require MediatR. MediatR is only a messaging library that helps route commands and queries.

In this article, we will implement CQRS in ASP.NET Core Web API without MediatR using:

  • Clean Architecture principles
  • Commands
  • Queries
  • Handlers
  • Dependency Injection
  • Entity Framework Core

What is CQRS?

Traditional CRUD architecture usually looks like this:

Controller
    |
    |
Service
    |
    |
Repository
    |
    |
Database

The same service handles:

  • Creating data
  • Updating data
  • Reading data
  • Searching
  • Reporting

As applications grow, services become complex.

Example:

ProductService.cs

CreateProduct()
UpdateProduct()
DeleteProduct()
GetProduct()
SearchProducts()
GetReports()

CQRS separates these responsibilities.


CQRS Architecture

A CQRS-based application looks like:

                 API Layer

                    |
        -------------------------
        |                       |
     Commands                Queries
        |                       |
 Command Handler          Query Handler
        |                       |
 Write Database          Read Database

Project Structure

A simple CQRS structure:

RetailERP.Api

├── Controllers

RetailERP.Application

├── Products
│
├── Commands
│   └── CreateProduct
│       ├── CreateProductCommand.cs
│       └── CreateProductHandler.cs
│
└── Queries
    └── GetProduct
        ├── GetProductQuery.cs
        └── GetProductHandler.cs


RetailERP.Domain

├── Product.cs


RetailERP.Infrastructure

├── ApplicationDbContext.cs

Step 1: Create Domain Entity

Let’s start creating code and see how can we use CORS in ASP.NET Core. Let’s create a Product entity.

public class Product
{
    public Guid Id { get; set; }

    public string Name { get; set; }

    public decimal Price { get; set; }
}

Step 2: Create Database Context

public class ApplicationDbContext 
    : DbContext
{
    public ApplicationDbContext(
        DbContextOptions options)
        : base(options)
    {

    }


    public DbSet<Product> Products { get; set; }
}

Implementing Commands

A command represents an action that changes application state.

Example:z

Create Product
Update Product
Delete Product

Step 3: Create CreateProductCommand

public class CreateProductCommand
{
    public string Name { get; set; }

    public decimal Price { get; set; }
}

This class only contains input data.

It does not contain business logic.


Step 4: Create Command Handler

The handler contains the command execution logic.

public class CreateProductHandler
{
    private readonly ApplicationDbContext _context;


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


    public async Task<Guid> Handle(
        CreateProductCommand command)
    {

        var product = new Product
        {
            Id = Guid.NewGuid(),
            Name = command.Name,
            Price = command.Price
        };


        _context.Products.Add(product);


        await _context.SaveChangesAsync();


        return product.Id;
    }
}

Implementing Queries

Queries only retrieve data.

Examples:

Get Product By Id
Get All Products
Search Products

Step 5: Create Query

public class GetProductQuery
{
    public Guid Id { get; set; }
}

Step 6: Create Query Handler

public class GetProductHandler
{

    private readonly ApplicationDbContext _context;


    public GetProductHandler(
        ApplicationDbContext context)
    {
        _context = context;
    }



    public async Task<Product?> Handle(
        GetProductQuery query)
    {

        return await _context.Products
            .FirstOrDefaultAsync(
                x => x.Id == query.Id);
    }
}

Step 7: Register Handlers With Dependency Injection

Since we are not using MediatR, we register handlers manually.

In Program.cs:

builder.Services.AddScoped<
    CreateProductHandler>();


builder.Services.AddScoped<
    GetProductHandler>();

Step 8: Create API Controller

Now the controller communicates with handlers.

[ApiController]
[Route("api/products")]
public class ProductsController 
    : ControllerBase
{

    private readonly CreateProductHandler 
        _createHandler;


    private readonly GetProductHandler
        _getHandler;



    public ProductsController(
        CreateProductHandler createHandler,
        GetProductHandler getHandler)
    {
        _createHandler = createHandler;
        _getHandler = getHandler;
    }



    [HttpPost]
    public async Task<IActionResult> Create(
        CreateProductCommand command)
    {

        var id = await _createHandler
            .Handle(command);


        return Ok(id);
    }



    [HttpGet("{id}")]
    public async Task<IActionResult> Get(
        Guid id)
    {

        var product =
            await _getHandler.Handle(
                new GetProductQuery
                {
                    Id=id
                });


        return Ok(product);
    }
}

The Complete Flow

Creating Product

Request:

POST /api/products

Body:

{
 "name":"Laptop",
 "price":1500
}

Flow:

Controller

     |
     
CreateProductCommand

     |

CreateProductHandler

     |

Database

Getting Product

Request:

GET /api/products/{id}

Flow:

Controller

     |

GetProductQuery

     |

GetProductHandler

     |

Database

Adding Generic Dispatcher (MediatR Alternative)

In larger projects, manually injecting every handler becomes repetitive.

We can create our own dispatcher.


ICommand Interface

public interface ICommand<TResponse>
{

}

ICommandHandler Interface

public interface ICommandHandler<TCommand,TResponse>
{
    Task<TResponse> Handle(
        TCommand command);
}

Example Command

public class CreateProductCommand 
    : ICommand<Guid>
{
    public string Name {get;set;}

    public decimal Price {get;set;}
}

Handler

public class CreateProductHandler :
ICommandHandler<CreateProductCommand,Guid>
{

public async Task<Guid> Handle(
CreateProductCommand command)
{

// business logic

}

}

Dispatcher

public interface ICommandDispatcher
{
    Task<T> Send<T>(
        object command);
}

Implementation:

public class CommandDispatcher 
    : ICommandDispatcher
{

private readonly IServiceProvider _provider;


public CommandDispatcher(
IServiceProvider provider)
{
    _provider=provider;
}



public async Task<T> Send<T>(
object command)
{

var handlerType =
typeof(ICommandHandler<,>)
.MakeGenericType(
command.GetType(),
typeof(T));


dynamic handler =
_provider.GetRequiredService(
handlerType);


return await handler.Handle(
(dynamic)command);

}

}

Register:

builder.Services
.AddScoped<ICommandDispatcher,
CommandDispatcher>();

Now controllers become cleaner:

[HttpPost]
public async Task<IActionResult> Create(
CreateProductCommand command)
{

var id =
await _dispatcher.Send<Guid>(
command);


return Ok(id);

}

CQRS Without MediatR vs With MediatR

FeatureWithout MediatRWith MediatR
DependencyNoneNuGet Package
ControlFullLess
CodeMoreLess
LearningBetter understandingEasier
PerformanceSlightly betterGood
Pipeline BehaviorsManualBuilt-in

When Should You Avoid MediatR?

Avoid MediatR when:

  • Application is small
  • You want fewer dependencies
  • You prefer explicit code
  • You want full control

Example:

Small ERP
Internal API
Startup MVP

When Should You Use MediatR?

Use MediatR when:

  • Many commands and queries exist
  • You need pipeline behaviors
  • You need logging
  • You need validation pipelines

Example:

Large SaaS Platform
Enterprise ERP
Banking System

CQRS With DDD

For a DDD-based application:

API

 |

Application Layer

 |
 
Commands
Queries
Handlers

 |

Domain

Entities
Aggregates
Domain Events

 |

Infrastructure

EF Core
Messaging
Database

CQRS fits naturally because:

  • Commands protect domain rules
  • Queries optimize reads
  • Handlers isolate use cases

Conclusion

CQRS does not require MediatR. MediatR only provides a convenient way to implement the mediator pattern.

A clean CQRS implementation can be built using:

  • Commands
  • Queries
  • Handlers
  • Dependency Injection
  • Interfaces

For modern ASP.NET Core applications, especially ERP systems, SaaS platforms, and DDD-based applications, implementing CQRS manually first helps developers understand the architecture before introducing libraries like MediatR.

Leave a Comment

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

Scroll to Top