Skip to main content

What is CQRS?

CQRS (Command Query Responsibility Segregation) is a pattern that separates read and write operations for a data store. Commands change the state of the system, while Queries return data without side effects.
CQRS is implemented across all services in AspNetRun using MediatR as the mediator and BuildingBlocks.CQRS for abstractions.

Core Principle

Commands

  • Write operations
  • Change system state
  • Void or return simple results
  • Can fail with validation errors
  • Examples: CreateOrder, UpdateProduct, DeleteBasket

Queries

  • Read operations
  • Never modify state
  • Return data
  • Idempotent (can be called multiple times)
  • Examples: GetOrders, GetProductById, GetBasket

CQRS Building Blocks

The project defines CQRS abstractions in the BuildingBlocks shared library:

Command Interfaces

src/BuildingBlocks/BuildingBlocks/CQRS/ICommand.cs
using MediatR;

namespace BuildingBlocks.CQRS;

// Command without return value (void)
public interface ICommand : ICommand<Unit>
{
}

// Command with return value
public interface ICommand<out TResponse> : IRequest<TResponse>
{
}
src/BuildingBlocks/BuildingBlocks/CQRS/ICommandHandler.cs
using MediatR;

namespace BuildingBlocks.CQRS;

// Handler for void commands
public interface ICommandHandler<in TCommand> 
    : ICommandHandler<TCommand, Unit>
    where TCommand : ICommand<Unit>
{ 
}

// Handler for commands with return values
public interface ICommandHandler<in TCommand, TResponse> 
    : IRequestHandler<TCommand, TResponse> 
    where TCommand : ICommand<TResponse>
    where TResponse : notnull
{
}

Query Interfaces

src/BuildingBlocks/BuildingBlocks/CQRS/IQuery.cs
using MediatR;

namespace BuildingBlocks.CQRS;

public interface IQuery<out TResponse> : IRequest<TResponse>  
    where TResponse : notnull
{
}
src/BuildingBlocks/BuildingBlocks/CQRS/IQueryHandler.cs
using MediatR;

namespace BuildingBlocks.CQRS;

public interface IQueryHandler<in TQuery, TResponse>
    : IRequestHandler<TQuery, TResponse>
    where TQuery : IQuery<TResponse>
    where TResponse : notnull
{
}
Why use abstractions?
  • Consistency: All services use the same pattern
  • Clarity: Explicit distinction between commands and queries
  • MediatR Integration: Abstractions wrap MediatR interfaces
  • Type Safety: Generic constraints ensure correct usage

CQRS in the Ordering Service

The Ordering service demonstrates CQRS with Clean Architecture and DDD.

Command Example: CreateOrder

1. Command Definition

src/Services/Ordering/Ordering.Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs
using BuildingBlocks.CQRS;
using FluentValidation;
using Ordering.Application.Dtos;

namespace Ordering.Application.Orders.Commands.CreateOrder;

// Command with input data and result type
public record CreateOrderCommand(OrderDto Order)
    : ICommand<CreateOrderResult>;

// Result returned after successful command execution
public record CreateOrderResult(Guid Id);

// Validation rules for the command
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.Order.OrderName)
            .NotEmpty().WithMessage("Name is required");
        
        RuleFor(x => x.Order.CustomerId)
            .NotNull().WithMessage("CustomerId is required");
        
        RuleFor(x => x.Order.OrderItems)
            .NotEmpty().WithMessage("OrderItems should not be empty");
    }
}

2. Command Handler

src/Services/Ordering/Ordering.Application/Orders/Commands/CreateOrder/CreateOrderHandler.cs
namespace Ordering.Application.Orders.Commands.CreateOrder;

