Introduction
As a senior .NET developer, Iβve seen numerous architectural patterns come and go. However, one pattern that has stood the test of time and continues to gain popularity is CQRS (Command Query Responsibility Segregation). When combined with the MediatR library and implemented in .NET 8βs Minimal API, it creates a powerful, efficient, and scalable architecture for modern applications.
In this blog post, weβll explore CQRS and MediatR in the context of .NET 8, using a Minimal API approach with an Employee record as our primary example. Weβll cover the basics, dive into implementation details, and discuss the benefits and potential drawbacks of this architecture.
Table of contents
Open Table of contents
-
-
- Features/Employees/: Contains all Employee-related features.
- Commands/: Holds command-related files for creating, updating, and deleting Employees.
- Queries/: Contains query-related files for retrieving Employees.
- DTOs/: Stores Data Transfer Objects for Employees.
- Infrastructure/: Contains cross-cutting concerns.
- Behaviors/: Holds MediatR pipeline behaviors like logging and validation.
- Middleware/: Contains custom middleware like the global exception handler.
- Models/: Stores the domain model for Employee.
- Validators/: Contains FluentValidation validators for Employee commands.
-
Understanding CQRS
CQRS, or Command Query Responsibility Segregation, is an architectural pattern that separates read and write operations for a data store. The fundamental idea behind CQRS is to split an applicationβs operations into two categories:
- Commands: These are operations that change the state of the system (Create, Update, Delete).
- Queries: These are operations that read the state of the system without modifying it.
By separating these concerns, we can optimize each path independently, leading to better performance, scalability, and maintainability.
Benefits of CQRS
- Scalability: Read and write operations can be scaled independently.
- Performance: Queries can be optimized without affecting the command side.
- Flexibility: Different data models can be used for reading and writing.
- Simplicity: Each model (read or write) can be simpler as it only deals with one aspect of the system.
Potential Drawbacks
- Increased Complexity: Maintaining two models can add complexity to the system.
- Eventual Consistency: If separate databases are used for reads and writes, there may be a delay in data consistency.
Introducing MediatR
MediatR is a simple, unambitious mediator implementation in .NET. It supports request/response, commands, queries, notifications, and events. MediatR simplifies building CQRS applications by providing a clean way to separate commands and queries.
Key Concepts in MediatR
- Requests and Handlers: A request is a message sent by the client, and a handler processes that request.
- Mediator: The central point that dispatches requests to the appropriate handlers.
- Pipelines: Allow for pre and post-processing of requests.
Setting Up the Project
Letβs start by setting up a new .NET 8 Minimal API project. Weβll use an Employee record as our primary domain model.
dotnet new web -n CqrsMediatRDemo
cd CqrsMediatRDemo
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
Project Folder Structure
This folder structure organizes the CQRS and MediatR implementation for a Product entity. Hereβs a brief explanation of each folder:
Features/Employees/: Contains all Employee-related features.
Commands/: Holds command-related files for creating, updating, and deleting Employees.
Queries/: Contains query-related files for retrieving Employees.
DTOs/: Stores Data Transfer Objects for Employees.
Infrastructure/: Contains cross-cutting concerns.
Behaviors/: Holds MediatR pipeline behaviors like logging and validation.
Middleware/: Contains custom middleware like the global exception handler.
Models/: Stores the domain model for Employee.
Validators/: Contains FluentValidation validators for Employee commands.
This structure helps maintain a clean separation of concerns and makes it easy to navigate and maintain the codebase as it grows.
CqrsMediatRDemo/
βββ Features/
β βββ Employees/
β βββ Commands/
β β βββ CreateEmployee/
β β β βββ CreateEmployeeCommand.cs
β β β βββ CreateEmployeeCommandHandler.cs
β β βββ UpdateEmployee/
β β β βββ UpdateEmployeeCommand.cs
β β β βββ UpdateEmployeeCommandHandler.cs
β β βββ DeleteEmployee/
β β βββ DeleteEmployeeCommand.cs
β β βββ DeleteEmployeeCommandHandler.cs
β β
β βββ Queries/
β β βββ GetEmployeeById/
β β β βββ GetEmployeeByIdQuery.cs
β β β βββ GetEmployeeByIdQueryHandler.cs
β β βββ GetAllEmployees/
β β βββ GetAllEmployeesQuery.cs
β β βββ GetAllEmployeesQueryHandler.cs
β β
β βββ DTOs/
β βββ EmployeeDto.cs
β βββ CreateEmployeeDto.cs
β
βββ Infrastructure/
β βββ Behaviors/
β β βββ LoggingBehavior.cs
β β βββ ValidationBehavior.cs
β β
β βββ Middleware/
β βββ GlobalExceptionHandlerMiddleware.cs
β
βββ Models/
β βββ Employee.cs
β
βββ Validators/
β βββ CreateEmployeeCommandValidator.cs
β βββ UpdateEmployeeCommandValidator.cs
β
βββ Program.cs
βββ appsettings.json
Defining the Employee Model
First, letβs define our Employee record:
public record Employee
{
public int Id { get; init; }
public string FirstName { get; init; }
public string LastName { get; init; }
public string Email { get; init; }
public DateTime DateOfBirth { get; init; }
public string Department { get; init; }
}
Implementing CQRS with MediatR
Now, letβs implement CQRS using MediatR. Weβll create commands and queries for our Employee record.
Commands
Commands are responsible for changing the state of our system. Letβs create a command to add a new employee.
public record CreateEmployeeCommand : IRequest<int>
{
public string FirstName { get; init; }
public string LastName { get; init; }
public string Email { get; init; }
public DateTime DateOfBirth { get; init; }
public string Department { get; init; }
}
public class CreateEmployeeCommandHandler : IRequestHandler<CreateEmployeeCommand, int>
{
private static List<Employee> _employees = new();
public Task<int> Handle(CreateEmployeeCommand request, CancellationToken cancellationToken)
{
var employee = new Employee
{
Id = _employees.Count + 1,
FirstName = request.FirstName,
LastName = request.LastName,
Email = request.Email,
DateOfBirth = request.DateOfBirth,
Department = request.Department
};
_employees.Add(employee);
return Task.FromResult(employee.Id);
}
}
Queries
Queries are responsible for reading data from our system. Letβs create a query to get an employee by ID.
public record GetEmployeeByIdQuery(int Id) : IRequest<Employee>;
public class GetEmployeeByIdQueryHandler : IRequestHandler<GetEmployeeByIdQuery, Employee>
{
private static List<Employee> _employees = new();
public Task<Employee> Handle(GetEmployeeByIdQuery request, CancellationToken cancellationToken)
{
var employee = _employees.FirstOrDefault(e => e.Id == request.Id);
return Task.FromResult(employee);
}
}
Configuring MediatR in Program.cs
Now, letβs configure MediatR in our Program.cs
file:
using MediatR;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediatR(typeof(Program));
var app = builder.Build();
// ... rest of your Program.cs code
Implementing Minimal API Endpoints
With MediatR configured, we can now implement our Minimal API endpoints:
app.MapPost("/employees", async (IMediator mediator, CreateEmployeeCommand command) =>
{
var employeeId = await mediator.Send(command);
return Results.Created($"/employees/{employeeId}", employeeId);
});
app.MapGet("/employees/{id}", async (IMediator mediator, int id) =>
{
var employee = await mediator.Send(new GetEmployeeByIdQuery(id));
return employee is not null ? Results.Ok(employee) : Results.NotFound();
});
app.Run();
Expanding the CQRS Implementation
Letβs expand our CQRS implementation to include more operations on the Employee record.
Update Employee Command
public record UpdateEmployeeCommand : IRequest<bool>
{
public int Id { get; init; }
public string FirstName { get; init; }
public string LastName { get; init; }
public string Email { get; init; }
public DateTime DateOfBirth { get; init; }
public string Department { get; init; }
}
public class UpdateEmployeeCommandHandler : IRequestHandler<UpdateEmployeeCommand, bool>
{
private static List<Employee> _employees = new();
public Task<bool> Handle(UpdateEmployeeCommand request, CancellationToken cancellationToken)
{
var employee = _employees.FirstOrDefault(e => e.Id == request.Id);
if (employee == null) return Task.FromResult(false);
var updatedEmployee = employee with
{
FirstName = request.FirstName,
LastName = request.LastName,
Email = request.Email,
DateOfBirth = request.DateOfBirth,
Department = request.Department
};
_employees[_employees.IndexOf(employee)] = updatedEmployee;
return Task.FromResult(true);
}
}
Delete Employee Command
public record DeleteEmployeeCommand(int Id) : IRequest<bool>;
public class DeleteEmployeeCommandHandler : IRequestHandler<DeleteEmployeeCommand, bool>
{
private static List<Employee> _employees = new();
public Task<bool> Handle(DeleteEmployeeCommand request, CancellationToken cancellationToken)
{
var employee = _employees.FirstOrDefault(e => e.Id == request.Id);
if (employee == null) return Task.FromResult(false);
_employees.Remove(employee);
return Task.FromResult(true);
}
}
Get All Employees Query
public record GetAllEmployeesQuery : IRequest<IEnumerable<Employee>>;
public class GetAllEmployeesQueryHandler : IRequestHandler<GetAllEmployeesQuery, IEnumerable<Employee>>
{
private static List<Employee> _employees = new();
public Task<IEnumerable<Employee>> Handle(GetAllEmployeesQuery request, CancellationToken cancellationToken)
{
return Task.FromResult(_employees.AsEnumerable());
}
}
Updating Minimal API Endpoints
Now, letβs update our Minimal API endpoints to include these new operations:
app.MapPost("/employees", async (IMediator mediator, CreateEmployeeCommand command) =>
{
var employeeId = await mediator.Send(command);
return Results.Created($"/employees/{employeeId}", employeeId);
});
app.MapGet("/employees/{id}", async (IMediator mediator, int id) =>
{
var employee = await mediator.Send(new GetEmployeeByIdQuery(id));
return employee is not null ? Results.Ok(employee) : Results.NotFound();
});
app.MapGet("/employees", async (IMediator mediator) =>
{
var employees = await mediator.Send(new GetAllEmployeesQuery());
return Results.Ok(employees);
});
app.MapPut("/employees/{id}", async (IMediator mediator, int id, UpdateEmployeeCommand command) =>
{
if (id != command.Id) return Results.BadRequest();
var result = await mediator.Send(command);
return result ? Results.NoContent() : Results.NotFound();
});
app.MapDelete("/employees/{id}", async (IMediator mediator, int id) =>
{
var result = await mediator.Send(new DeleteEmployeeCommand(id));
return result ? Results.NoContent() : Results.NotFound();
});
app.Run();
Adding Validation with FluentValidation
To ensure data integrity, we can add validation to our commands and queries using FluentValidation. First, install the FluentValidation package:
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
Now, letβs create validators for our commands:
public class CreateEmployeeCommandValidator : AbstractValidator<CreateEmployeeCommand>
{
public CreateEmployeeCommandValidator()
{
RuleFor(x => x.FirstName).NotEmpty().MaximumLength(50);
RuleFor(x => x.LastName).NotEmpty().MaximumLength(50);
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.DateOfBirth).NotEmpty().LessThan(DateTime.Now.AddYears(-18));
RuleFor(x => x.Department).NotEmpty().MaximumLength(50);
}
}
public class UpdateEmployeeCommandValidator : AbstractValidator<UpdateEmployeeCommand>
{
public UpdateEmployeeCommandValidator()
{
RuleFor(x => x.Id).GreaterThan(0);
RuleFor(x => x.FirstName).NotEmpty().MaximumLength(50);
RuleFor(x => x.LastName).NotEmpty().MaximumLength(50);
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.DateOfBirth).NotEmpty().LessThan(DateTime.Now.AddYears(-18));
RuleFor(x => x.Department).NotEmpty().MaximumLength(50);
}
}
To use these validators, we need to create a validation behavior that will be executed before the actual handler:
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 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();
}
}
Finally, we need to register our validators and the validation behavior in Program.cs
:
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
Implementing Logging with MediatR Pipeline
Logging is crucial for monitoring and debugging our application. We can implement logging using MediatRβs pipeline behavior. First, letβs create a logging behavior:
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)
{
_logger.LogInformation($"Handling {typeof(TRequest).Name}");
var response = await next();
_logger.LogInformation($"Handled {typeof(TResponse).Name}");
return response;
}
}
Now, register this behavior in Program.cs
:
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
Error Handling
To handle errors gracefully, we can create a global exception handler middleware:
public class GlobalExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionHandlerMiddleware> _logger;
public GlobalExceptionHandlerMiddleware(RequestDelegate next, ILogger<GlobalExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception has occurred.");
await HandleExceptionAsync(context, ex);
}
}
private static Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = exception switch
{
ValidationException => StatusCodes.Status400BadRequest,
KeyNotFoundException => StatusCodes.Status404NotFound,
_ => StatusCodes.Status500InternalServerError
};
return context.Response.WriteAsJsonAsync(new
{
context.Response.StatusCode,
Message = exception.Message
});
}
}
Register this middleware in Program.cs
:
app.UseMiddleware<GlobalExceptionHandlerMiddleware>();
Performance Considerations
When implementing CQRS with MediatR, itβs important to consider performance. Here are some tips:
- Use async/await consistently: Ensure all your handlers use async/await for better scalability.
- Implement caching: For frequently accessed data, implement caching to reduce database load.
- **Optimize database
Summary
The blog provides a comprehensive guide to implementing CQRS and MediatR in a .NET 8 Minimal API application, covering essential aspects such as setup, implementation, validation, logging, error handling, and project organization. It serves as a practical introduction for developers looking to adopt these patterns in their .NET projects.