Skip to main content

What is CQRS?

Command Query Responsibility Segregation (CQRS) separates read operations (queries) from write operations (commands). This separation provides:

Clear Intent

Commands change state, queries don’t. The type system enforces this.

Optimized Paths

Queries can bypass validation and use read-optimized projections.

Easy Testing

Test commands and queries independently with focused unit tests.

Audit Trail

Commands represent business operations that can be logged and audited.

Mediator vs MediatR

FullStackHero uses Mediator library (not MediatR). The interfaces and return types are different.
FeatureMediatorMediatR
Command interfaceICommand<T>IRequest<T>
Query interfaceIQuery<T>IRequest<T>
Handler return typeValueTask<T>Task<T>
PackageMediatorMediatR
PerformanceFaster (source generators)Slower (reflection)

Command Pattern

Commands represent write operations that change application state.

Command Interface

Commands implement ICommand<TResponse> from the Mediator library:
Example Command
using FSH.Modules.Identity.Contracts.DTOs;
using Mediator;

namespace FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;

public sealed record CreateGroupCommand(
    string Name,
    string? Description,
    bool IsDefault,
    List<string>? RoleIds) : ICommand<GroupDto>;
Commands are records for immutability. They should be serializable for audit logging and event sourcing.

Command Handler

Handlers implement ICommandHandler<TCommand, TResponse>:
Example Command Handler
using FSH.Framework.Core.Context;
using FSH.Framework.Core.Exceptions;
using FSH.Modules.Identity.Contracts.DTOs;
using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;
using FSH.Modules.Identity.Data;
using FSH.Modules.Identity.Domain;
using Mediator;
using Microsoft.EntityFrameworkCore;

namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup;

public sealed class CreateGroupCommandHandler 
    : ICommandHandler<CreateGroupCommand, GroupDto>
{
    private readonly IdentityDbContext _dbContext;
    private readonly ICurrentUser _currentUser;

    public CreateGroupCommandHandler(
        IdentityDbContext dbContext, 
        ICurrentUser currentUser)
    {
        _dbContext = dbContext;
        _currentUser = currentUser;
    }

    public async ValueTask<GroupDto> Handle(
        CreateGroupCommand command, 
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(command);

        // Validate business rules
        var nameExists = await _dbContext.Groups
            .AnyAsync(g => g.Name == command.Name, cancellationToken);

        if (nameExists)
        {
            throw new CustomException(
                $"Group with name '{command.Name}' already exists.",
                (IEnumerable<string>?)null,
                System.Net.HttpStatusCode.Conflict);
        }

        // Create domain entity
        var group = Group.Create(
            name: command.Name,
            description: command.Description,
            isDefault: command.IsDefault,
            isSystemGroup: false,
            createdBy: _currentUser.GetUserId().ToString());

        _dbContext.Groups.Add(group);
        await _dbContext.SaveChangesAsync(cancellationToken);

        return new GroupDto
        {
            Id = group.Id,
            Name = group.Name,
            Description = group.Description,
            IsDefault = group.IsDefault,
            IsSystemGroup = group.IsSystemGroup,
            MemberCount = 0,
            CreatedAt = group.CreatedAt
        };
    }
}
Handlers return ValueTask<T> (not Task<T>) for better performance when operations complete synchronously.

Command Validation

Every command must have a validator using FluentValidation:
Example Validator
using FluentValidation;
using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;

namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup;

public sealed class CreateGroupCommandValidator 
    : AbstractValidator<CreateGroupCommand>
{
    public CreateGroupCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Group name is required.")
            .MaximumLength(256).WithMessage("Group name must not exceed 256 characters.");

        RuleFor(x => x.Description)
            .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters.");
    }
}
Validation runs automatically before the handler via a pipeline behavior.

Query Pattern

Queries represent read operations that don’t change state.

Query Interface