public class CreateOrderHandler(IApplicationDbContext dbContext)
    : ICommandHandler<CreateOrderCommand, CreateOrderResult>
{
    public async Task<CreateOrderResult> Handle(
        CreateOrderCommand command, 
        CancellationToken cancellationToken)
    {
        // 1. Create Order entity from command object
        var order = CreateNewOrder(command.Order);

        // 2. Save to database
        dbContext.Orders.Add(order);
        await dbContext.SaveChangesAsync(cancellationToken);

        // 3. Return result
        return new CreateOrderResult(order.Id.Value);
    }

    private Order CreateNewOrder(OrderDto orderDto)
    {
        // Create value objects
        var shippingAddress = Address.Of(
            orderDto.ShippingAddress.FirstName, 
            orderDto.ShippingAddress.LastName,
            orderDto.ShippingAddress.EmailAddress, 
            orderDto.ShippingAddress.AddressLine,
            orderDto.ShippingAddress.Country, 
            orderDto.ShippingAddress.State,
            orderDto.ShippingAddress.ZipCode);

        var billingAddress = Address.Of(
            orderDto.BillingAddress.FirstName, 
            orderDto.BillingAddress.LastName,
            orderDto.BillingAddress.EmailAddress, 
            orderDto.BillingAddress.AddressLine,
            orderDto.BillingAddress.Country, 
            orderDto.BillingAddress.State,
            orderDto.BillingAddress.ZipCode);

        // Create aggregate using factory method
        var newOrder = Order.Create(
            id: OrderId.Of(Guid.NewGuid()),
            customerId: CustomerId.Of(orderDto.CustomerId),
            orderName: OrderName.Of(orderDto.OrderName),
            shippingAddress: shippingAddress,
            billingAddress: billingAddress,
            payment: Payment.Of(
                orderDto.Payment.CardName, 
                orderDto.Payment.CardNumber,
                orderDto.Payment.Expiration, 
                orderDto.Payment.Cvv,
                orderDto.Payment.PaymentMethod)
        );

        // Add order items through aggregate method
        foreach (var orderItemDto in orderDto.OrderItems)
        {
            newOrder.Add(
                ProductId.Of(orderItemDto.ProductId), 
                orderItemDto.Quantity, 
                orderItemDto.Price);
        }
        
        return newOrder;
    }
}

3. API Endpoint

src/Services/Ordering/Ordering.API/Endpoints/CreateOrder.cs
namespace Ordering.API.Endpoints;

public record CreateOrderRequest(OrderDto Order);
public record CreateOrderResponse(Guid Id);

public class CreateOrder : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapPost("/orders", async (CreateOrderRequest request, ISender sender) =>
        {
            // 1. Map request to command
            var command = request.Adapt<CreateOrderCommand>();

            // 2. Send command via MediatR
            var result = await sender.Send(command);

            // 3. Map result to response
            var response = result.Adapt<CreateOrderResponse>();

            return Results.Created($"/orders/{response.Id}", response);
        })
        .WithName("CreateOrder")
        .Produces<CreateOrderResponse>(StatusCodes.Status201Created)
        .ProducesProblem(StatusCodes.Status400BadRequest)
        .WithSummary("Create Order")
        .WithDescription("Create Order");
    }
}

Query Example: GetOrders

1. Query Definition

src/Services/Ordering/Ordering.Application/Orders/Queries/GetOrders/GetOrdersQuery.cs
using BuildingBlocks.Pagination;

namespace Ordering.Application.Orders.Queries.GetOrders;

// Query with parameters and result type
public record GetOrdersQuery(PaginationRequest PaginationRequest) 
    : IQuery<GetOrdersResult>;

// Result containing the data
public record GetOrdersResult(PaginatedResult<OrderDto> Orders);

2. Query Handler

src/Services/Ordering/Ordering.Application/Orders/Queries/GetOrders/GetOrdersHandler.cs
using BuildingBlocks.Pagination;

namespace Ordering.Application.Orders.Queries.GetOrders;

public class GetOrdersHandler(IApplicationDbContext dbContext)
    : IQueryHandler<GetOrdersQuery, GetOrdersResult>
{
    public async Task<GetOrdersResult> Handle(
        GetOrdersQuery query, 
        CancellationToken cancellationToken)
    {
        // 1. Get pagination parameters
        var pageIndex = query.PaginationRequest.PageIndex;
        var pageSize = query.PaginationRequest.PageSize;

        // 2. Get total count
        var totalCount = await dbContext.Orders.LongCountAsync(cancellationToken);

        // 3. Query orders with pagination
        var orders = await dbContext.Orders
            .Include(o => o.OrderItems)
            .OrderBy(o => o.OrderName.Value)
            .Skip(pageSize * pageIndex)
            .Take(pageSize)
            .ToListAsync(cancellationToken);

        // 4. Return paginated result
        return new GetOrdersResult(
            new PaginatedResult<OrderDto>(
                pageIndex,
                pageSize,
                totalCount,
                orders.ToOrderDtoList()));
    }
}

