Skip to content

Command Module

Sébastien Bernier edited this page Feb 23, 2026 · 7 revisions

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.

Table of Contents

Overview

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

Key Concepts

  • 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

Installation

Install the Command module package:

dotnet add package Arcanic.Mediator.Command

For abstractions only (useful in domain/application layers):

dotnet add package Arcanic.Mediator.Command.Abstractions

Basic Usage

1. Define a Command

Commands 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; }
}

2. Create Command Handlers

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;
    }
}

3. Configure Services

using Arcanic.Mediator;
using Arcanic.Mediator.Command;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddArcanicMediator()
    .AddCommands(Assembly.GetExecutingAssembly());

var app = builder.Build();

4. Use in Controllers

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);
    }
}

Command Types

Commands Without Return Values

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;
}

Commands With Return Values

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;
}

Command Handlers

Simple Command Handler

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);
    }
}

Command Handler with Complex Business Logic

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

Pre and post handlers allow you to execute logic before and after the main command handler.

Pre-Handlers (Validation, Authorization)

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");
    }
}

Post-Handlers (Notifications, Cleanup)

// 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));
    }
}

Command Pipeline Behaviors

Pipeline behaviors provide cross-cutting concerns that execute around command handlers.

Transaction Management

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;
        }
    }
}

Command Authorization

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);
    }
}

Command Validation with FluentValidation

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);
    }
}

Command Performance Monitoring

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;
        }
    }
}

Registration and Configuration

Basic Registration

using Arcanic.Mediator;
using Arcanic.Mediator.Command;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddArcanicMediator()
    .AddCommands(Assembly.GetExecutingAssembly());

var app = builder.Build();

Registration with Pipeline Behaviors

builder.Services.AddArcanicMediator()
    // Add pipeline behaviors
    .AddCommandPipelineBehavior(typeof(ValidationCommandPipelineBehavior<,>))
    .AddCommandPipelineBehavior(typeof(AuthorizationCommandPipelineBehavior<,>))
    .AddCommandPipelineBehavior(typeof(TransactionCommandPipelineBehavior<,>))
    .AddCommandPipelineBehavior(typeof(PerformanceCommandPipelineBehavior<,>))
    // Register commands
    .AddCommands(Assembly.GetExecutingAssembly());

Advanced Configuration with Dependencies

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();

Conditional Registration

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<,>));
}

Best Practices

1. Keep Commands Simple and Focused

// ✅ 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
}

2. Use Meaningful Command Names

// ✅ 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

3. Validate Input Early

// 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;
    }
}

4. Handle Exceptions Appropriately

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);
        }
    }
}

5. Use Dependency Injection Properly

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;
    }
}

6. Make Commands Immutable When Possible

// ✅ 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; }
}

Testing

Unit Testing Command Handlers

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);
    }
}

Integration Testing

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);
    }
}

Next Steps

Related Documentation

Clone this wiki locally