Queries implement IQuery<TResponse>:
Example Query
using FSH.Modules.Identity.Contracts.DTOs;
using Mediator;

namespace FSH.Modules.Identity.Contracts.v1.Groups.GetGroupById;

public sealed record GetGroupByIdQuery(Guid Id) : IQuery<GroupDto>;

Query Handler

Query handlers implement IQueryHandler<TQuery, TResponse>:
Example Query Handler
using FSH.Framework.Core.Exceptions;
using FSH.Modules.Identity.Contracts.DTOs;
using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupById;
using FSH.Modules.Identity.Data;
using Mediator;
using Microsoft.EntityFrameworkCore;

namespace FSH.Modules.Identity.Features.v1.Groups.GetGroupById;

public sealed class GetGroupByIdQueryHandler 
    : IQueryHandler<GetGroupByIdQuery, GroupDto>
{
    private readonly IdentityDbContext _dbContext;

    public GetGroupByIdQueryHandler(IdentityDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async ValueTask<GroupDto> Handle(
        GetGroupByIdQuery query, 
        CancellationToken cancellationToken)
    {
        var group = await _dbContext.Groups
            .Include(g => g.GroupRoles)
            .FirstOrDefaultAsync(g => g.Id == query.Id, cancellationToken)
            ?? throw new NotFoundException($"Group with ID '{query.Id}' not found.");

        var memberCount = await _dbContext.UserGroups
            .CountAsync(ug => ug.GroupId == group.Id, cancellationToken);

        var roleIds = group.GroupRoles.Select(gr => gr.RoleId).ToList();
        var roleNames = roleIds.Count > 0
            ? await _dbContext.Roles
                .Where(r => roleIds.Contains(r.Id))
                .Select(r => r.Name!)
                .ToListAsync(cancellationToken)
            : [];

        return new GroupDto
        {
            Id = group.Id,
            Name = group.Name,
            Description = group.Description,
            IsDefault = group.IsDefault,
            IsSystemGroup = group.IsSystemGroup,
            MemberCount = memberCount,
            RoleIds = roleIds.AsReadOnly(),
            RoleNames = roleNames.AsReadOnly(),
            CreatedAt = group.CreatedAt
        };
    }
}
Queries typically don’t have validators since they’re read-only. Add validation only if needed for security or performance.

Sending Commands and Queries

Use IMediator to send commands and queries:

From an Endpoint

Minimal API Endpoint
public static class CreateGroupEndpoint
{
    public static RouteHandlerBuilder MapCreateGroupEndpoint(
        this IEndpointRouteBuilder endpoints)
    {
        return endpoints.MapPost("/groups", 
            async (IMediator mediator, 
                   [FromBody] CreateGroupCommand request, 
                   CancellationToken cancellationToken) =>
            {
                var result = await mediator.Send(request, cancellationToken);
                return Results.Ok(result);
            })
        .WithName("CreateGroup")
        .WithSummary("Create a new group")
        .RequirePermission(IdentityPermissionConstants.Groups.Create);
    }
}

From a Service

Service Calling Command
public class TenantProvisioningService
{
    private readonly IMediator _mediator;

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

    public async Task ProvisionTenant(string tenantId)
    {
        // Create default admin group
        var group = await _mediator.Send(
            new CreateGroupCommand("Admins", "Administrator group", true, null));

        // Query the group to verify
        var result = await _mediator.Send(
            new GetGroupByIdQuery(group.Id));
    }
}

Mediator Registration

Mediator must be registered with assemblies containing handlers:
Program.cs
builder.Services.AddMediator(o =>
{
    o.ServiceLifetime = ServiceLifetime.Scoped;
    o.Assemblies = [
        typeof(GenerateTokenCommand),           // Contract assembly
        typeof(GenerateTokenCommandHandler),    // Handler assembly
        typeof(GetTenantStatusQuery),
        typeof(GetTenantStatusQueryHandler),
        typeof(AuditEnvelope),
        typeof(AuditDbContext)
    ];
});
You must register both the contract assemblies (containing commands/queries) and implementation assemblies (containing handlers).

Validation Pipeline Behavior

Validation happens automatically via a pipeline behavior:
src/BuildingBlocks/Web/Mediator/Behaviors/ValidationBehavior.cs
using FluentValidation;
using Mediator;

public class ValidationBehavior<TMessage, TResponse> 
    : IPipelineBehavior<TMessage, TResponse>
    where TMessage : IMessage
{
    private readonly IEnumerable<IValidator<TMessage>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TMessage>> validators)
    {
        _validators = validators;
    }

    public async ValueTask<TResponse> Handle(
        TMessage message,
        CancellationToken cancellationToken,
        MessageHandlerDelegate<TMessage, TResponse> next)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TMessage>(message);
            
            var validationResults = await Task.WhenAll(
                _validators.Select(v => 
                    v.ValidateAsync(context, cancellationToken)));

            var failures = validationResults
                .SelectMany(r => r.Errors)
                .Where(f => f != null)
                .ToList();

            if (failures.Count != 0)
            {
                throw new ValidationException(failures);
            }
        }

        return await next(message, cancellationToken);
    }
}
Registration:
Extension Registration
builder.Services.AddTransient(
    typeof(IPipelineBehavior<,>), 
    typeof(ValidationBehavior<,>));

