When building enterprise applications, we often start with a simple CRUD approach. It works fine until it faces some issues and system grows more we may encounter several challenges.

As systems grow, business rules become complex, read traffic increases, and scalability becomes critical the traditional “one model for everything” approach starts to show cracks.

That’s where CQRS (Command Query Responsibility Segregation) comes in and resolves these issues.

In this article, I’ll walk you through:

  • What CQRS really means in simpler way
  • Why it matters in real-world systems
  • How to implement it using MediatR in .NET 10
  • Clean project structure
  • Practical tips from production experience

What is CQRS (Command Query Responsibility Segregation)?

CQRS is a design pattern that separates read operations (queries) from write operations (commands) in an application.

Instead of using the same model for both reading and writing data, CQRS splits them into two different models:

  • Command Model → Handles writes (Create, Update, Delete)
  • Query Model → Handles reads (Fetch, Search, Get)

This improves scalability, maintainability, and clarity in complex systems.

CQRS separates read operations from write operations. We never mix command and queries.

Basic Idea

Traditional CRUD approach:

Controller → Service → Repository → Database

CQRS approach:

Command → Command Handler → Domain → Database
Query   → Query Handler   → Read Model → Database

Visual Understanding

Why Use CQRS?

Let’s think practically. In most applications:

  • Reads happen far more than writes.
  • Reads need performance.
  • Writes need validation and business rules.

Trying to optimize both using the same model leads to complexity.

CQRS allows:

  • Separate scaling of reads & writes
  • Optimized read models
  • Cleaner business logic
  • Better testability
  • Clear architectural boundaries

If you’re already using Clean Architecture CQRS fits naturally.

1. Clear Separation of Concerns

Reads and writes have different responsibilities.

2. Scalability

  • Read traffic can scale independently
  • Write side can have complex business logic
  • Read side can use caching or projections

3. Performance Optimization

  • Read models can be optimized for UI
  • Write models focus on business rules

4. Works Great with:

  • Clean Architecture (which you’re already using)
  • MediatR
  • Event Sourcing
  • Microservices

Where MediatR Fits In

MediatR is a lightweight mediator library that helps implement CQRS cleanly.

Instead of controllers directly calling services:

Controller → Service → Repository

We do:

Controller → MediatR → Handler

Each request (command/query) has exactly one handler.

This removes tight coupling and improves maintainability.

Project Structure (.NET 10 Web API)

A clean CQRS structure typically looks like:

Application
 ├── Commands
 ├── Queries
 ├── Handlers
Domain
Infrastructure
API (Controllers)

This aligns perfectly with Clean Architecture principles.

Step-by-Step: CQRS with MediatR in .NET 10

Create a new WebAPI project in .NET 10 or you can you your existing project and implement CQRS with MediatR in .NET 10

Then you will follow the below.

Install Required Packages

dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

Register MediatR in Program.cs

builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

That’s it. Clean and simple.

Implementing Command Side (Write Model)

Example: Create Booking

Add a new record CreateBookingCommand

public record CreateBookingCommand(
    string GuestName,
    DateTime CheckIn
) : IRequest<Guid>;

Note:

  • It represents an intention.
  • It returns only the ID (not full object).
  • It changes system state.

Now, let’s create a new CreateBookingCommandHandler class

public class CreateBookingCommandHandler 
    : IRequestHandler<CreateBookingCommand, Guid>
{
    private readonly AppDbContext _context;

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

    public async Task<Guid> Handle(
        CreateBookingCommand request, 
        CancellationToken cancellationToken)
    {
        var booking = new Booking
        {
            GuestName = request.GuestName,
            CheckIn = request.CheckIn
        };

        _context.Bookings.Add(booking);
        await _context.SaveChangesAsync(cancellationToken);

        return booking.Id;
    }
}
  • Key principle:
  • Commands contain business logic.
  • They modify state.
  • They should not contain read logic.

Implementing Query Side (Read Model)

Now we separate the read logic.

Let’s create a record class for GetBooking

public record GetBookingByIdQuery(Guid Id) : IRequest<BookingDto>;<br>

Note:

  • It does not change state.
  • It returns data.
  • It can be optimized for UI.

Now, let’s create GetBookingByIdQueryHandler class

public class GetBookingByIdQueryHandler
    : IRequestHandler<GetBookingByIdQuery, BookingDto>
{
    private readonly AppDbContext _context;

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

    public async Task<BookingDto> Handle(
        GetBookingByIdQuery request,
        CancellationToken cancellationToken)
    {
        return await _context.Bookings
            .AsNoTracking()
            .Where(x => x.Id == request.Id)
            .Select(x => new BookingDto
            {
                Id = x.Id,
                GuestName = x.GuestName,
                CheckIn = x.CheckIn
            })
            .FirstOrDefaultAsync(cancellationToken);
    }
}

Important:

  • AsNoTracking() improves performance.
  • We project directly to DTO.
  • We don’t return domain entities.

Controller Usage

Controllers become thin.

[ApiController]
[Route("api/bookings")]
public class BookingsController : ControllerBase
{
    private readonly IMediator _mediator;

    public BookingsController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateBookingCommand command)
    {
        var id = await _mediator.Send(command);
        return Ok(id);
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(Guid id)
    {
        var result = await _mediator.Send(
            new GetBookingByIdQuery(id));

        if (result == null)
            return NotFound();

        return Ok(result);
    }
}

Now the controller:

  • Does NOT contain business logic.
  • Does NOT talk to database.
  • Only delegates.

This is clean architecture in action.

Improvements (Production Ready)

In real systems, you usually add:

  • Validation Pipeline Behavior

Use FluentValidation with MediatR pipeline.

  • Logging Behavior

Centralized logging without polluting handlers.

  • Transaction Behavior

Wrap command handlers in transactions.

  • Separate Read Database

In high-scale systems:

  • Write DB → normalized
  • Read DB → denormalized / optimized

When Should You Use CQRS?

Use CQRS pattern when:

  • Business logic is complex
  • System is growing
  • Performance matters
  • You follow Clean Architecture
  • Multiple teams work on the system

Avoid it when:

  • Small CRUD app
  • Simple internal tool
  • Overengineering risk

CQRS adds structure — and structure adds complexity.

Use it wisely.

Key Takeaways

  • CQRS separates reads and writes.
  • Commands change state.
  • Queries return data.
  • MediatR makes implementation clean and decoupled.
  • Controllers become thin.
  • Handlers become focused and testable.
  • Scaling becomes easier.

Conclusion

CQRS helps you build clearer, more maintainable systems by separating reads from writes. When combined with MediatR in .NET 10, it promotes clean architecture, focused handlers, and thin controllers making your code easier to test, scale, and evolve.

While it may be unnecessary for small CRUD apps, CQRS becomes highly valuable in complex or growing systems. When it is used wisely, it provides a solid foundation for building modern, scalable enterprise applications.