Skip to main content

Overview

CQRS (Command Query Responsibility Segregation) is a pattern that separates read operations (queries) from write operations (commands). SGRH implements CQRS using MediatR to organize application use cases.
CQRS helps maintain clean separation between operations that modify state (commands) and operations that retrieve data (queries).

Architecture Overview

Project Structure

SGRH.Application/
├── Features/
│   ├── Clientes/
│   │   ├── Commands/
│   │   │   ├── CrearCliente.cs          # Command + Handler
│   │   │   ├── ActualizarCliente.cs
│   │   │   └── EliminarCliente.cs
│   │   └── Queries/
│   │       ├── ObtenerClientePorId.cs   # Query + Handler
│   │       └── ListarClientes.cs
│   ├── Reservas/
│   │   ├── Commands/
│   │   │   ├── ConfirmarReserva.cs
│   │   │   ├── CancelarReserva.cs
│   │   │   ├── CheckIn.cs
│   │   │   └── CheckOut.cs
│   │   └── Queries/
│   │       ├── ObtenerReservaPorId.cs
│   │       └── ListarReservas.cs
│   └── Habitaciones/
│       ├── Commands/
│       │   ├── CrearHabitacion.cs
│       │   ├── ActualizarHabitacion.cs
│       │   └── CambiarEstadoHabitacion.cs
│       └── Queries/
│           ├── ObtenerHabitacionPorId.cs
│           └── ListarHabitaciones.cs
└── DTOs/
    ├── Clientes/
    │   ├── Requests/
    │   │   ├── CrearClienteRequest.cs
    │   │   └── ActualizarClienteRequest.cs
    │   └── Responses/
    │       ├── ClienteResponse.cs
    │       └── ClienteDetalleResponse.cs
    └── ...

Commands vs Queries

Commands

Purpose: Change system stateCharacteristics:
  • Modify domain entities
  • Use UnitOfWork for transactions
  • Validate business rules
  • Return simple results (ID, success/fail)
  • May trigger side effects (emails, events)
Examples:
  • CrearClienteCommand
  • ConfirmarReservaCommand
  • CambiarEstadoHabitacionCommand

Queries

Purpose: Retrieve dataCharacteristics:
  • Read-only operations
  • Return DTOs, not domain entities
  • Can bypass domain layer for performance
  • No side effects
  • Can use optimized queries (projections)
Examples:
  • ObtenerClientePorIdQuery
  • ListarReservasQuery
  • GenerarReporteOcupacionQuery

MediatR Integration

SGRH uses MediatR to implement the mediator pattern for CQRS:
SGRH.Application/SGRH.Application.csproj
<ItemGroup>
  <PackageReference Include="FluentValidation" Version="12.1.1" />
  <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
  <PackageReference Include="MediatR" Version="14.0.0" />
</ItemGroup>

Request/Response Pattern

Commands implement IRequest<TResponse> and return a result:
CrearClienteCommand.cs
using MediatR;
using SGRH.Application.Common.Results;

namespace SGRH.Application.Features.Clientes.Commands;

public record CrearClienteCommand(
    string NationalId,
    string NombreCliente,
    string ApellidoCliente,
    string Email,
    string Telefono
) : IRequest<Result<int>>;

