
Previous Article: ASP.NET Core Future Trends 2025: MAUI, AI Integration, Blazor Hybrid & Cloud-Native Development (Part-35 of 40)
Table of Contents
Understanding the Problem
CQRS Fundamentals
MediatR Deep Dive
Basic Implementation
Advanced CQRS Patterns
Validation & Behavior
Performance Optimization
Event Sourcing Integration
Testing Strategies
Real-World E-Commerce Case Study
Microservices Integration
Production Best Practices
1. Understanding the Problem
The Traditional CRUD Challenges
Real-World Scenario: Imagine building an e-commerce platform where a single product page needs to:
Display product information
Show inventory status
Calculate discounts
Display customer reviews
Show related products
Track view count for analytics
// Traditional monolithic service approach
public class ProductService : IProductService
{
private readonly IProductRepository _productRepository;
private readonly IInventoryRepository _inventoryRepository;
private readonly IDiscountRepository _discountRepository;
private readonly IReviewRepository _reviewRepository;
private readonly IAnalyticsRepository _analyticsRepository;
public async Task<ProductDTO> GetProductAsync(int productId)
{
// Multiple database calls
var product = await _productRepository.GetByIdAsync(productId);
var inventory = await _inventoryRepository.GetByProductIdAsync(productId);
var discounts = await _discountRepository.GetActiveDiscountsAsync(productId);
var reviews = await _reviewRepository.GetByProductIdAsync(productId);
// Business logic scattered
var discountedPrice = CalculateDiscountedPrice(product.Price, discounts);
var stockStatus = CalculateStockStatus(inventory.Quantity);
var averageRating = CalculateAverageRating(reviews);
// Analytics tracking
await _analyticsRepository.TrackProductViewAsync(productId);
// Complex mapping
return new ProductDTO
{
Id = product.Id,
Name = product.Name,
Price = product.Price,
DiscountedPrice = discountedPrice,
StockStatus = stockStatus,
AverageRating = averageRating,
Reviews = reviews,
// ... more properties
};
}
// Similar complexity for write operations
public async Task UpdateProductAsync(ProductUpdateDTO update)
{
// Validation
if (update.Price < 0)
throw new ArgumentException("Price cannot be negative");
// Business rules
if (update.Price > 1000 && !await IsPremiumUserAsync(update.UpdatedBy))
throw new UnauthorizedAccessException("Only premium users can set high prices");
// Multiple updates
await _productRepository.UpdateAsync(update);
await _priceHistoryRepository.AddAsync(update);
await _auditRepository.LogUpdateAsync(update);
// Notifications
await _notificationService.NotifyPriceChangeAsync(update);
}
}
Problems with the Traditional Approach
Single Responsibility Violation: Services handle too many concerns
Performance Issues: Multiple database calls in a single operation
Complex Testing: Difficult to unit test individual behaviors
Tight Coupling: Changes affect multiple parts of the system
Scalability Challenges: Read and write operations compete for resources
2. CQRS Fundamentals
What is CQRS?
Command Query Responsibility Segregation is a pattern that separates read and write operations into different models.
// CQRS Core Concepts
public interface ICommand
{
// Marker interface for commands
}
public interface IQuery<TResponse>
{
// Marker interface for queries
}
public interface ICommandHandler<TCommand>
where TCommand : ICommand
{
Task Handle(TCommand command, CancellationToken cancellationToken);
}
public interface IQueryHandler<TQuery, TResponse>
where TQuery : IQuery<TResponse>
{
Task<TResponse> Handle(TQuery query, CancellationToken cancellationToken);
}
CQRS Architecture Patterns
// Level 1: Simple Separation
public class SimpleCQRS
{
// Commands - Write operations
public class CreateProductCommand : ICommand
{
public string Name { get; set; }
public decimal Price { get; set; }
public string Description { get; set; }
}
// Queries - Read operations
public class GetProductQuery : IQuery<ProductDTO>
{
public int ProductId { get; set; }
}
// Separate handlers
public class CreateProductCommandHandler : ICommandHandler<CreateProductCommand>
{
public async Task Handle(CreateProductCommand command, CancellationToken cancellationToken)
{
// Write logic only
var product = new Product(command.Name, command.Price, command.Description);
await _productRepository.AddAsync(product);
}
}
public class GetProductQueryHandler : IQueryHandler<GetProductQuery, ProductDTO>
{
public async Task<ProductDTO> Handle(GetProductQuery query, CancellationToken cancellationToken)
{
// Read logic only - optimized for reading
return await _productReadRepository.GetByIdAsync(query.ProductId);
}
}
}
When to Use CQRS
Perfect Scenarios :
High-traffic applications with different read/write patterns
Complex business logic requiring separation of concerns
Systems needing different data models for reads vs writes
Applications requiring event sourcing
Microservices architectures
Not Recommended For :
Simple CRUD applications
Low-traffic systems
When over-engineering would cause more harm than good
3. MediatR Deep Dive
MediatR Architecture
MediatR is a mediator pattern implementation for .NET that helps reduce coupling between components.
// MediatR Core Interfaces
public interface IMediator
{
Task<TResponse> Send<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default);
Task<object?> Send(object request, CancellationToken cancellationToken = default);
Task Publish(object notification, CancellationToken cancellationToken = default);
}
public interface IRequest<TResponse> { }
public interface IRequestHandler<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}
public interface INotification { }
public interface INotificationHandler<TNotification>
where TNotification : INotification
{
Task Handle(TNotification notification, CancellationToken cancellationToken);
}
Setting Up MediatR
// Program.cs - Configuration
using MediatR;
var builder = WebApplication.CreateBuilder(args);
// Add MediatR
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
// Add behaviors
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
// Add supporting services
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
var app = builder.Build();
MediatR Pipeline Behaviors
// 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)
{
var requestName = typeof(TRequest).Name;
_logger.LogInformation("Handling {RequestName} with {@Request}", requestName, request);
try
{
var response = await next();
_logger.LogInformation("Handled {RequestName} successfully", requestName);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling {RequestName}", requestName);
throw;
}
}
}
// Validation Behavior
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())
{
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();
}
}
4. Basic Implementation
Complete E-Commerce Example
Let's implement a complete e-commerce system using CQRS and MediatR.
Project Structure
ECommerce.CQRS/
├── src/
│ ├── ECommerce.Application/
│ │ ├── Commands/
│ │ ├── Queries/
│ │ ├── Behaviors/
│ │ └── Models/
│ ├── ECommerce.Domain/
│ ├── ECommerce.Infrastructure/
│ └── ECommerce.API/
├── tests/
└── docker-compose.yml
Domain Models
// ECommerce.Domain/Entities/Product.cs
public class Product : Entity
{
public string Name { get; private set; }
public string Description { get; private set; }
public decimal Price { get; private set; }
public int StockQuantity { get; private set; }
public bool IsActive { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
private Product() { } // For EF Core
public Product(string name, string description, decimal price, int stockQuantity)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
Price = price >= 0 ? price : throw new ArgumentException("Price cannot be negative");
StockQuantity = stockQuantity >= 0 ? stockQuantity :
throw new ArgumentException("Stock quantity cannot be negative");
IsActive = true;
CreatedAt = DateTime.UtcNow;
}
public void UpdatePrice(decimal newPrice)
{
if (newPrice < 0)
throw new ArgumentException("Price cannot be negative");
Price = newPrice;
UpdatedAt = DateTime.UtcNow;
}
public void UpdateStock(int quantity)
{
if (quantity < 0)
throw new ArgumentException("Stock quantity cannot be negative");
StockQuantity = quantity;
UpdatedAt = DateTime.UtcNow;
}
public void Deactivate()
{
IsActive = false;
UpdatedAt = DateTime.UtcNow;
}
}
// ECommerce.Domain/Common/Entity.cs
public abstract class Entity
{
public int Id { get; protected set; }
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void AddDomainEvent(IDomainEvent eventItem)
{
_domainEvents.Add(eventItem);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
// ECommerce.Domain/Common/IDomainEvent.cs
public interface IDomainEvent : INotification
{
DateTime OccurredOn { get; }
}
Commands Implementation
// ECommerce.Application/Commands/CreateProductCommand.cs
public class CreateProductCommand : IRequest<CreateProductResponse>
{
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public CreateProductCommand(string name, string description, decimal price, int stockQuantity)
{
Name = name;
Description = description;
Price = price;
StockQuantity = stockQuantity;
}
}
public class CreateProductResponse
{
public int ProductId { get; set; }
public string Name { get; set; }
public DateTime CreatedAt { get; set; }
public CreateProductResponse(int productId, string name, DateTime createdAt)
{
ProductId = productId;
Name = name;
CreatedAt = createdAt;
}
}
// ECommerce.Application/Commands/CreateProductCommandValidator.cs
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Product name is required")
.MaximumLength(100).WithMessage("Product name cannot exceed 100 characters");
RuleFor(x => x.Description)
.NotEmpty().WithMessage("Product description is required")
.MaximumLength(500).WithMessage("Description cannot exceed 500 characters");
RuleFor(x => x.Price)
.GreaterThanOrEqualTo(0).WithMessage("Price cannot be negative")
.LessThanOrEqualTo(1000000).WithMessage("Price seems too high");
RuleFor(x => x.StockQuantity)
.GreaterThanOrEqualTo(0).WithMessage("Stock quantity cannot be negative");
}
}
// ECommerce.Application/Commands/CreateProductCommandHandler.cs
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, CreateProductResponse>
{
private readonly IProductRepository _productRepository;
private readonly ILogger<CreateProductCommandHandler> _logger;
public CreateProductCommandHandler(
IProductRepository productRepository,
ILogger<CreateProductCommandHandler> logger)
{
_productRepository = productRepository;
_logger = logger;
}
public async Task<CreateProductResponse> Handle(
CreateProductCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Creating product: {ProductName}", request.Name);
// Business logic
var product = new Product(
request.Name,
request.Description,
request.Price,
request.StockQuantity);
// Persist
await _productRepository.AddAsync(product);
await _productRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Product created with ID: {ProductId}", product.Id);
return new CreateProductResponse(product.Id, product.Name, product.CreatedAt);
}
}
Queries Implementation
// ECommerce.Application/Queries/GetProductQuery.cs
public class GetProductQuery : IRequest<ProductDTO>
{
public int ProductId { get; set; }
public GetProductQuery(int productId)
{
ProductId = productId;
}
}
public class ProductDTO
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public string StockStatus { get; set; }
}
// ECommerce.Application/Queries/GetProductQueryHandler.cs
public class GetProductQueryHandler : IRequestHandler<GetProductQuery, ProductDTO>
{
private readonly IProductReadRepository _productReadRepository;
private readonly ILogger<GetProductQueryHandler> _logger;
public GetProductQueryHandler(
IProductReadRepository productReadRepository,
ILogger<GetProductQueryHandler> logger)
{
_productReadRepository = productReadRepository;
_logger = logger;
}
public async Task<ProductDTO> Handle(
GetProductQuery request,
CancellationToken cancellationToken)
{
_logger.LogDebug("Retrieving product with ID: {ProductId}", request.ProductId);
var product = await _productReadRepository.GetByIdAsync(request.ProductId);
if (product == null)
{
_logger.LogWarning("Product with ID {ProductId} not found", request.ProductId);
throw new ProductNotFoundException(request.ProductId);
}
// Map to DTO and calculate derived properties
return new ProductDTO
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
StockQuantity = product.StockQuantity,
IsActive = product.IsActive,
CreatedAt = product.CreatedAt,
UpdatedAt = product.UpdatedAt,
StockStatus = CalculateStockStatus(product.StockQuantity)
};
}
private string CalculateStockStatus(int stockQuantity)
{
return stockQuantity switch
{
0 => "Out of Stock",
< 10 => "Low Stock",
_ => "In Stock"
};
}
}
// Custom exception
public class ProductNotFoundException : Exception
{
public ProductNotFoundException(int productId)
: base($"Product with ID {productId} was not found.")
{
}
public ProductNotFoundException(string message) : base(message)
{
}
}
API Controllers
csharp
// ECommerce.API/Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<ProductsController> _logger;
public ProductsController(IMediator mediator, ILogger<ProductsController> logger)
{
_mediator = mediator;
_logger = logger;
}
[HttpPost]
[ProducesResponseType(typeof(CreateProductResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<CreateProductResponse>> CreateProduct(
[FromBody] CreateProductCommand command)
{
_logger.LogInformation("Creating new product: {ProductName}", command.Name);
var result = await _mediator.Send(command);
return CreatedAtAction(
nameof(GetProduct),
new { id = result.ProductId },
result);
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(ProductDTO), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDTO>> GetProduct(int id)
{
var query = new GetProductQuery(id);
var product = await _mediator.Send(query);
return Ok(product);
}
[HttpGet]
[ProducesResponseType(typeof(PaginatedList<ProductSummaryDTO>), StatusCodes.Status200OK)]
public async Task<ActionResult<PaginatedList<ProductSummaryDTO>>> GetProducts(
[FromQuery] GetProductsQuery query)
{
var result = await _mediator.Send(query);
return Ok(result);
}
}
5. Advanced CQRS Patterns
Domain Events with MediatR
csharp
// ECommerce.Domain/Events/ProductCreatedEvent.cs
public class ProductCreatedEvent : IDomainEvent
{
public int ProductId { get; }
public string ProductName { get; }
public decimal Price { get; }
public DateTime OccurredOn { get; }
public ProductCreatedEvent(int productId, string productName, decimal price)
{
ProductId = productId;
ProductName = productName;
Price = price;
OccurredOn = DateTime.UtcNow;
}
}
// ECommerce.Application/DomainEventHandlers/ProductCreatedEventHandler.cs
public class ProductCreatedEventHandler : INotificationHandler<ProductCreatedEvent>
{
private readonly ILogger<ProductCreatedEventHandler> _logger;
private readonly IEmailService _emailService;
private readonly IAnalyticsRepository _analyticsRepository;
public ProductCreatedEventHandler(
ILogger<ProductCreatedEventHandler> logger,
IEmailService emailService,
IAnalyticsRepository analyticsRepository)
{
_logger = logger;
_emailService = emailService;
_analyticsRepository = analyticsRepository;
}
public async Task Handle(ProductCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Handling product created event for product: {ProductName}",
notification.ProductName);
// Multiple handlers can process the same event
await Task.WhenAll(
SendAdminNotification(notification, cancellationToken),
TrackAnalytics(notification, cancellationToken),
UpdateSearchIndex(notification, cancellationToken)
);
}
private async Task SendAdminNotification(ProductCreatedEvent notification, CancellationToken cancellationToken)
{
try
{
await _emailService.SendProductCreatedNotificationAsync(
notification.ProductId,
notification.ProductName);
_logger.LogInformation("Admin notification sent for product {ProductId}", notification.ProductId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send admin notification for product {ProductId}", notification.ProductId);
// Don't throw - other handlers should still execute
}
}
private async Task TrackAnalytics(ProductCreatedEvent notification, CancellationToken cancellationToken)
{
await _analyticsRepository.TrackProductCreationAsync(
notification.ProductId,
notification.Price);
}
private async Task UpdateSearchIndex(ProductCreatedEvent notification, CancellationToken cancellationToken)
{
// Update search index asynchronously
await Task.Delay(100, cancellationToken); // Simulate work
_logger.LogInformation("Search index updated for product {ProductId}", notification.ProductId);
}
}
// Updated command handler to publish domain events
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, CreateProductResponse>
{
private readonly IProductRepository _productRepository;
private readonly ILogger<CreateProductCommandHandler> _logger;
private readonly IPublisher _publisher;
public CreateProductCommandHandler(
IProductRepository productRepository,
ILogger<CreateProductCommandHandler> logger,
IPublisher publisher)
{
_productRepository = productRepository;
_logger = logger;
_publisher = publisher;
}
public async Task<CreateProductResponse> Handle(
CreateProductCommand request,
CancellationToken cancellationToken)
{
var product = new Product(
request.Name,
request.Description,
request.Price,
request.StockQuantity);
await _productRepository.AddAsync(product);
await _productRepository.SaveChangesAsync(cancellationToken);
// Publish domain events
foreach (var domainEvent in product.DomainEvents)
{
await _publisher.Publish(domainEvent, cancellationToken);
}
product.ClearDomainEvents();
return new CreateProductResponse(product.Id, product.Name, product.CreatedAt);
}
}
Separate Read/Write Models
// Write Model (Domain)
public class Product : Entity
{
// Rich behavior and business rules
public void ApplyDiscount(decimal percentage)
{
if (percentage < 0 || percentage > 100)
throw new ArgumentException("Discount percentage must be between 0 and 100");
Price = Price * (100 - percentage) / 100;
AddDomainEvent(new ProductPriceChangedEvent(Id, Price));
}
}
// Read Model (DTOs optimized for reading)
public class ProductDetailView
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public decimal DiscountedPrice { get; set; }
public string CategoryName { get; set; }
public double AverageRating { get; set; }
public int ReviewCount { get; set; }
public string[] ImageUrls { get; set; }
public Dictionary<string, string> Specifications { get; set; }
public ProductVariantDTO[] Variants { get; set; }
}
// Separate read repository
public interface IProductReadRepository
{
Task<ProductDetailView> GetProductDetailAsync(int productId);
Task<PaginatedList<ProductSummaryView>> GetProductsAsync(ProductQuery query);
Task<List<ProductSearchResult>> SearchProductsAsync(string searchTerm);
}
// Implementation with Dapper for performance
public class ProductReadRepository : IProductReadRepository
{
private readonly IDbConnection _connection;
public ProductReadRepository(IDbConnection connection)
{
_connection = connection;
}
public async Task<ProductDetailView> GetProductDetailAsync(int productId)
{
const string sql = @"
SELECT
p.Id, p.Name, p.Description, p.Price,
c.Name as CategoryName,
COALESCE(AVG(r.Rating), 0) as AverageRating,
COUNT(r.Id) as ReviewCount,
-- Complex joins for optimized reading
FROM Products p
LEFT JOIN Categories c ON p.CategoryId = c.Id
LEFT JOIN Reviews r ON p.Id = r.ProductId
LEFT JOIN ProductImages pi ON p.Id = pi.ProductId
WHERE p.Id = @ProductId AND p.IsActive = 1
GROUP BY p.Id, p.Name, p.Description, p.Price, c.Name";
return await _connection.QuerySingleOrDefaultAsync<ProductDetailView>(sql, new { ProductId = productId });
}
}
6. Validation & Behavior
Advanced Validation Pipeline
// FluentValidation validators
public class UpdateProductCommandValidator : AbstractValidator<UpdateProductCommand>
{
public UpdateProductCommandValidator()
{
RuleFor(x => x.ProductId)
.GreaterThan(0).WithMessage("Product ID must be positive");
RuleFor(x => x.Name)
.NotEmpty().When(x => x.Name != null)
.MaximumLength(100).WithMessage("Name cannot exceed 100 characters");
RuleFor(x => x.Price)
.GreaterThanOrEqualTo(0).When(x => x.Price.HasValue)
.WithMessage("Price cannot be negative");
RuleFor(x => x.StockQuantity)
.GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue)
.WithMessage("Stock quantity cannot be negative");
// Cross-property validation
RuleFor(x => x)
.Must(x => !(x.Price.HasValue && x.Price.Value == 0 && x.StockQuantity > 0))
.WithMessage("Products with stock cannot have zero price")
.OverridePropertyName("Price");
}
}
// Custom validation behavior
public class CustomValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
private readonly ILogger<CustomValidationBehavior<TRequest, TResponse>> _logger;
public CustomValidationBehavior(
IEnumerable<IValidator<TRequest>> validators,
ILogger<CustomValidationBehavior<TRequest, TResponse>> logger)
{
_validators = validators;
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
if (_validators.Any())
{
_logger.LogDebug("Validating request {RequestName}", requestName);
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)
{
_logger.LogWarning(
"Validation failed for {RequestName} with {ErrorCount} errors",
requestName, failures.Count);
throw new CustomValidationException(failures);
}
}
return await next();
}
}
// Custom validation exception
public class CustomValidationException : Exception
{
public IDictionary<string, string[]> Errors { get; }
public CustomValidationException(IEnumerable<ValidationFailure> failures)
: base("Validation failed")
{
Errors = failures
.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
.ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray());
}
}

Join the conversation! Your thoughts help the community grow.