3. API Endpoint

namespace Ordering.API.Endpoints;

public record GetOrdersRequest(PaginationRequest PaginationRequest);
public record GetOrdersResponse(PaginatedResult<OrderDto> Orders);

public class GetOrders : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("/orders", async ([AsParameters] GetOrdersRequest request, ISender sender) =>
        {
            var query = request.Adapt<GetOrdersQuery>();
            var result = await sender.Send(query);
            var response = result.Adapt<GetOrdersResponse>();
            return Results.Ok(response);
        })
        .WithName("GetOrders")
        .Produces<GetOrdersResponse>(StatusCodes.Status200OK)
        .WithSummary("Get Orders")
        .WithDescription("Get Orders with Pagination");
    }
}

CQRS in the Catalog Service

The Catalog service demonstrates CQRS with Vertical Slice Architecture.

Command: CreateProduct

src/Services/Catalog/Catalog.API/Products/CreateProduct/CreateProductHandler.cs
namespace Catalog.API.Products.CreateProduct;

public record CreateProductCommand(string Name, List<string> Category, 
    string Description, string ImageFile, decimal Price)
    : ICommand<CreateProductResult>;
    
public record CreateProductResult(Guid Id);

public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
    public CreateProductCommandValidator()
    {
        RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required");
        RuleFor(x => x.Category).NotEmpty().WithMessage("Category is required");
        RuleFor(x => x.ImageFile).NotEmpty().WithMessage("ImageFile is required");
        RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than 0");
    }
}

internal class CreateProductCommandHandler
    (IDocumentSession session)
    : ICommandHandler<CreateProductCommand, CreateProductResult>
{
    public async Task<CreateProductResult> Handle(
        CreateProductCommand command, 
        CancellationToken cancellationToken)
    {
        // Create Product entity from command object
        var product = new Product
        {
            Name = command.Name,
            Category = command.Category,
            Description = command.Description,
            ImageFile = command.ImageFile,
            Price = command.Price
        };
        
        // Save to database (Marten document store)
        session.Store(product);
        await session.SaveChangesAsync(cancellationToken);

        // Return result
        return new CreateProductResult(product.Id);
    }
}

Query: GetProducts

src/Services/Catalog/Catalog.API/Products/GetProducts/GetProductsHandler.cs
namespace Catalog.API.Products.GetProducts;

public record GetProductsQuery(int? PageNumber = 1, int? PageSize = 10) 
    : IQuery<GetProductsResult>;
    
public record GetProductsResult(IEnumerable<Product> Products);

internal class GetProductsQueryHandler
    (IDocumentSession session)
    : IQueryHandler<GetProductsQuery, GetProductsResult>
{
    public async Task<GetProductsResult> Handle(
        GetProductsQuery query, 
        CancellationToken cancellationToken)
    {
        var products = await session.Query<Product>()
            .ToPagedListAsync(query.PageNumber ?? 1, query.PageSize ?? 10, cancellationToken);

        return new GetProductsResult(products);
    }
}
Vertical Slice Difference:
  • Commands, handlers, and endpoints are in the same feature folder
  • Simpler model without domain complexity
  • Uses Marten document database instead of EF Core
  • No separate domain layer

MediatR Pipeline Behaviors

The project uses MediatR pipeline behaviors for cross-cutting concerns:

Validation Behavior

Automatically validates commands before execution:
src/BuildingBlocks/BuildingBlocks/Behaviors/ValidationBehavior.cs
using BuildingBlocks.CQRS;
using FluentValidation;
using MediatR;

namespace BuildingBlocks.Behaviors;