public class CrearClienteCommandHandler 
    : IRequestHandler<CrearClienteCommand, Result<int>>
{
    private readonly IClienteRepository _clienteRepo;
    private readonly IUnitOfWork _unitOfWork;

    public CrearClienteCommandHandler(
        IClienteRepository clienteRepo,
        IUnitOfWork unitOfWork)
    {
        _clienteRepo = clienteRepo;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<int>> Handle(
        CrearClienteCommand request, 
        CancellationToken cancellationToken)
    {
        // 1. Validate uniqueness
        var existente = await _clienteRepo
            .GetByNationalIdAsync(request.NationalId, cancellationToken);
            
        if (existente is not null)
            return Result<int>.Failure(
                "Ya existe un cliente con ese documento.");

        // 2. Create domain entity
        var cliente = new Cliente(
            request.NationalId,
            request.NombreCliente,
            request.ApellidoCliente,
            request.Email,
            request.Telefono
        );

        // 3. Persist
        await _clienteRepo.AddAsync(cliente, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return Result<int>.Success(cliente.ClienteId);
    }
}

Command Pattern Deep Dive

Complex Command Example: ConfirmarReserva

Commands can orchestrate complex business operations:
ConfirmarReservaCommand.cs
using MediatR;
using SGRH.Domain.Abstractions.Repositories;
using SGRH.Domain.Abstractions.Email;
using SGRH.Application.Common.Results;

namespace SGRH.Application.Features.Reservas.Commands;

public record ConfirmarReservaCommand(int ReservaId) 
    : IRequest<Result<bool>>;

public class ConfirmarReservaCommandHandler 
    : IRequestHandler<ConfirmarReservaCommand, Result<bool>>
{
    private readonly IReservaRepository _reservaRepo;
    private readonly IHabitacionRepository _habitacionRepo;
    private readonly IEmailSender _emailSender;
    private readonly IUnitOfWork _unitOfWork;

    public ConfirmarReservaCommandHandler(
        IReservaRepository reservaRepo,
        IHabitacionRepository habitacionRepo,
        IEmailSender emailSender,
        IUnitOfWork unitOfWork)
    {
        _reservaRepo = reservaRepo;
        _habitacionRepo = habitacionRepo;
        _emailSender = emailSender;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<bool>> Handle(
        ConfirmarReservaCommand request,
        CancellationToken cancellationToken)
    {
        // 1. Load aggregate with all related entities
        var reserva = await _reservaRepo
            .GetByIdWithDetailsAsync(request.ReservaId, cancellationToken);

        if (reserva is null)
            return Result<bool>.Failure(
                $"Reserva con ID {request.ReservaId} no encontrada.");

        // 2. Execute business logic (domain method enforces rules)
        try
        {
            reserva.Confirmar();
        }
        catch (BusinessRuleViolationException ex)
        {
            return Result<bool>.Failure(ex.Message);
        }

        // 3. Mark rooms as occupied
        foreach (var detalle in reserva.Habitaciones)
        {
            var habitacion = await _habitacionRepo
                .GetByIdAsync(detalle.HabitacionId, cancellationToken);
                
            if (habitacion is not null)
                habitacion.CambiarEstado(EstadoHabitacion.Ocupada);
        }

        // 4. Persist changes in transaction
        await _unitOfWork.BeginTransactionAsync(cancellationToken);
        try
        {
            _reservaRepo.Update(reserva);
            await _unitOfWork.SaveChangesAsync(cancellationToken);
            await _unitOfWork.CommitAsync(cancellationToken);
        }
        catch
        {
            await _unitOfWork.RollbackAsync(cancellationToken);
            throw;
        }

        // 5. Send confirmation email (side effect - outside transaction)
        await EnviarEmailConfirmacionAsync(reserva, cancellationToken);

        return Result<bool>.Success(true);
    }

    private async Task EnviarEmailConfirmacionAsync(
        Reserva reserva, 
        CancellationToken ct)
    {
        var mensaje = new EmailMessage
        {
            To = new[] { new EmailRecipient(reserva.Cliente.Email) },
            Subject = $"Confirmación de Reserva #{reserva.ReservaId}",
            Body = $"Su reserva ha sido confirmada para {reserva.FechaEntrada:d}."
        };

        await _emailSender.SendAsync(mensaje, ct);
    }
}
Command Handler Responsibilities:
1

Load Entities

Retrieve necessary domain entities and aggregates from repositories.
2

Execute Business Logic

Call domain methods that enforce business rules. Handle domain exceptions.
3

Coordinate Changes

Orchestrate changes across multiple aggregates if needed.
4

Persist in Transaction

Use UnitOfWork to save all changes atomically.
5

Trigger Side Effects

Send emails, publish events, etc. (outside transaction).

Query Pattern Deep Dive

Simple Query Example

Queries can directly query the database for read models:
ListarClientesQuery.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using SGRH.Persistence.Context;
using SGRH.Application.DTOs.Clientes.Responses;
using SGRH.Application.Common.Results;

namespace SGRH.Application.Features.Clientes.Queries;

public record ListarClientesQuery(
    string? Filtro = null,
    int Pagina = 1,
    int TamañoPagina = 20
) : IRequest<PagedResult<ClienteResponse>>;

public class ListarClientesQueryHandler 
    : IRequestHandler<ListarClientesQuery, PagedResult<ClienteResponse>>
{
    private readonly SGRHDbContext _context;

    public ListarClientesQueryHandler(SGRHDbContext context)
    {
        _context = context;
    }

    public async Task<PagedResult<ClienteResponse>> Handle(
        ListarClientesQuery request,
        CancellationToken cancellationToken)
    {
        var query = _context.Clientes.AsQueryable();

        // Apply filter
        if (!string.IsNullOrWhiteSpace(request.Filtro))
        {
            query = query.Where(c => 
                c.NombreCliente.Contains(request.Filtro) ||
                c.ApellidoCliente.Contains(request.Filtro) ||
                c.Email.Contains(request.Filtro) ||
                c.NationalId.Contains(request.Filtro)
            );
        }

        // Get total count
        var totalItems = await query.CountAsync(cancellationToken);

        // Apply pagination and projection
        var items = await query
            .OrderBy(c => c.ApellidoCliente)
            .ThenBy(c => c.NombreCliente)
            .Skip((request.Pagina - 1) * request.TamañoPagina)
            .Take(request.TamañoPagina)
            .Select(c => new ClienteResponse
            {
                ClienteId = c.ClienteId,
                NombreCompleto = $"{c.NombreCliente} {c.ApellidoCliente}",
                Email = c.Email,
                Telefono = c.Telefono
            })
            .ToListAsync(cancellationToken);

        return new PagedResult<ClienteResponse>
        {
            Items = items,
            TotalItems = totalItems,
            Pagina = request.Pagina,
            TamañoPagina = request.TamañoPagina
        };
    }
}
Performance Optimization: Queries can bypass the domain layer and use EF projections (Select) to load only the data needed for the DTO. This is more efficient than loading full entities.

Complex Query Example: Reports

Queries can execute complex aggregations:
GenerarReporteOcupacionQuery.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using SGRH.Persistence.Context;
using SGRH.Application.DTOs.Reportes;
using SGRH.Domain.Enums;

namespace SGRH.Application.Features.Reportes.Queries;

public record GenerarReporteOcupacionQuery(
    DateTime FechaInicio,
    DateTime FechaFin,
    AgrupacionTemporal Agrupacion
) : IRequest<ReporteOcupacionResponse>;

public class GenerarReporteOcupacionQueryHandler
    : IRequestHandler<GenerarReporteOcupacionQuery, ReporteOcupacionResponse>
{
    private readonly SGRHDbContext _context;

    public GenerarReporteOcupacionQueryHandler(SGRHDbContext context)
    {
        _context = context;
    }

    public async Task<ReporteOcupacionResponse> Handle(
        GenerarReporteOcupacionQuery request,
        CancellationToken cancellationToken)
    {
        var totalHabitaciones = await _context.Habitaciones.CountAsync(cancellationToken);

        var ocupacion = await _context.Reservas
            .Where(r => 
                r.EstadoReserva == EstadoReserva.Confirmada &&
                r.FechaEntrada >= request.FechaInicio &&
                r.FechaSalida <= request.FechaFin)
            .SelectMany(r => r.Habitaciones)
            .GroupBy(d => d.HabitacionId)
            .Select(g => new
            {
                HabitacionId = g.Key,
                DiasOcupados = g.Count()
            })
            .ToListAsync(cancellationToken);

        var diasPeriodo = (request.FechaFin - request.FechaInicio).Days;
        var tasaOcupacion = ocupacion.Count > 0
            ? (ocupacion.Sum(o => o.DiasOcupados) / (double)(totalHabitaciones * diasPeriodo)) * 100
            : 0;

        return new ReporteOcupacionResponse
        {
            FechaInicio = request.FechaInicio,
            FechaFin = request.FechaFin,
            TotalHabitaciones = totalHabitaciones,
            TasaOcupacionPromedio = Math.Round(tasaOcupacion, 2),
            DetalleOcupacion = ocupacion.Select(o => new OcupacionDetalle
            {
                HabitacionId = o.HabitacionId,
                DiasOcupados = o.DiasOcupados,
                Porcentaje = Math.Round((o.DiasOcupados / (double)diasPeriodo) * 100, 2)
            }).ToList()
        };
    }
}

DTOs (Data Transfer Objects)

Request DTOs

Define input data for commands:
CrearClienteRequest.cs
namespace SGRH.Application.DTOs.Clientes.Requests;

public class CrearClienteRequest
{
    public string NationalId { get; set; } = string.Empty;
    public string NombreCliente { get; set; } = string.Empty;
    public string ApellidoCliente { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Telefono { get; set; } = string.Empty;
}

Response DTOs

Define output data for queries:
ClienteResponse.cs
namespace SGRH.Application.DTOs.Clientes.Responses;

public class ClienteResponse
{
    public int ClienteId { get; set; }
    public string NombreCompleto { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Telefono { get; set; } = string.Empty;
}
ClienteDetalleResponse.cs
namespace SGRH.Application.DTOs.Clientes.Responses;

public class ClienteDetalleResponse : ClienteResponse
{
    public string NationalId { get; set; } = string.Empty;
    public DateTime FechaRegistro { get; set; }
    public List<ReservaResponse> ReservasRecientes { get; set; } = [];
}
DTOs are anemic (no behavior) - they only carry data. This is intentional and appropriate for the application layer.

Validation with FluentValidation

SGRH uses FluentValidation for input validation:
CrearClienteCommandValidator.cs
using FluentValidation;

namespace SGRH.Application.Features.Clientes.Commands;

public class CrearClienteCommandValidator : AbstractValidator<CrearClienteCommand>
{
    public CrearClienteCommandValidator()
    {
        RuleFor(x => x.NationalId)
            .NotEmpty().WithMessage("El documento nacional es requerido.")
            .MaximumLength(20).WithMessage("El documento no puede exceder 20 caracteres.");

        RuleFor(x => x.NombreCliente)
            .NotEmpty().WithMessage("El nombre es requerido.")
            .MaximumLength(100).WithMessage("El nombre no puede exceder 100 caracteres.");

        RuleFor(x => x.ApellidoCliente)
            .NotEmpty().WithMessage("El apellido es requerido.")
            .MaximumLength(100).WithMessage("El apellido no puede exceder 100 caracteres.");

        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("El email es requerido.")
            .EmailAddress().WithMessage("Debe ser un email válido.")
            .MaximumLength(100).WithMessage("El email no puede exceder 100 caracteres.");

        RuleFor(x => x.Telefono)
            .NotEmpty().WithMessage("El teléfono es requerido.")
            .Matches(@"^\+?[0-9\s\-()]+$").WithMessage("Formato de teléfono inválido.")
            .MaximumLength(20).WithMessage("El teléfono no puede exceder 20 caracteres.");
    }
}

Controller Integration

Controllers send commands/queries through MediatR:
ClientesController.cs
using MediatR;
using Microsoft.AspNetCore.Mvc;
using SGRH.Application.Features.Clientes.Commands;
using SGRH.Application.Features.Clientes.Queries;
using SGRH.Application.DTOs.Clientes.Requests;

namespace SGRH.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ClientesController : ControllerBase
{
    private readonly IMediator _mediator;

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

    [HttpPost]
    public async Task<IActionResult> CrearCliente(
        [FromBody] CrearClienteRequest request,
        CancellationToken ct)
    {
        var command = new CrearClienteCommand(
            request.NationalId,
            request.NombreCliente,
            request.ApellidoCliente,
            request.Email,
            request.Telefono
        );

        var result = await _mediator.Send(command, ct);

        return result.IsSuccess
            ? CreatedAtAction(nameof(ObtenerCliente), new { id = result.Value }, result.Value)
            : BadRequest(result.Error);
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> ObtenerCliente(
        int id,
        CancellationToken ct)
    {
        var query = new ObtenerClientePorIdQuery(id);
        var result = await _mediator.Send(query, ct);

        return result.IsSuccess
            ? Ok(result.Value)
            : NotFound(result.Error);
    }

    [HttpGet]
    public async Task<IActionResult> ListarClientes(
        [FromQuery] string? filtro,
        [FromQuery] int pagina = 1,
        [FromQuery] int tamaño = 20,
        CancellationToken ct = default)
    {
        var query = new ListarClientesQuery(filtro, pagina, tamaño);
        var result = await _mediator.Send(query, ct);

        return Ok(result);
    }

    [HttpPut("{id}")]
    public async Task<IActionResult> ActualizarCliente(
        int id,
        [FromBody] ActualizarClienteRequest request,
        CancellationToken ct)
    {
        var command = new ActualizarClienteCommand(id, request);
        var result = await _mediator.Send(command, ct);

        return result.IsSuccess
            ? NoContent()
            : BadRequest(result.Error);
    }
}
The controller is thin - it just translates HTTP requests to commands/queries and sends them through MediatR. All business logic is in handlers.

Benefits of CQRS in SGRH

Separation of Concerns

Read and write models are optimized independently. Commands focus on business rules, queries focus on performance.

Scalability

Read and write operations can be scaled independently. Queries can use read replicas, caching, etc.

Maintainability

Each use case is a separate class. Easy to find, test, and modify specific operations.

Testability

Handlers are easy to unit test. Mock repositories and verify behavior.

Flexibility

Can use different data models for reads (denormalized) and writes (normalized domain entities).

Clear Intent

Command/query names clearly express business operations: ConfirmarReservaCommand, not UpdateReservaStatusCommand.

Read vs Write Models

// Use rich domain entities
var cliente = await _clienteRepo.GetByIdAsync(id);
cliente.ActualizarDatos(nombre, apellido, email, telefono);
await _unitOfWork.SaveChangesAsync();
Characteristics:
  • Load full domain entities with behavior
  • Enforce business rules through domain methods
  • Use repositories and UnitOfWork
  • Transactional consistency

CQRS Best Practices

1

One Handler Per Operation

Each command/query should have exactly one handler. Don’t reuse handlers.
2

Commands Return Simple Results

Return IDs, success/failure, not complex DTOs. Use queries to fetch data after commands.
// Good ✅
Task<Result<int>> Handle(CrearClienteCommand)

// Bad ❌
Task<Result<ClienteDetalleResponse>> Handle(CrearClienteCommand)
3

Queries Never Modify State

Queries should be side-effect free. No Update(), Delete(), or SaveChanges().
4

Use Domain Entities in Commands

Commands should work with domain entities to leverage business logic encapsulation.
5

Use DTOs in Queries

Queries should project directly to DTOs for optimal performance.

Result Pattern

SGRH uses a Result<T> type to handle success/failure:
Result.cs
namespace SGRH.Application.Common.Results;

public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    private Result(bool isSuccess, T? value, string? error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }

    public static Result<T> Success(T value) 
        => new(true, value, null);

    public static Result<T> Failure(string error) 
        => new(false, default, error);
}
Usage:
// In handler
if (cliente is null)
    return Result<ClienteResponse>.Failure("Cliente no encontrado.");

return Result<ClienteResponse>.Success(response);

// In controller
var result = await _mediator.Send(query);
return result.IsSuccess ? Ok(result.Value) : NotFound(result.Error);

Advanced Patterns

Pipeline Behaviors

MediatR allows cross-cutting concerns via pipeline behaviors:
ValidationBehavior.cs
using FluentValidation;
using MediatR;

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

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

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(result => result.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

Logging Behavior

LoggingBehavior.cs
using MediatR;
using Microsoft.Extensions.Logging;

public class LoggingBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;
        
        _logger.LogInformation(
            "Handling {RequestName}", requestName);

        var response = await next();

        _logger.LogInformation(
            "Handled {RequestName}", requestName);

        return response;
    }
}

Clean Architecture

See how CQRS fits into the application layer

Domain-Driven Design

Learn about the domain entities used in commands

Build docs developers (and LLMs) love