dotnet-cqrs/docs/core-features/commands/basic-commands.md

7.7 KiB

Basic Commands

Commands that perform actions without returning data (void commands).

Overview

Basic commands execute operations and don't return a result. They use the ICommandHandler<TCommand> interface (without a result type parameter).

Use cases:

  • Delete operations
  • Update operations
  • Toggle/flag operations
  • Cleanup operations
  • Fire-and-forget operations

Interface

public interface ICommandHandler<in TCommand>
{
    Task HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}

Example: Delete User

// Command
public record DeleteUserCommand
{
    public int UserId { get; init; }
}

// Handler
public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
{
    private readonly IUserRepository _userRepository;
    private readonly ILogger<DeleteUserCommandHandler> _logger;

    public DeleteUserCommandHandler(
        IUserRepository userRepository,
        ILogger<DeleteUserCommandHandler> logger)
    {
        _userRepository = userRepository;
        _logger = logger;
    }

    public async Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken)
    {
        _logger.LogInformation("Deleting user: {UserId}", command.UserId);

        var user = await _userRepository.GetByIdAsync(command.UserId, cancellationToken);

        if (user == null)
            throw new KeyNotFoundException($"User {command.UserId} not found");

        await _userRepository.DeleteAsync(user, cancellationToken);

        _logger.LogInformation("User deleted: {UserId}", command.UserId);
    }
}

// Registration
builder.Services.AddCommand<DeleteUserCommand, DeleteUserCommandHandler>();

HTTP Behavior

Request

curl -X POST http://localhost:5000/api/command/deleteUser \
  -H "Content-Type: application/json" \
  -d '{"userId": 123}'

Response

Success:

HTTP/1.1 204 No Content

Error (Not Found):

HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "Not Found",
  "status": 404,
  "detail": "User 123 not found"
}

Common Patterns

Pattern 1: Update Operation

public record UpdateUserEmailCommand
{
    public int UserId { get; init; }
    public string NewEmail { get; init; } = string.Empty;
}

public class UpdateUserEmailCommandHandler : ICommandHandler<UpdateUserEmailCommand>
{
    private readonly IUserRepository _userRepository;

    public async Task HandleAsync(UpdateUserEmailCommand command, CancellationToken cancellationToken)
    {
        var user = await _userRepository.GetByIdAsync(command.UserId, cancellationToken);

        if (user == null)
            throw new KeyNotFoundException($"User {command.UserId} not found");

        user.Email = command.NewEmail;
        user.EmailVerified = false; // Reset verification

        await _userRepository.UpdateAsync(user, cancellationToken);
    }
}

Pattern 2: Toggle Operation

public record ToggleUserActiveCommand
{
    public int UserId { get; init; }
}

public class ToggleUserActiveCommandHandler : ICommandHandler<ToggleUserActiveCommand>
{
    private readonly IUserRepository _userRepository;

    public async Task HandleAsync(ToggleUserActiveCommand command, CancellationToken cancellationToken)
    {
        var user = await _userRepository.GetByIdAsync(command.UserId, cancellationToken);

        if (user == null)
            throw new KeyNotFoundException($"User {command.UserId} not found");

        user.IsActive = !user.IsActive; // Toggle

        await _userRepository.UpdateAsync(user, cancellationToken);
    }
}

Pattern 3: Cleanup Operation

public record CleanupExpiredSessionsCommand
{
}

public class CleanupExpiredSessionsCommandHandler : ICommandHandler<CleanupExpiredSessionsCommand>
{
    private readonly ISessionRepository _sessions;
    private readonly ILogger<CleanupExpiredSessionsCommandHandler> _logger;

    public async Task HandleAsync(CleanupExpiredSessionsCommand command, CancellationToken cancellationToken)
    {
        var expiredSessions = await _sessions.GetExpiredAsync(cancellationToken);

        _logger.LogInformation("Cleaning up {Count} expired sessions", expiredSessions.Count);

        foreach (var session in expiredSessions)
        {
            await _sessions.DeleteAsync(session, cancellationToken);
        }

        _logger.LogInformation("Cleanup complete");
    }
}

Pattern 4: Notification Command

public record SendWelcomeEmailCommand
{
    public int UserId { get; init; }
}

public class SendWelcomeEmailCommandHandler : ICommandHandler<SendWelcomeEmailCommand>
{
    private readonly IUserRepository _userRepository;
    private readonly IEmailService _emailService;

    public async Task HandleAsync(SendWelcomeEmailCommand command, CancellationToken cancellationToken)
    {
        var user = await _userRepository.GetByIdAsync(command.UserId, cancellationToken);

        if (user == null)
            throw new KeyNotFoundException($"User {command.UserId} not found");

        await _emailService.SendWelcomeEmailAsync(user.Email, user.Name, cancellationToken);
    }
}

When to Use Basic Commands

Use Basic Commands When:

  • Delete operations - Removing data
  • Update operations - Changing existing data without needing confirmation
  • Side effects - Sending emails, publishing events
  • Fire-and-forget - No result needed
  • Idempotent operations - Can be safely retried

Use Commands with Results When:

  • Need to return created ID
  • Need to return operation status
  • Need to return validation results
  • Need to return computed data

Error Handling

Not Found

public async Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken)
{
    var user = await _userRepository.GetByIdAsync(command.UserId, cancellationToken);

    if (user == null)
        throw new KeyNotFoundException($"User {command.UserId} not found");

    await _userRepository.DeleteAsync(user, cancellationToken);
}

HTTP Response: 404 Not Found

Business Rule Violation

public async Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken)
{
    var user = await _userRepository.GetByIdAsync(command.UserId, cancellationToken);

    // Business rule: Cannot delete users with active orders
    if (await _orders.UserHasActiveOrdersAsync(user.Id, cancellationToken))
    {
        throw new InvalidOperationException("Cannot delete user with active orders");
    }

    await _userRepository.DeleteAsync(user, cancellationToken);
}

HTTP Response: 400 Bad Request

Concurrent Modification

public async Task HandleAsync(UpdateUserCommand command, CancellationToken cancellationToken)
{
    var user = await _userRepository.GetByIdAsync(command.UserId, cancellationToken);

    // Check concurrency token (optimistic concurrency)
    if (user.RowVersion != command.RowVersion)
    {
        throw new DbUpdateConcurrencyException("User was modified by another user");
    }

    user.Name = command.Name;
    await _userRepository.UpdateAsync(user, cancellationToken);
}

HTTP Response: 409 Conflict

Best Practices

DO

  • Throw exceptions for errors (framework handles HTTP status codes)
  • Log important operations
  • Validate business rules
  • Use CancellationToken
  • Return Task (not Task<unit> or similar)

DON'T

  • Don't return null or void - return Task
  • Don't swallow exceptions
  • Don't forget to check if entity exists
  • Don't skip business rule validation

See Also