-
Notifications
You must be signed in to change notification settings - Fork 0
Command Module
The Command module in Arcanic Mediator provides a powerful and flexible way to handle write operations in your application. Commands represent actions that change state and are a core component of the Command Query Responsibility Segregation (CQRS) pattern.
- Overview
- Installation
- Basic Usage
- Command Types
- Command Handlers
- Pre and Post Handlers
- Command Pipeline Behaviors
- Registration and Configuration
- Best Practices
- Testing
Commands in Arcanic Mediator are messages that represent write operations - actions that modify the state of your application. They follow the command pattern and provide several benefits:
- Encapsulation - Business logic is contained within command handlers
- Separation of Concerns - Controllers are kept thin and focused
- Testability - Commands and handlers can be easily unit tested
- Flexibility - Cross-cutting concerns are handled through pipeline behaviors
- Consistency - Standardized approach to handling business operations
- Command - A message that represents an action to be performed
- Command Handler - The class responsible for executing the command
- Pre-Handler - Executes before the main handler (validation, preparation)
- Post-Handler - Executes after the main handler (notifications, cleanup)
- Pipeline Behavior - Cross-cutting concerns that wrap command execution
Install the Command module package:
dotnet add package Arcanic.Mediator.CommandFor abstractions only (useful in domain/application layers):
dotnet add package Arcanic.Mediator.Command.AbstractionsCommands can be defined with or without return values:
using Arcanic.Mediator.Command.Abstractions;
// Command without return value
public class CreateUserCommand : ICommand
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime DateOfBirth { get; set; }
}
// Command with return value
public class CreateProductCommand : ICommand<int>
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int CategoryId { get; set; }
}using Arcanic.Mediator.Command.Abstractions.Handler;
// Handler without return value
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand>
{
private readonly IUserRepository _userRepository;
private readonly ILogger<CreateUserCommandHandler> _logger;
public CreateUserCommandHandler(IUserRepository userRepository, ILogger<CreateUserCommandHandler> logger)
{
_userRepository = userRepository;
_logger = logger;
}
public async Task HandleAsync(CreateUserCommand request, CancellationToken cancellationToken = default)
{
var user = new User
{
FirstName = request.FirstName,
LastName = request.LastName,
Email = request.Email,
DateOfBirth = request.DateOfBirth,
CreatedAt = DateTime.UtcNow
};
await _userRepository.AddAsync(user, cancellationToken);
_logger.LogInformation("User created: {Email}", request.Email);
}
}
// Handler with return value
public class CreateProductCommandHandler : ICommandHandler<CreateProductCommand, int>
{
private readonly IProductRepository _productRepository;
private readonly IPublisher _publisher;
public CreateProductCommandHandler(IProductRepository productRepository, IPublisher publisher)
{
_productRepository = productRepository;
_publisher = publisher;
}
public async Task<int> HandleAsync(CreateProductCommand request, CancellationToken cancellationToken = default)
{
var product = new Product
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
CategoryId = request.CategoryId,
CreatedAt = DateTime.UtcNow
};
await _productRepository.AddAsync(product, cancellationToken);
// Publish domain event
await _publisher.PublishAsync(new ProductCreatedEvent
{
ProductId = product.Id,
Name = product.Name,
Price = product.Price
}, cancellationToken);
return product.Id;
}
}using Arcanic.Mediator;
using Arcanic.Mediator.Command;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddArcanicMediator()
.AddCommands(Assembly.GetExecutingAssembly());
var app = builder.Build();using Microsoft.AspNetCore.Mvc;
using Arcanic.Mediator.Request.Abstractions;
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IMediator _mediator;
public UsersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> CreateUser(CreateUserCommand command)
{
await _mediator.SendAsync(command);
return Ok();
}
}
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<ActionResult<int>> CreateProduct(CreateProductCommand command)
{
var productId = await _mediator.SendAsync(command);
return CreatedAtAction(nameof(GetProduct), new { id = productId }, productId);
}
}Use for operations where you only need to know if the operation succeeded:
public class UpdateUserProfileCommand : ICommand
{
public int UserId { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Bio { get; set; } = string.Empty;
}
public class DeleteProductCommand : ICommand
{
public int ProductId { get; set; }
}
public class SendEmailCommand : ICommand
{
public string To { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
}Use when you need to return data after the operation:
public class RegisterUserCommand : ICommand<UserRegistrationResult>
{
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
}
public class ProcessOrderCommand : ICommand<OrderResult>
{
public int CustomerId { get; set; }
public List<OrderItem> Items { get; set; } = new();
public string ShippingAddress { get; set; } = string.Empty;
}
public class UploadFileCommand : ICommand<FileUploadResult>
{
public string FileName { get; set; } = string.Empty;
public byte[] Content { get; set; } = Array.Empty<byte>();
public string ContentType { get; set; } = string.Empty;
}public class UpdateUserProfileCommandHandler : ICommandHandler<UpdateUserProfileCommand>
{
private readonly IUserRepository _userRepository;
public UpdateUserProfileCommandHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task HandleAsync(UpdateUserProfileCommand request, CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
throw new NotFoundException($"User with ID {request.UserId} not found");
user.FirstName = request.FirstName;
user.LastName = request.LastName;
user.Bio = request.Bio;
user.UpdatedAt = DateTime.UtcNow;
await _userRepository.UpdateAsync(user, cancellationToken);
}
}public class ProcessOrderCommandHandler : ICommandHandler<ProcessOrderCommand, OrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly IProductRepository _productRepository;
private readonly IInventoryService _inventoryService;
private readonly IPaymentService _paymentService;
private readonly IPublisher _publisher;
private readonly ILogger<ProcessOrderCommandHandler> _logger;
public ProcessOrderCommandHandler(
IOrderRepository orderRepository,
IProductRepository productRepository,
IInventoryService inventoryService,
IPaymentService paymentService,
IPublisher publisher,
ILogger<ProcessOrderCommandHandler> logger)
{
_orderRepository = orderRepository;
_productRepository = productRepository;
_inventoryService = inventoryService;
_paymentService = paymentService;
_publisher = publisher;
_logger = logger;
}
public async Task<OrderResult> HandleAsync(ProcessOrderCommand request, CancellationToken cancellationToken = default)
{
// Validate inventory
var products = await _productRepository.GetByIdsAsync(
request.Items.Select(i => i.ProductId).ToList(),
cancellationToken);
foreach (var item in request.Items)
{
var product = products.FirstOrDefault(p => p.Id == item.ProductId);
if (product == null)
throw new ProductNotFoundException($"Product {item.ProductId} not found");
var available = await _inventoryService.GetAvailableQuantityAsync(item.ProductId, cancellationToken);
if (available < item.Quantity)
throw new InsufficientInventoryException($"Not enough inventory for product {item.ProductId}");
}
// Reserve inventory
foreach (var item in request.Items)
{
await _inventoryService.ReserveAsync(item.ProductId, item.Quantity, cancellationToken);
}
try
{
// Calculate totals
var subtotal = request.Items.Sum(i =>
products.First(p => p.Id == i.ProductId).Price * i.Quantity);
var tax = subtotal * 0.1m; // 10% tax
var total = subtotal + tax;
// Create order
var order = new Order
{
CustomerId = request.CustomerId,
Items = request.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = products.First(p => p.Id == i.ProductId).Price
}).ToList(),
ShippingAddress = request.ShippingAddress,
Subtotal = subtotal,
Tax = tax,
Total = total,
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow
};
await _orderRepository.AddAsync(order, cancellationToken);
// Process payment
var paymentResult = await _paymentService.ProcessPaymentAsync(
request.CustomerId, total, cancellationToken);
if (!paymentResult.IsSuccessful)
{
order.Status = OrderStatus.PaymentFailed;
await _orderRepository.UpdateAsync(order, cancellationToken);
throw new PaymentFailedException(paymentResult.ErrorMessage);
}
// Confirm inventory reservation
foreach (var item in request.Items)
{
await _inventoryService.ConfirmReservationAsync(item.ProductId, item.Quantity, cancellationToken);
}
order.Status = OrderStatus.Confirmed;
order.PaymentId = paymentResult.PaymentId;
await _orderRepository.UpdateAsync(order, cancellationToken);
// Publish events
await _publisher.PublishAsync(new OrderCreatedEvent
{
OrderId = order.Id,
CustomerId = order.CustomerId,
Total = order.Total
}, cancellationToken);
_logger.LogInformation("Order {OrderId} processed successfully for customer {CustomerId}",
order.Id, request.CustomerId);
return new OrderResult
{
OrderId = order.Id,
Total = order.Total,
Status = order.Status
};
}
catch
{
// Release reserved inventory on failure
foreach (var item in request.Items)
{
await _inventoryService.ReleaseReservationAsync(item.ProductId, item.Quantity, cancellationToken);
}
throw;
}
}
}Pre and post handlers allow you to execute logic before and after the main command handler.
using Arcanic.Mediator.Command.Abstractions.Handler;
// Validation pre-handler
public class CreateProductCommandValidationPreHandler : ICommandPreHandler<CreateProductCommand>
{
public async Task HandleAsync(CreateProductCommand request, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.Name))
throw new ValidationException("Product name is required");
if (request.Price <= 0)
throw new ValidationException("Product price must be greater than zero");
if (request.CategoryId <= 0)
throw new ValidationException("Valid category ID is required");
await Task.CompletedTask;
}
}
// Authorization pre-handler
public class CreateProductCommandAuthorizationPreHandler : ICommandPreHandler<CreateProductCommand>
{
private readonly ICurrentUser _currentUser;
private readonly IAuthorizationService _authorizationService;
public CreateProductCommandAuthorizationPreHandler(ICurrentUser currentUser, IAuthorizationService authorizationService)
{
_currentUser = currentUser;
_authorizationService = authorizationService;
}
public async Task HandleAsync(CreateProductCommand request, CancellationToken cancellationToken = default)
{
var authResult = await _authorizationService.AuthorizeAsync(
_currentUser.User, "CreateProduct");
if (!authResult.Succeeded)
{
throw new UnauthorizedAccessException("User is not authorized to create products");
}
}
}
// Business rule validation pre-handler
public class CreateProductCommandBusinessRulePreHandler : ICommandPreHandler<CreateProductCommand>
{
private readonly IProductRepository _productRepository;
private readonly ICategoryRepository _categoryRepository;
public CreateProductCommandBusinessRulePreHandler(
IProductRepository productRepository,
ICategoryRepository categoryRepository)
{
_productRepository = productRepository;
_categoryRepository = categoryRepository;
}
public async Task HandleAsync(CreateProductCommand request, CancellationToken cancellationToken = default)
{
// Check if product name already exists
var existingProduct = await _productRepository.GetByNameAsync(request.Name, cancellationToken);
if (existingProduct != null)
throw new BusinessRuleException("Product name already exists");
// Check if category exists and is active
var category = await _categoryRepository.GetByIdAsync(request.CategoryId, cancellationToken);
if (category == null || !category.IsActive)
throw new BusinessRuleException("Invalid or inactive category");
}
}// Notification post-handler
public class CreateProductCommandNotificationPostHandler : ICommandPostHandler<CreateProductCommand>
{
private readonly INotificationService _notificationService;
private readonly ILogger<CreateProductCommandNotificationPostHandler> _logger;
public CreateProductCommandNotificationPostHandler(
INotificationService notificationService,
ILogger<CreateProductCommandNotificationPostHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task HandleAsync(CreateProductCommand request, CancellationToken cancellationToken = default)
{
await _notificationService.SendNotificationAsync(
"admin@company.com",
"New Product Created",
$"A new product '{request.Name}' has been created with price ${request.Price:F2}",
cancellationToken);
_logger.LogInformation("Notification sent for new product: {ProductName}", request.Name);
}
}
// Audit logging post-handler
public class CreateProductCommandAuditPostHandler : ICommandPostHandler<CreateProductCommand>
{
private readonly IAuditService _auditService;
private readonly ICurrentUser _currentUser;
public CreateProductCommandAuditPostHandler(IAuditService auditService, ICurrentUser currentUser)
{
_auditService = auditService;
_currentUser = currentUser;
}
public async Task HandleAsync(CreateProductCommand request, CancellationToken cancellationToken = default)
{
await _auditService.LogActionAsync(new AuditEntry
{
Action = "CreateProduct",
UserId = _currentUser.UserId,
Timestamp = DateTime.UtcNow,
Details = $"Created product: {request.Name} with price ${request.Price:F2}",
EntityType = "Product",
Data = JsonSerializer.Serialize(request)
}, cancellationToken);
}
}
// Cache invalidation post-handler
public class CreateProductCommandCacheInvalidationPostHandler : ICommandPostHandler<CreateProductCommand>
{
private readonly IDistributedCache _cache;
private readonly ILogger<CreateProductCommandCacheInvalidationPostHandler> _logger;
public CreateProductCommandCacheInvalidationPostHandler(
IDistributedCache cache,
ILogger<CreateProductCommandCacheInvalidationPostHandler> logger)
{
_cache = cache;
_logger = logger;
}
public async Task HandleAsync(CreateProductCommand request, CancellationToken cancellationToken = default)
{
// Invalidate relevant caches
var cacheKeys = new[]
{
"products:all",
$"products:category:{request.CategoryId}",
"products:featured"
};
foreach (var key in cacheKeys)
{
await _cache.RemoveAsync(key, cancellationToken);
}
_logger.LogDebug("Cache invalidated for product creation: {Keys}", string.Join(", ", cacheKeys));
}
}Pipeline behaviors provide cross-cutting concerns that execute around command handlers.
using Arcanic.Mediator.Command.Abstractions.Pipeline;
using Microsoft.EntityFrameworkCore;
public class TransactionCommandPipelineBehavior<TCommand, TResponse> : ICommandPipelineBehavior<TCommand, TResponse>
where TCommand : ICommand
{
private readonly ApplicationDbContext _context;
private readonly ILogger<TransactionCommandPipelineBehavior<TCommand, TResponse>> _logger;
public TransactionCommandPipelineBehavior(
ApplicationDbContext context,
ILogger<TransactionCommandPipelineBehavior<TCommand, TResponse>> logger)
{
_context = context;
_logger = logger;
}
public async Task<TResponse> HandleAsync(TCommand command, PipelineDelegate<TResponse> next, CancellationToken cancellationToken = default)
{
var commandName = typeof(TCommand).Name;
if (_context.Database.CurrentTransaction != null)
{
// Already in transaction, continue
return await next(cancellationToken);
}
_logger.LogInformation("Starting transaction for {CommandName}", commandName);
using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
var result = await next(cancellationToken);
await transaction.CommitAsync(cancellationToken);
_logger.LogInformation("Transaction committed for {CommandName}", commandName);
return result;
}
catch (Exception ex)
{
await transaction.RollbackAsync(cancellationToken);
_logger.LogError(ex, "Transaction rolled back for {CommandName}", commandName);
throw;
}
}
}using Microsoft.AspNetCore.Authorization;
public class AuthorizationCommandPipelineBehavior<TCommand, TResponse> : ICommandPipelineBehavior<TCommand, TResponse>
where TCommand : ICommand
{
private readonly ICurrentUser _currentUser;
private readonly IAuthorizationService _authorizationService;
public AuthorizationCommandPipelineBehavior(ICurrentUser currentUser, IAuthorizationService authorizationService)
{
_currentUser = currentUser;
_authorizationService = authorizationService;
}
public async Task<TResponse> HandleAsync(TCommand command, PipelineDelegate<TResponse> next, CancellationToken cancellationToken = default)
{
// Check for authorization attributes
var authorizeAttribute = typeof(TCommand).GetCustomAttribute<AuthorizeAttribute>();
if (authorizeAttribute != null)
{
var policy = authorizeAttribute.Policy ?? typeof(TCommand).Name;
var authorizationResult = await _authorizationService.AuthorizeAsync(
_currentUser.User, command, policy);
if (!authorizationResult.Succeeded)
{
throw new UnauthorizedAccessException($"User not authorized to execute {typeof(TCommand).Name}");
}
}
return await next(cancellationToken);
}
}using FluentValidation;
public class ValidationCommandPipelineBehavior<TCommand, TResponse> : ICommandPipelineBehavior<TCommand, TResponse>
where TCommand : ICommand
{
private readonly IEnumerable<IValidator<TCommand>> _validators;
public ValidationCommandPipelineBehavior(IEnumerable<IValidator<TCommand>> validators)
{
_validators = validators;
}
public async Task<TResponse> HandleAsync(TCommand command, PipelineDelegate<TResponse> next, CancellationToken cancellationToken = default)
{
if (_validators.Any())
{
var context = new ValidationContext<TCommand>(command);
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.Any())
{
throw new ValidationException(failures);
}
}
return await next(cancellationToken);
}
}public class PerformanceCommandPipelineBehavior<TCommand, TResponse> : ICommandPipelineBehavior<TCommand, TResponse>
where TCommand : ICommand
{
private readonly ILogger<PerformanceCommandPipelineBehavior<TCommand, TResponse>> _logger;
private readonly IMetricsCollector _metrics;
public PerformanceCommandPipelineBehavior(
ILogger<PerformanceCommandPipelineBehavior<TCommand, TResponse>> logger,
IMetricsCollector metrics)
{
_logger = logger;
_metrics = metrics;
}
public async Task<TResponse> HandleAsync(TCommand command, PipelineDelegate<TResponse> next, CancellationToken cancellationToken = default)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var commandName = typeof(TCommand).Name;
try
{
var result = await next(cancellationToken);
stopwatch.Stop();
var elapsed = stopwatch.ElapsedMilliseconds;
// Record metrics
_metrics.RecordCommandExecution(commandName, elapsed);
// Log slow commands
if (elapsed > 5000) // 5 seconds
{
_logger.LogWarning("Slow command detected: {CommandName} took {ElapsedMs}ms. Command: {@Command}",
commandName, elapsed, command);
}
else
{
_logger.LogDebug("Command {CommandName} completed in {ElapsedMs}ms", commandName, elapsed);
}
return result;
}
catch (Exception ex)
{
stopwatch.Stop();
_metrics.RecordCommandFailure(commandName, stopwatch.ElapsedMilliseconds);
_logger.LogError(ex, "Command {CommandName} failed after {ElapsedMs}ms",
commandName, stopwatch.ElapsedMilliseconds);
throw;
}
}
}using Arcanic.Mediator;
using Arcanic.Mediator.Command;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddArcanicMediator()
.AddCommands(Assembly.GetExecutingAssembly());
var app = builder.Build();builder.Services.AddArcanicMediator()
// Add pipeline behaviors
.AddCommandPipelineBehavior(typeof(ValidationCommandPipelineBehavior<,>))
.AddCommandPipelineBehavior(typeof(AuthorizationCommandPipelineBehavior<,>))
.AddCommandPipelineBehavior(typeof(TransactionCommandPipelineBehavior<,>))
.AddCommandPipelineBehavior(typeof(PerformanceCommandPipelineBehavior<,>))
// Register commands
.AddCommands(Assembly.GetExecutingAssembly());var builder = WebApplication.CreateBuilder(args);
// Add dependencies
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddScoped<IAuditService, AuditService>();
// Add FluentValidation
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
// Add Authorization
builder.Services.AddAuthorization();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
// Add Caching
builder.Services.AddMemoryCache();
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
// Add Mediator with all features
builder.Services.AddArcanicMediator()
.AddCommandPipelineBehavior(typeof(ValidationCommandPipelineBehavior<,>))
.AddCommandPipelineBehavior(typeof(AuthorizationCommandPipelineBehavior<,>))
.AddCommandPipelineBehavior(typeof(TransactionCommandPipelineBehavior<,>))
.AddCommandPipelineBehavior(typeof(PerformanceCommandPipelineBehavior<,>))
.AddCommands(Assembly.GetExecutingAssembly());
var app = builder.Build();var builder = WebApplication.CreateBuilder(args);
builder.Services.AddArcanicMediator()
.AddCommands(Assembly.GetExecutingAssembly());
// Add validation only in development and staging
if (!builder.Environment.IsProduction())
{
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
builder.Services.AddArcanicMediatorCommandPipelineBehavior(typeof(ValidationCommandPipelineBehavior<,>));
}
// Add transaction support only if database is configured
if (builder.Configuration.GetConnectionString("DefaultConnection") != null)
{
builder.Services.AddDbContext<ApplicationDbContext>();
builder.Services.AddArcanicMediatorCommandPipelineBehavior(typeof(TransactionCommandPipelineBehavior<,>));
}
// Add performance monitoring in production
if (builder.Environment.IsProduction())
{
builder.Services.AddSingleton<IMetricsCollector, MetricsCollector>();
builder.Services.AddArcanicMediatorCommandPipelineBehavior(typeof(PerformanceCommandPipelineBehavior<,>));
}// ✅ Good - Single responsibility
public class UpdateUserEmailCommand : ICommand
{
public int UserId { get; set; }
public string NewEmail { get; set; } = string.Empty;
}
// ❌ Bad - Multiple responsibilities
public class UpdateUserCommand : ICommand
{
public int UserId { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? Email { get; set; }
public string? PhoneNumber { get; set; }
public string? Address { get; set; }
// Too many optional properties
}// ✅ Good - Clear intent
public class ActivateUserAccountCommand : ICommand
public class DeactivateUserAccountCommand : ICommand
public class ResetUserPasswordCommand : ICommand
// ❌ Bad - Vague names
public class UserCommand : ICommand
public class UpdateUserCommand : ICommand
public class ProcessUserCommand : ICommand// Use pre-handlers or pipeline behaviors for validation
public class CreateProductCommandValidationPreHandler : ICommandPreHandler<CreateProductCommand>
{
public async Task HandleAsync(CreateProductCommand request, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.Name))
throw new ValidationException("Product name is required");
if (request.Price <= 0)
throw new ValidationException("Price must be greater than zero");
await Task.CompletedTask;
}
}public class UpdateProductCommandHandler : ICommandHandler<UpdateProductCommand>
{
public async Task HandleAsync(UpdateProductCommand request, CancellationToken cancellationToken = default)
{
try
{
var product = await _productRepository.GetByIdAsync(request.Id, cancellationToken);
if (product == null)
throw new ProductNotFoundException($"Product with ID {request.Id} not found");
// Business logic here
await _productRepository.UpdateAsync(product, cancellationToken);
}
catch (ProductNotFoundException)
{
throw; // Re-throw domain exceptions
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating product {ProductId}", request.Id);
throw new ApplicationException("An error occurred while updating the product", ex);
}
}
}public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand, int>
{
private readonly IOrderRepository _orderRepository;
private readonly IProductRepository _productRepository;
private readonly IInventoryService _inventoryService;
private readonly IPublisher _publisher;
private readonly ILogger<CreateOrderCommandHandler> _logger;
// Inject only what you need
public CreateOrderCommandHandler(
IOrderRepository orderRepository,
IProductRepository productRepository,
IInventoryService inventoryService,
IPublisher publisher,
ILogger<CreateOrderCommandHandler> logger)
{
_orderRepository = orderRepository;
_productRepository = productRepository;
_inventoryService = inventoryService;
_publisher = publisher;
_logger = logger;
}
}// ✅ Good - Immutable record
public record CreateProductCommand(
string Name,
string Description,
decimal Price,
int CategoryId) : ICommand<int>;
// ✅ Good - Read-only properties
public class UpdateProductCommand : ICommand
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public decimal Price { get; init; }
}public class CreateProductCommandHandlerTests
{
private readonly Mock<IProductRepository> _mockProductRepository;
private readonly Mock<IPublisher> _mockPublisher;
private readonly CreateProductCommandHandler _handler;
public CreateProductCommandHandlerTests()
{
_mockProductRepository = new Mock<IProductRepository>();
_mockPublisher = new Mock<IPublisher>();
_handler = new CreateProductCommandHandler(_mockProductRepository.Object, _mockPublisher.Object);
}
[Fact]
public async Task HandleAsync_ValidCommand_ReturnsProductId()
{
// Arrange
var command = new CreateProductCommand
{
Name = "Test Product",
Description = "Test Description",
Price = 19.99m,
CategoryId = 1
};
_mockProductRepository.Setup(x => x.AddAsync(It.IsAny<Product>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask)
.Callback<Product, CancellationToken>((product, ct) => product.Id = 123);
// Act
var result = await _handler.HandleAsync(command, CancellationToken.None);
// Assert
Assert.Equal(123, result);
_mockProductRepository.Verify(x => x.AddAsync(It.Is<Product>(p =>
p.Name == command.Name &&
p.Price == command.Price),
It.IsAny<CancellationToken>()), Times.Once);
_mockPublisher.Verify(x => x.PublishAsync(It.IsAny<ProductCreatedEvent>(),
It.IsAny<CancellationToken>()), Times.Once);
}
}public class CreateProductCommandIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public CreateProductCommandIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task CreateProduct_ValidCommand_ReturnsProductId()
{
// Arrange
var command = new CreateProductCommand
{
Name = "Integration Test Product",
Description = "Test Description",
Price = 29.99m,
CategoryId = 1
};
var json = JsonSerializer.Serialize(command);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/products", content);
// Assert
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStringAsync();
var productId = JsonSerializer.Deserialize<int>(responseContent);
Assert.True(productId > 0);
}
}- Event Module - Learn about domain events and event handling
- Query Module - Implement read operations with queries
- Pipeline Behaviors - Advanced cross-cutting concerns
- Getting Started - Basic setup and first steps
- Sample Projects - Complete examples