535 lines
14 KiB
Markdown
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
|