public class ValidationBehavior<TRequest, TResponse>
    (IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICommand<TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request, 
        RequestHandlerDelegate<TResponse> next, 
        CancellationToken cancellationToken)
    {
        // Create validation context
        var context = new ValidationContext<TRequest>(request);

        // Run all validators
        var validationResults =
            await Task.WhenAll(validators.Select(v => 
                v.ValidateAsync(context, cancellationToken)));

        // Collect failures
        var failures =
            validationResults
            .Where(r => r.Errors.Any())
            .SelectMany(r => r.Errors)
            .ToList();

        // Throw if validation failed
        if (failures.Any())
            throw new ValidationException(failures);

        // Continue pipeline
        return await next();
    }
}

Logging Behavior

Logs all requests with performance tracking:
src/BuildingBlocks/BuildingBlocks/Behaviors/LoggingBehavior.cs
using MediatR;
using Microsoft.Extensions.Logging;
using System.Diagnostics;

namespace BuildingBlocks.Behaviors;

public class LoggingBehavior<TRequest, TResponse>
    (ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull, IRequest<TResponse>
    where TResponse : notnull
{
    public async Task<TResponse> Handle(
        TRequest request, 
        RequestHandlerDelegate<TResponse> next, 
        CancellationToken cancellationToken)
    {
        logger.LogInformation(
            "[START] Handle request={Request} - Response={Response} - RequestData={RequestData}",
            typeof(TRequest).Name, typeof(TResponse).Name, request);

        var timer = new Stopwatch();
        timer.Start();

        var response = await next();

        timer.Stop();
        var timeTaken = timer.Elapsed;
        
        // Warn if request took too long
        if (timeTaken.Seconds > 3)
            logger.LogWarning(
                "[PERFORMANCE] The request {Request} took {TimeTaken} seconds.",
                typeof(TRequest).Name, timeTaken.Seconds);

        logger.LogInformation(
            "[END] Handled {Request} with {Response}", 
            typeof(TRequest).Name, typeof(TResponse).Name);
            
        return response;
    }
}

Registering Behaviors

src/Services/Catalog/Catalog.API/Program.cs:8-13
var assembly = typeof(Program).Assembly;
builder.Services.AddMediatR(config =>
{
    config.RegisterServicesFromAssembly(assembly);
    config.AddOpenBehavior(typeof(ValidationBehavior<,>));
    config.AddOpenBehavior(typeof(LoggingBehavior<,>));
});

Request Flow with Behaviors

Benefits of CQRS

Scalability

Read and write sides can be scaled independently

Optimization

Optimize reads separately from writes (e.g., denormalized read models)

Simplicity

Each handler has a single responsibility

Testability

Handlers are easy to unit test in isolation

Security

Different permissions for commands vs queries

Performance

Can use different data stores for reads and writes

CQRS Variations

Simple CQRS (Used in AspNetRun)

Characteristics:
  • Same database for reads and writes
  • Synchronous updates
  • Simple to implement
  • Good for most applications

Advanced CQRS with Separate Models

Characteristics:
  • Separate databases for reads and writes
  • Eventual consistency
  • Maximum scalability
  • More complex to implement

Best Practices

// Good: Task-based command name
public record CreateOrderCommand(OrderDto Order) : ICommand<CreateOrderResult>;

// Bad: CRUD-based name
public record InsertOrderCommand(OrderDto Order) : ICommand<CreateOrderResult>;
// Good: Read-only query
public async Task<GetOrdersResult> Handle(GetOrdersQuery query, CancellationToken ct)
{
    return await dbContext.Orders.AsNoTracking().ToListAsync(ct);
}

// Bad: Query that modifies state
public async Task<GetOrdersResult> Handle(GetOrdersQuery query, CancellationToken ct)
{
    var orders = await dbContext.Orders.ToListAsync(ct);
    orders[0].Status = OrderStatus.Processing; // DON'T DO THIS!
    await dbContext.SaveChangesAsync();
    return orders;
}
// Define validator for command
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.Order.OrderName).NotEmpty();
        RuleFor(x => x.Order.CustomerId).NotNull();
    }
}

// Validation runs automatically via pipeline behavior
Each handler should do one thing well. Don’t put multiple responsibilities in a single handler.

Clean Architecture

See how CQRS fits into the Application layer

DDD Principles

Learn how CQRS complements Domain-Driven Design

Vertical Slice

Compare CQRS implementation in different architectures

Microservices

Understand CQRS in distributed systems

Build docs developers (and LLMs) love