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

535 lines
14 KiB
Markdown

# Commands Overview
Commands represent write operations that change system state in your application.
## What are Commands?
Commands are **imperative requests** to perform an action that changes the state of your system. They encapsulate all the data needed to perform an operation.
**Characteristics:**
-**Imperative names** - CreateUser, PlaceOrder, CancelSubscription
-**Change state** - Modify database, send emails, publish events
-**May return data** - Often return IDs or confirmation data
-**Validated** - Input validation before execution
-**Can fail** - Business rules may prevent execution
-**Not idempotent** - Executing twice may have different results
## Command Types
### Commands Without Results
Commands that perform an action but don't return data:
```csharp
public record DeleteUserCommand
{
public int UserId { get; init; }
}
public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
{
public async Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken)
{
// Delete user logic
// No return value
}
}
```
**HTTP Response:** `204 No Content`
### Commands With Results
Commands that return data (typically IDs or confirmation):
```csharp
public record CreateUserCommand
{
public string Name { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
}
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
{
// Create user logic
return userId;
}
}
```
**HTTP Response:** `200 OK` with the result
## Basic Command Example
### Step 1: Define the Command
```csharp
// Commands/CreateUserCommand.cs
namespace MyApp.Commands;
public record CreateUserCommand
{
public string Name { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
public int Age { get; init; }
}
```
### Step 2: Create the Handler
```csharp
using Svrnty.CQRS.Abstractions;
using MyApp.Domain.Entities;
using MyApp.Domain.Repositories;
namespace MyApp.Commands;
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
private readonly IUserRepository _userRepository;
private readonly ILogger<CreateUserCommandHandler> _logger;
public CreateUserCommandHandler(
IUserRepository userRepository,
ILogger<CreateUserCommandHandler> logger)
{
_userRepository = userRepository;
_logger = logger;
}
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
{
_logger.LogInformation("Creating user: {Email}", command.Email);
// Create domain entity
var user = new User
{
Name = command.Name,
Email = command.Email,
Age = command.Age
};
// Save to database
var userId = await _userRepository.AddAsync(user, cancellationToken);
_logger.LogInformation("User created with ID: {UserId}", userId);
return userId;
}
}
```
### Step 3: Register the Handler
```csharp
// Program.cs
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
```
### Step 4: Test the Command
```bash
curl -X POST http://localhost:5000/api/command/createUser \
-H "Content-Type: application/json" \
-d '{
"name": "Alice Smith",
"email": "alice@example.com",
"age": 30
}'
```
**Response:**
```json
123
```
## Command Documentation
### [Basic Commands](basic-commands.md)
Commands without return values (void):
- When to use
- Implementation patterns
- Error handling
- HTTP responses (204 No Content)
**Topics:**
- Delete operations
- Update operations without return
- Fire-and-forget commands
- Idempotent commands
### [Commands with Results](commands-with-results.md)
Commands that return data:
- When to return data
- What to return (IDs, DTOs, confirmation)
- Implementation patterns
- HTTP responses (200 OK with result)
**Topics:**
- Create operations (return ID)
- Complex operations (return status/summary)
- Batch operations (return results array)
- Conditional results
### [Command Registration](command-registration.md)
How to register commands in DI:
- Basic registration
- Registration with validators
- Registration with workflows
- Bulk registration patterns
- Extension methods
**Topics:**
- Service lifetimes (Scoped, Transient, Singleton)
- Registration organization
- Module pattern
- Auto-registration
### [Command Authorization](command-authorization.md)
Securing commands with authorization:
- ICommandAuthorizationService interface
- Role-based authorization
- Resource-based authorization
- Claims-based authorization
**Topics:**
- Authorization services
- Multiple authorization rules
- Combining with ASP.NET Core authorization
- Custom authorization logic
### [Command Attributes](command-attributes.md)
Controlling command behavior with attributes:
- [CommandName] - Custom endpoint names
- [IgnoreCommand] - Prevent endpoint generation
- [GrpcIgnore] - Skip gRPC generation
- Custom attributes
**Topics:**
- Naming conventions
- Endpoint control
- Protocol selection
- Metadata customization
## Command Patterns
### Pattern 1: Simple CRUD Command
```csharp
public record UpdateUserCommand
{
public int UserId { get; init; }
public string Name { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
}
public class UpdateUserCommandHandler : ICommandHandler<UpdateUserCommand>
{
private readonly IUserRepository _userRepository;
public async Task HandleAsync(UpdateUserCommand command, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(command.UserId, cancellationToken);
if (user == null)
throw new KeyNotFoundException($"User {command.UserId} not found");
user.Name = command.Name;
user.Email = command.Email;
await _userRepository.UpdateAsync(user, cancellationToken);
}
}
```
### Pattern 2: Command with Business Logic
```csharp
public record PlaceOrderCommand
{
public int CustomerId { get; init; }
public List<OrderItem> Items { get; init; } = new();
}
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
{
private readonly IOrderRepository _orders;
private readonly IInventoryService _inventory;
private readonly IPaymentService _payment;
public async Task<int> HandleAsync(PlaceOrderCommand command, CancellationToken cancellationToken)
{
// Business rule: Check inventory
foreach (var item in command.Items)
{
if (!await _inventory.IsAvailableAsync(item.ProductId, item.Quantity))
throw new InvalidOperationException($"Product {item.ProductId} out of stock");
}
// Create order
var order = new Order
{
CustomerId = command.CustomerId,
Items = command.Items,
Status = OrderStatus.Pending
};
var orderId = await _orders.AddAsync(order, cancellationToken);
// Reserve inventory
foreach (var item in command.Items)
{
await _inventory.ReserveAsync(item.ProductId, item.Quantity, cancellationToken);
}
return orderId;
}
}
```
### Pattern 3: Command with Events
```csharp
public class CreateUserCommandHandler : ICommandHandlerWithWorkflow<CreateUserCommand, int, UserWorkflow>
{
private readonly IUserRepository _userRepository;
public async Task<int> HandleAsync(
CreateUserCommand command,
UserWorkflow workflow,
CancellationToken cancellationToken)
{
var user = new User { Name = command.Name, Email = command.Email };
var userId = await _userRepository.AddAsync(user, cancellationToken);
// Emit domain event
workflow.EmitCreated(new UserCreatedEvent
{
UserId = userId,
Name = user.Name,
Email = user.Email
});
return userId;
}
}
```
### Pattern 4: Compensating Command (Saga)
```csharp
public record CancelOrderCommand
{
public int OrderId { get; init; }
public string Reason { get; init; } = string.Empty;
}
public class CancelOrderCommandHandler : ICommandHandler<CancelOrderCommand>
{
private readonly IOrderRepository _orders;
private readonly IInventoryService _inventory;
private readonly IPaymentService _payment;
public async Task HandleAsync(CancelOrderCommand command, CancellationToken cancellationToken)
{
var order = await _orders.GetByIdAsync(command.OrderId, cancellationToken);
// Business rule: Can only cancel pending orders
if (order.Status != OrderStatus.Pending)
throw new InvalidOperationException("Cannot cancel order in this state");
// Compensate: Release inventory
foreach (var item in order.Items)
{
await _inventory.ReleaseAsync(item.ProductId, item.Quantity, cancellationToken);
}
// Compensate: Refund payment (if paid)
if (order.PaymentId != null)
{
await _payment.RefundAsync(order.PaymentId, cancellationToken);
}
// Update order status
order.Status = OrderStatus.Cancelled;
order.CancellationReason = command.Reason;
await _orders.UpdateAsync(order, cancellationToken);
}
}
```
## Best Practices
### ✅ DO
- **Use imperative names** - CreateUser, PlaceOrder, CancelSubscription
- **Keep commands simple** - Just data, no logic
- **Validate in handlers** - Or use FluentValidation
- **Return meaningful data** - IDs, confirmation, summary
- **Handle errors gracefully** - Business rule violations, validation errors
- **Use CancellationToken** - Enable request cancellation
- **Log important actions** - Audit trail
- **Make commands immutable** - Use `record` and `init`
### ❌ DON'T
- **Don't put logic in commands** - Commands are just data
- **Don't return domain entities** - Use DTOs or primitives
- **Don't ignore errors** - Handle business rule violations
- **Don't make properties mutable** - Use `init` not `set`
- **Don't skip validation** - Always validate input
- **Don't create fat handlers** - Extract domain services
- **Don't forget cancellation** - Always pass CancellationToken
## Common Scenarios
### Scenario 1: Batch Operations
```csharp
public record ImportUsersCommand
{
public List<UserImportDto> Users { get; init; } = new();
}
public class ImportUsersCommandHandler : ICommandHandler<ImportUsersCommand, ImportResult>
{
public async Task<ImportResult> HandleAsync(ImportUsersCommand command, CancellationToken cancellationToken)
{
var result = new ImportResult();
foreach (var userDto in command.Users)
{
try
{
var user = new User { Name = userDto.Name, Email = userDto.Email };
await _userRepository.AddAsync(user, cancellationToken);
result.SuccessCount++;
}
catch (Exception ex)
{
result.Errors.Add($"{userDto.Email}: {ex.Message}");
}
}
return result;
}
}
public class ImportResult
{
public int SuccessCount { get; set; }
public List<string> Errors { get; set; } = new();
}
```
### Scenario 2: Multi-Step Workflow
```csharp
public record ProcessPaymentCommand
{
public int OrderId { get; init; }
public string PaymentMethod { get; init; } = string.Empty;
public decimal Amount { get; init; }
}
public class ProcessPaymentCommandHandler : ICommandHandler<ProcessPaymentCommand, PaymentResult>
{
public async Task<PaymentResult> HandleAsync(ProcessPaymentCommand command, CancellationToken cancellationToken)
{
// Step 1: Validate order
var order = await _orders.GetByIdAsync(command.OrderId, cancellationToken);
ValidateOrder(order);
// Step 2: Process payment
var paymentId = await _payment.ChargeAsync(
command.PaymentMethod,
command.Amount,
cancellationToken);
// Step 3: Update order
order.PaymentId = paymentId;
order.Status = OrderStatus.Paid;
await _orders.UpdateAsync(order, cancellationToken);
// Step 4: Emit event
await _events.PublishAsync(new OrderPaidEvent { OrderId = order.Id });
return new PaymentResult
{
PaymentId = paymentId,
Status = "Success"
};
}
}
```
### Scenario 3: Idempotent Command
```csharp
public record CreateSubscriptionCommand
{
public string IdempotencyKey { get; init; } = string.Empty;
public int UserId { get; init; }
public string Plan { get; init; } = string.Empty;
}
public class CreateSubscriptionCommandHandler : ICommandHandler<CreateSubscriptionCommand, int>
{
private readonly ISubscriptionRepository _subscriptions;
public async Task<int> HandleAsync(CreateSubscriptionCommand command, CancellationToken cancellationToken)
{
// Check if already processed
var existing = await _subscriptions.GetByIdempotencyKeyAsync(
command.IdempotencyKey,
cancellationToken);
if (existing != null)
{
// Already processed, return existing ID
return existing.Id;
}
// Create new subscription
var subscription = new Subscription
{
IdempotencyKey = command.IdempotencyKey,
UserId = command.UserId,
Plan = command.Plan
};
return await _subscriptions.AddAsync(subscription, cancellationToken);
}
}
```
## What's Next?
Explore specific command topics:
- **[Basic Commands](basic-commands.md)** - Commands without results
- **[Commands with Results](commands-with-results.md)** - Commands that return data
- **[Command Registration](command-registration.md)** - Registration patterns
- **[Command Authorization](command-authorization.md)** - Securing commands
- **[Command Attributes](command-attributes.md)** - Controlling behavior
## See Also
- [Getting Started: Your First Command](../../getting-started/03-first-command.md) - Step-by-step guide
- [Best Practices: Command Design](../../best-practices/command-design.md) - Design patterns
- [Architecture: CQRS Pattern](../../architecture/cqrs-pattern.md) - Understanding CQRS
- [Validation](../validation/README.md) - Input validation