Request Flow

1

Client sends HTTP request

POST /api/v1/identity/groups with JSON body
2

Model binding

ASP.NET Core deserializes JSON to CreateGroupCommand
3

Authorization

.RequirePermission() checks user permissions
4

Mediator.Send()

Endpoint calls await mediator.Send(command, ct)
5

Validation pipeline

ValidationBehavior runs CreateGroupCommandValidator
6

Handler execution

CreateGroupCommandHandler.Handle() executes business logic
7

Response

Handler returns GroupDto serialized to JSON

Command vs Query Decision Tree

Does this operation change state?
├── YES → Use ICommand<T>
│   ├── Creates data? → CreateXCommand
│   ├── Updates data? → UpdateXCommand
│   ├── Deletes data? → DeleteXCommand
│   └── Creates OR updates? → UpsertXCommand
└── NO → Use IQuery<T>
    ├── Single entity? → GetXByIdQuery
    ├── List of entities? → GetXListQuery
    └── Filtered/paginated? → SearchXQuery

Best Practices

Immutable Commands

Use record types for commands and queries. Never mutate them after creation.

Single Responsibility

One handler per command/query. Don’t create generic handlers.

Validation Required

Every command must have a validator. Queries only if needed.

Return DTOs

Never return domain entities directly. Always use DTOs.

Command Naming

// ✅ Good
CreateGroupCommand
UpdateGroupCommand
DeleteGroupCommand
AssignUserRolesCommand

// ❌ Bad
GroupCommand          // Too vague
AddGroupCommand       // Use "Create" not "Add"
ModifyGroupCommand    // Use "Update" not "Modify"

Query Naming

// ✅ Good
GetGroupByIdQuery
GetGroupsQuery
SearchUsersQuery
GetUserPermissionsQuery

// ❌ Bad
GroupQuery            // Too vague
FetchGroupQuery       // Use "Get" not "Fetch"
RetrieveGroupQuery    // Use "Get" not "Retrieve"

Testing Commands and Queries

Unit Testing a Command Handler

public class CreateGroupCommandHandlerTests
{
    [Fact]
    public async Task Handle_ValidCommand_CreatesGroup()
    {
        // Arrange
        var dbContext = CreateInMemoryDbContext();
        var currentUser = CreateMockCurrentUser();
        var handler = new CreateGroupCommandHandler(dbContext, currentUser);
        
        var command = new CreateGroupCommand(
            "Admins", 
            "Administrator group", 
            false, 
            null);

        // Act
        var result = await handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.NotNull(result);
        Assert.Equal("Admins", result.Name);
        Assert.Equal("Administrator group", result.Description);
    }

