CQRS: Separate Your Reads From Your Writes

CQRS: Separate Your Reads From Your Writes

11 Aug 2026

CQRS: Separate Your Reads From Your Writes

Every developer has been there. A query endpoint grows a few if statements to handle edge cases, then a few more to support filtering, and before you know it, your "simple read" is tangled with the same code path as your "complex write." CQRS (Command Query Responsibility Segregation) fixes this by giving reads and writes their own dedicated handler classes, their own request/response models, and their own database query strategies.

In Brand Website V3, every single operation is a separate handler class. There are no fat controllers or overloaded service methods. A GetBlogPostsQueryHandler handles reads. An UpsertBlogPostCommandHandler handles writes. Each one is focused, testable, and easy to reason about.

Queries: Clean, Focused Reads

A query handler fetches data and returns a typed result. No side effects, no mutations. Just data in, data out.

// Blog.Features/GetBlogPosts/GetBlogPostsQuery.cs
public record GetBlogPostsQuery : GetBlogPostsRequest, IQuery<PaginatedList<BlogPostResponse>>;

public class GetBlogPostsQueryHandler : IQueryHandler<GetBlogPostsQuery, PaginatedList<BlogPostResponse>>
{
    private readonly IBlogDbContext _dbContext;

    public GetBlogPostsQueryHandler(IBlogDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Result<PaginatedList<BlogPostResponse>>> HandleAsync(
        GetBlogPostsQuery request, CancellationToken cancellationToken)
    {
        IQueryable<BlogPost> queryable = _dbContext.BlogPosts;

        if (!request.IncludeDrafts)
            queryable = queryable.Where(post => post.IsPublic);

        if (!string.IsNullOrWhiteSpace(request.PostType))
            queryable = queryable
                .Where(post => post.PostType == request.PostType)
                .OrderByDescending(post => post.IsPinned)
                .ThenByDescending(post => post.CreatedAt);
        else
            queryable = queryable.OrderByDescending(post => post.CreatedAt);

        return Result.Success(await queryable
            .ProjectToType<BlogPostResponse>()
            .PaginatedListAsync(request.PageNumber, request.PageSize, cancellationToken));
    }
}

Notice how clean this is. The handler knows nothing about HTTP, routing, or authentication. It receives a query, runs LINQ against the database context, and returns a result. That is it.

Commands: Mutations With Clear Intent

A command handler performs a write operation. It changes state, saves to the database, and returns success or failure.

// Blog.Features/UpsertBlogPost/UpsertBlogPostCommand.cs
public record UpsertBlogPostCommand : UpsertBlogPostRequest, ICommand;

public class UpsertBlogPostCommandHandler : ICommandHandler<UpsertBlogPostCommand>
{
    private readonly IBlogDbContext _dbContext;

    public UpsertBlogPostCommandHandler(IBlogDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Result> HandleAsync(UpsertBlogPostCommand request, CancellationToken cancellationToken)
    {
        var existingBlogPost = await _dbContext.BlogPosts
            .Include(post => post.Downloadable.Attachment)
            .FirstOrDefaultAsync(post => post.Id == request.Id, cancellationToken);

        existingBlogPost ??= new BlogPost();
        existingBlogPost.Title = request.Title;
        // ... map properties ...

        _dbContext.BlogPosts.Update(existingBlogPost);
        await _dbContext.SaveChangesAsync(cancellationToken);
        return Result.Success();
    }
}

The command handler is just as focused. It fetches the entity (or creates a new one), maps the properties, saves, and returns a Result. No leaked exceptions, no ambiguous return values.

Handler Registration: Zero Boilerplate

Handlers are auto-discovered via assembly scanning. No manual registration needed per handler.

// Blog.Features/Extensions.cs
services.RegisterHandlersFromAssembly(typeof(Extensions));

One line. Every handler in the assembly is registered. Add a new handler, and it just works.

Why It Works

  • Read models and write models evolve independently. You can add a filter to a query without touching any command handler.
public record GetBlogPostsQuery : GetBlogPostsRequest, IQuery<PaginatedList<BlogPostResponse>>;
  • Each handler is a single responsibility. One handler, one operation, one test target.
public async Task<Result<PaginatedList<BlogPostResponse>>> HandleAsync(
    GetBlogPostsQuery request, CancellationToken cancellationToken)
  • Testing becomes trivial. Mock the DbContext, send a command, assert the result. No HTTP pipeline needed.
var result = await handler.HandleAsync(query, CancellationToken.None);
Assert.IsTrue(result.IsSuccess);
  • Handlers are independently discoverable and replaceable. Assembly scanning means dropping in a new handler is one file.
services.RegisterHandlersFromAssembly(typeof(Extensions));

The CQRS Bus: Why AugusteVN.CQRS?

MediatR is excellent and widely used in the .NET ecosystem. Many teams rely on it daily and it has proven itself as a robust mediator library. However, MediatR's recent licensing change introduced commercial license requirements for companies above a certain revenue threshold. This created uncertainty for teams evaluating long-term dependencies.

Brand Website V3 uses a custom CQRS bus from AugusteVN.CQRS instead. This is not about MediatR being worse. It is about future-proofing. By depending on an open-source bus with a permissive license, the project avoids potential licensing complications down the road. The AugusteVN.CQRS bus provides the same ICommandBus and IQueryBus interfaces, auto-discovery via assembly scanning, and DI-based handler resolution that teams expect from a modern CQRS implementation.

// The bus interfaces look familiar
var result = await commandBus.SendAsync(command, CancellationToken.None);

Want to see a production-grade CQRS implementation in action? Brand Website V3 uses AugusteVN.CQRS across all 11 domain modules, with every operation as a separate handler. Check it out at kiss-code.com.


Join the community

I continuously build, learn and experiment with innovative technology. Allow me to share what I learn, with you.

Newsletter

Allow me to share what I learn, with you.

Share

Support

An error has occurred. 🗙