Design Patterns – CQRS

By | 01/10/2025

In this post, we will see what CQRS is and how we can use it in our projects.
But first of all, what is CQRS?
Command Query Responsibility Segregation (CQRS) is an architectural pattern that challenges traditional monolithic approaches to data management. At its core, CQRS separates the read (Query Model) and write (Command Model) operations of an application, allowing for more flexible, scalable, and performant system designs.
In traditional architectures, a single model handles both reading and writing data. CQRS introduces a paradigm shift by creating distinct models for commands (operations that modify data) and queries (operations that read data).”

CQRS is a perfect solution in scenarios where read and write workloads differ significantly in complexity or performance requirements. For example, we may need a highly optimized read model, while the writing side has to deal with complex domain logic or validations. In this scenario, CQRS allow us to refine the command and query sides independently, without forcing a complete redesign. This approach also enables us to maintain separate models for reading and writing, making each side more efficient and easier to maintain.
In a nutshell, the key reasons we might consider to use CQRS are:

  • Complex Domain: If our application has a complex domain model, separating reads and writes can simplify the logic and make it easier to manage.
  • Performance Tuning: Distinct read and write paths allow for targeted optimizations based on use-case scenarios.
  • Scalability: We can scale the read side without necessarily modifying the write side.
  • Security: Separating read and write models can enhance security by allowing different levels of access control for each side.

Before to continue, I want to highlight that not always CQRS is the best solution for a project.
Smaller applications or those with straightforward CRUD requirements can easily become over-engineered if we introduce CQRS without a clear justification.
My advice is to understand very well the project and, after a thorough reflection, decide what is the best architecture to implement.

Now, let’s create a minimal API to perform the usual CRUD operations on a Person entity, implementing the CQRS pattern and the Mediator pattern using MediatR library. This allows our API endpoints to remain decoupled from the underlying logic of how each command or query is processed.

We start to create a .net Minimal API project and then, we install two libraries using the commands:

dotnet add package Microsoft.EntityFrameworkCore.InMemory
dotnet add package MediatR

Then, we define the Person entities (one for read and one for write) and the Database contexts:

[PERSONWRITEMODEL.CS]

namespace CQRS_Test;

public class PersonWriteModel
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Surname { get; set; }
    public int Age { get; set; }
}

[PERSONREADMODEL.CS]

namespace CQRS_Test;

public class PersonReadModel
{
    public int Id { get; set; }
    public string FullName { get; set; } = string.Empty;
    public int Age { get; set; }
    public string AgeCategory { get; set; } = string.Empty;
}

[WRITEDBCONTEXT.CS]

using Microsoft.EntityFrameworkCore;

namespace CQRS_Test;

public class WriteDbContext(DbContextOptions<WriteDbContext> options) : DbContext(options)
{
    public DbSet<PersonWriteModel> Persons { get; set; }
}

[READDBCONTEXT.CS]

using Microsoft.EntityFrameworkCore;

namespace CQRS_Test;

public class ReadDbContext(DbContextOptions<ReadDbContext> options) : DbContext(options)
{
    public DbSet<PersonReadModel> Persons { get; set; }
}


Now, we will create all COMMANDS to manage the entities:

CREATE

[CREATEPERSONCOMMAND.CS]

using MediatR;

namespace CQRS_Test.Commands;

public record CreatePersonCommand(string Name, string Surname, int Age) : IRequest<int>;

[CREATEPERSONCOMMANDHANDLER.CS]

using MediatR;

namespace CQRS_Test.Commands;

public class CreatePersonCommandHandler(WriteDbContext writeDb, ReadDbContext readDb)
    : IRequestHandler<CreatePersonCommand, int>
{
    public async Task<int> Handle(CreatePersonCommand command, CancellationToken cancellationToken)
    {
        var writeModel = new PersonWriteModel
        {
            Name = command.Name,
            Surname = command.Surname,
            Age = command.Age
        };

        writeDb.Persons.Add(writeModel);
        await writeDb.SaveChangesAsync(cancellationToken);

        var readModel = new PersonReadModel
        {
            Id = writeModel.Id,
            FullName = $"{writeModel.Name} {writeModel.Surname}",
            Age = writeModel.Age,
            AgeCategory = writeModel.Age switch
            {
                < 18 => "Minor",
                < 65 => "Adult",
                _ => "Senior"
            }
        };

        readDb.Persons.Add(readModel);
        await readDb.SaveChangesAsync(cancellationToken);

        return writeModel.Id;
    }
}


UPDATE

[UPDATEPERSONCOMMAND.CS]

using MediatR;

namespace CQRS_Test.Commands;

public record UpdatePersonCommand(int Id, string Name, string Surname, int Age) : IRequest<bool>;

[UPDATEPERSONCOMMANDHANDLER.CS]

using MediatR;

namespace CQRS_Test.Commands;

