7.7 KiB
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(notTask<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
- Commands with Results - When to return data
- Command Registration - Registration patterns
- Validation - Input validation