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

302 lines
7.7 KiB
Markdown

# 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
```csharp
public interface ICommandHandler<in TCommand>
{
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
```
## Example: Delete User
```csharp
// 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
```bash
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
- [Commands with Results](commands-with-results.md) - When to return data
- [Command Registration](command-registration.md) - Registration patterns
- [Validation](../validation/README.md) - Input validation