public class UpdatePersonCommandHandler(WriteDbContext writeDb, ReadDbContext readDb)
    : IRequestHandler<UpdatePersonCommand, bool>
{
    public async Task<bool> Handle(UpdatePersonCommand command, CancellationToken cancellationToken)
    {
        var writeModel = await writeDb.Persons.FindAsync([command.Id], cancellationToken);
        if (writeModel == null) return false;

        writeModel.Name = command.Name;
        writeModel.Surname = command.Surname;
        writeModel.Age = command.Age;

        await writeDb.SaveChangesAsync(cancellationToken);

        var readModel = await readDb.Persons.FindAsync([command.Id], cancellationToken);
        if (readModel == null) return false;

        readModel.FullName = $"{command.Name} {command.Surname}";
        readModel.Age = command.Age;
        readModel.AgeCategory = command.Age switch
        {
            < 18 => "Minor",
            < 65 => "Adult",
            _ => "Senior"
        };

        await readDb.SaveChangesAsync(cancellationToken);
        return true;
    }
}


DELETE

[DELETEPERSONCOMMAND.CS]

using MediatR;

namespace CQRS_Test.Commands;

public record DeletePersonCommand(int Id) : IRequest<bool>;

[DELETEPERSONCOMMANDHANDLER.CS]

using MediatR;

namespace CQRS_Test.Commands;

public class DeletePersonCommandHandler(WriteDbContext writeDb, ReadDbContext readDb)
    : IRequestHandler<DeletePersonCommand, bool>
{
    public async Task<bool> Handle(DeletePersonCommand command, CancellationToken cancellationToken)
    {
        var writeModel = await writeDb.Persons.FindAsync([command.Id], cancellationToken);
        if (writeModel == null) return false;

        writeDb.Persons.Remove(writeModel);
        await writeDb.SaveChangesAsync(cancellationToken);

        var readModel = await readDb.Persons.FindAsync([command.Id], cancellationToken);
        if (readModel != null)
        {
            readDb.Persons.Remove(readModel);
            await readDb.SaveChangesAsync(cancellationToken);
        }

        return true;
    }
}


Finally, we will create all QUERIES to extract data:

[GETPERSONBYIDQUERY.CS]

using MediatR;

namespace CQRS_Test.Commands;

public record GetPersonByIdQuery(int Id) : IRequest<PersonReadModel?>;

[GETPERSONBYIDQUERYHANDLER.CS]

using MediatR;

namespace CQRS_Test.Commands;

public class GetPersonByIdQueryHandler(ReadDbContext readDb) : IRequestHandler<GetPersonByIdQuery, PersonReadModel?>
{
    public async Task<PersonReadModel?> Handle(GetPersonByIdQuery query, CancellationToken cancellationToken)
    {
        return await readDb.Persons.FindAsync([query.Id], cancellationToken);
    }
}


[GETALLPERSONQUERY.CS]

using MediatR;

namespace CQRS_Test.Commands;

public record GetAllPersonsQuery : IRequest<IEnumerable<PersonReadModel>>;

[GETALLPERSONQUERYHANDLER.CS]

using MediatR;
using Microsoft.EntityFrameworkCore;

namespace CQRS_Test.Commands;

public class GetAllPersonsQueryHandler(ReadDbContext readDb)
    : IRequestHandler<GetAllPersonsQuery, IEnumerable<PersonReadModel>>
{
    public async Task<IEnumerable<PersonReadModel>> Handle(GetAllPersonsQuery query, CancellationToken cancellationToken)
    {
        return await readDb.Persons.ToListAsync(cancellationToken);
    }
}


The last thing we need to do, is to modify the file Program.cs to insert the endpoints:
[PROGRAM.CS]

using CQRS_Test;
using CQRS_Test.Commands;
using MediatR;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<WriteDbContext>(opt => 
    opt.UseInMemoryDatabase("WriteDb"));
builder.Services.AddDbContext<ReadDbContext>(opt => 
    opt.UseInMemoryDatabase("ReadDb"));

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

var app = builder.Build();

app.MapPost("/api/persons", async (CreatePersonCommand command, ISender sender) =>
{
    var id = await sender.Send(command);
    return Results.Created($"/api/persons/{id}", id);
});

app.MapGet("/api/persons/{id}", async (int id, ISender sender) =>
{
    var person = await sender.Send(new GetPersonByIdQuery(id));
    return person is null ? Results.NotFound() : Results.Ok(person);
});

app.MapGet("/api/persons", async (ISender sender) =>
{
    var persons = await sender.Send(new GetAllPersonsQuery());
    return Results.Ok(persons);
});

app.MapPut("/api/persons/{id}", async (int id, UpdatePersonCommand command, ISender sender) =>
{
    if (id != command.Id) return Results.BadRequest();
    var success = await sender.Send(command);
    return success ? Results.NoContent() : Results.NotFound();
});

app.MapDelete("/api/persons/{id}", async (int id, ISender sender) =>
{
    var success = await sender.Send(new DeletePersonCommand(id));
    return success ? Results.NoContent() : Results.NotFound();
});

app.Run();


We are done and now, if we run the application, the result will be as follows:
INSERT:


DELETE:


UPDATE:





Leave a Reply

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