    [Fact]
    public async Task Handle_DuplicateName_ThrowsCustomException()
    {
        // Arrange
        var dbContext = CreateInMemoryDbContext();
        await dbContext.Groups.AddAsync(
            Group.Create("Admins", null, false, false, null));
        await dbContext.SaveChangesAsync();

        var handler = new CreateGroupCommandHandler(
            dbContext, 
            CreateMockCurrentUser());
        
        var command = new CreateGroupCommand(
            "Admins",  // Duplicate
            "Another admin group", 
            false, 
            null);

        // Act & Assert
        await Assert.ThrowsAsync<CustomException>(
            () => handler.Handle(command, CancellationToken.None));
    }
}

Testing Validators

public class CreateGroupCommandValidatorTests
{
    private readonly CreateGroupCommandValidator _validator = new();

    [Fact]
    public async Task Validate_EmptyName_ReturnsError()
    {
        // Arrange
        var command = new CreateGroupCommand("", null, false, null);

        // Act
        var result = await _validator.ValidateAsync(command);

        // Assert
        Assert.False(result.IsValid);
        Assert.Contains(result.Errors, 
            e => e.PropertyName == nameof(CreateGroupCommand.Name));
    }

    [Fact]
    public async Task Validate_ValidCommand_ReturnsSuccess()
    {
        // Arrange
        var command = new CreateGroupCommand(
            "Admins", 
            "Administrator group", 
            false, 
            null);

        // Act
        var result = await _validator.ValidateAsync(command);

        // Assert
        Assert.True(result.IsValid);
    }
}

Advanced Patterns

Commands with Side Effects

public async ValueTask<GroupDto> Handle(
    CreateGroupCommand command, 
    CancellationToken cancellationToken)
{
    var group = Group.Create(
        command.Name, 
        command.Description, 
        command.IsDefault, 
        false, 
        _currentUser.GetUserId().ToString());

    // Raise domain event for other modules
    group.AddDomainEvent(GroupCreatedEvent.Create(
        group.Id, 
        group.Name, 
        _currentUser.GetUserId().ToString()));

    _dbContext.Groups.Add(group);
    await _dbContext.SaveChangesAsync(cancellationToken);

    // Domain events are dispatched automatically after SaveChanges

    return MapToDto(group);
}

Queries with Pagination

public sealed record SearchGroupsQuery(
    string? SearchTerm,
    int PageNumber,
    int PageSize) : IQuery<PaginatedResult<GroupDto>>;

public sealed class SearchGroupsQueryHandler 
    : IQueryHandler<SearchGroupsQuery, PaginatedResult<GroupDto>>
{
    public async ValueTask<PaginatedResult<GroupDto>> Handle(
        SearchGroupsQuery query, 
        CancellationToken cancellationToken)
    {
        var queryable = _dbContext.Groups.AsQueryable();

        if (!string.IsNullOrWhiteSpace(query.SearchTerm))
        {
            queryable = queryable.Where(g => 
                g.Name.Contains(query.SearchTerm) || 
                g.Description!.Contains(query.SearchTerm));
        }

        var totalCount = await queryable.CountAsync(cancellationToken);

        var items = await queryable
            .Skip((query.PageNumber - 1) * query.PageSize)
            .Take(query.PageSize)
            .Select(g => new GroupDto { /* ... */ })
            .ToListAsync(cancellationToken);

        return new PaginatedResult<GroupDto>
        {
            Items = items,
            TotalCount = totalCount,
            PageNumber = query.PageNumber,
            PageSize = query.PageSize
        };
    }
}

Next Steps

Domain-Driven Design

Learn about entities, aggregates, and domain events

Vertical Slices

See how commands/queries fit into feature folders

Build docs developers (and LLMs) love