11 Aug 2026
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.
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.
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.
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.
public record GetBlogPostsQuery : GetBlogPostsRequest, IQuery<PaginatedList<BlogPostResponse>>;
public async Task<Result<PaginatedList<BlogPostResponse>>> HandleAsync(
GetBlogPostsQuery request, CancellationToken cancellationToken)
var result = await handler.HandleAsync(query, CancellationToken.None);
Assert.IsTrue(result.IsSuccess);
services.RegisterHandlersFromAssembly(typeof(Extensions));
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.
I continuously build, learn and experiment with innovative technology. Allow me to share what I learn, with you.