435 lines
10 KiB
Markdown
435 lines
10 KiB
Markdown
# Your First Command
|
|
|
|
Build your first command handler step-by-step and expose it via HTTP or gRPC.
|
|
|
|
## What You'll Build
|
|
|
|
In this guide, you'll create a `CreateUserCommand` that:
|
|
- ✅ Accepts user data (name, email)
|
|
- ✅ Creates a new user
|
|
- ✅ Returns the generated user ID
|
|
- ✅ Is automatically exposed as an HTTP endpoint
|
|
|
|
## Step 1: Create the Command
|
|
|
|
Commands are simple POCOs (Plain Old CLR Objects). Create a new file `Commands/CreateUserCommand.cs`:
|
|
|
|
```csharp
|
|
namespace MyApp.Commands;
|
|
|
|
public record CreateUserCommand
|
|
{
|
|
public string Name { get; init; } = string.Empty;
|
|
public string Email { get; init; } = string.Empty;
|
|
}
|
|
```
|
|
|
|
**Key Points:**
|
|
- ✅ Use `record` for immutability (recommended)
|
|
- ✅ Properties should be `init`-only
|
|
- ✅ No base class or interface required
|
|
- ✅ Name should end with "Command" (convention)
|
|
|
|
## Step 2: Create the Handler
|
|
|
|
Handlers contain your business logic. Create `Commands/CreateUserCommandHandler.cs`:
|
|
|
|
```csharp
|
|
using Svrnty.CQRS.Abstractions;
|
|
|
|
namespace MyApp.Commands;
|
|
|
|
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
|
|
{
|
|
public Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
|
|
{
|
|
// TODO: Add your business logic here
|
|
// For now, return a random ID
|
|
var userId = new Random().Next(1, 1000);
|
|
|
|
Console.WriteLine($"Creating user: {command.Name} ({command.Email})");
|
|
|
|
return Task.FromResult(userId);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Handler Interface:**
|
|
|
|
```csharp
|
|
ICommandHandler<TCommand, TResult>
|
|
```
|
|
|
|
- `TCommand`: Your command type (CreateUserCommand)
|
|
- `TResult`: Return type (int for user ID)
|
|
|
|
For commands without a return value, use:
|
|
|
|
```csharp
|
|
ICommandHandler<TCommand>
|
|
```
|
|
|
|
## Step 3: Register the Handler
|
|
|
|
In your `Program.cs`, register the command handler:
|
|
|
|
```csharp
|
|
using Svrnty.CQRS.Abstractions;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Register CQRS core services
|
|
builder.Services.AddSvrntyCQRS();
|
|
builder.Services.AddDefaultCommandDiscovery();
|
|
|
|
// Register your command handler
|
|
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
|
|
|
|
var app = builder.Build();
|
|
|
|
// Map CQRS endpoints
|
|
app.UseSvrntyCqrs();
|
|
|
|
app.Run();
|
|
```
|
|
|
|
**Registration Syntax:**
|
|
|
|
```csharp
|
|
// Command with result
|
|
services.AddCommand<TCommand, TResult, THandler>();
|
|
|
|
// Command without result
|
|
services.AddCommand<TCommand, THandler>();
|
|
```
|
|
|
|
## Step 4: Test Your Command
|
|
|
|
### Using HTTP
|
|
|
|
Run your application:
|
|
|
|
```bash
|
|
dotnet run
|
|
```
|
|
|
|
The command is automatically exposed at:
|
|
|
|
```
|
|
POST /api/command/createUser
|
|
```
|
|
|
|
Test with curl:
|
|
|
|
```bash
|
|
curl -X POST http://localhost:5000/api/command/createUser \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"name": "Alice Smith",
|
|
"email": "alice@example.com"
|
|
}'
|
|
```
|
|
|
|
Expected response:
|
|
|
|
```json
|
|
456
|
|
```
|
|
|
|
(The generated user ID)
|
|
|
|
### Using Swagger
|
|
|
|
If you have Swagger enabled, navigate to:
|
|
|
|
```
|
|
http://localhost:5000/swagger
|
|
```
|
|
|
|
You'll see your command listed under "Commands":
|
|
|
|
```
|
|
POST /api/command/createUser
|
|
```
|
|
|
|
Click "Try it out", fill in the request body, and execute.
|
|
|
|
## Complete Example
|
|
|
|
Here's a more realistic example with actual data persistence:
|
|
|
|
### Create a User Model
|
|
|
|
```csharp
|
|
// Models/User.cs
|
|
namespace MyApp.Models;
|
|
|
|
public class User
|
|
{
|
|
public int Id { get; set; }
|
|
public string Name { get; set; } = string.Empty;
|
|
public string Email { get; set; } = string.Empty;
|
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
}
|
|
```
|
|
|
|
### Create a Repository
|
|
|
|
```csharp
|
|
// Repositories/IUserRepository.cs
|
|
namespace MyApp.Repositories;
|
|
|
|
public interface IUserRepository
|
|
{
|
|
Task<int> AddAsync(User user, CancellationToken cancellationToken);
|
|
}
|
|
|
|
// Repositories/InMemoryUserRepository.cs
|
|
public class InMemoryUserRepository : IUserRepository
|
|
{
|
|
private readonly List<User> _users = new();
|
|
private int _nextId = 1;
|
|
|
|
public Task<int> AddAsync(User user, CancellationToken cancellationToken)
|
|
{
|
|
user.Id = _nextId++;
|
|
user.CreatedAt = DateTime.UtcNow;
|
|
_users.Add(user);
|
|
|
|
return Task.FromResult(user.Id);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Update the Handler
|
|
|
|
```csharp
|
|
using Svrnty.CQRS.Abstractions;
|
|
using MyApp.Models;
|
|
using MyApp.Repositories;
|
|
|
|
namespace MyApp.Commands;
|
|
|
|
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
|
|
public CreateUserCommandHandler(IUserRepository userRepository)
|
|
{
|
|
_userRepository = userRepository;
|
|
}
|
|
|
|
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
|
|
{
|
|
var user = new User
|
|
{
|
|
Name = command.Name,
|
|
Email = command.Email
|
|
};
|
|
|
|
var userId = await _userRepository.AddAsync(user, cancellationToken);
|
|
|
|
Console.WriteLine($"Created user {userId}: {command.Name}");
|
|
|
|
return userId;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Update Program.cs
|
|
|
|
```csharp
|
|
using MyApp.Repositories;
|
|
using Svrnty.CQRS.Abstractions;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Register repository
|
|
builder.Services.AddSingleton<IUserRepository, InMemoryUserRepository>();
|
|
|
|
// Register CQRS
|
|
builder.Services.AddSvrntyCQRS();
|
|
builder.Services.AddDefaultCommandDiscovery();
|
|
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseSvrntyCqrs();
|
|
app.Run();
|
|
```
|
|
|
|
## Command Naming Conventions
|
|
|
|
### Automatic Endpoint Names
|
|
|
|
By default, endpoints are generated from the command class name:
|
|
|
|
| Class Name | HTTP Endpoint |
|
|
|------------|---------------|
|
|
| `CreateUserCommand` | `POST /api/command/createUser` |
|
|
| `UpdateProfileCommand` | `POST /api/command/updateProfile` |
|
|
| `DeleteOrderCommand` | `POST /api/command/deleteOrder` |
|
|
|
|
**Rules:**
|
|
1. Strips "Command" suffix
|
|
2. Converts to lowerCamelCase
|
|
3. Prefixes with `/api/command/`
|
|
|
|
### Custom Endpoint Names
|
|
|
|
Use the `[CommandName]` attribute to customize:
|
|
|
|
```csharp
|
|
using Svrnty.CQRS.Abstractions;
|
|
|
|
[CommandName("register")]
|
|
public record CreateUserCommand
|
|
{
|
|
public string Name { get; init; } = string.Empty;
|
|
public string Email { get; init; } = string.Empty;
|
|
}
|
|
```
|
|
|
|
Endpoint becomes:
|
|
|
|
```
|
|
POST /api/command/register
|
|
```
|
|
|
|
## Commands Without Results
|
|
|
|
Some commands don't need to return a value:
|
|
|
|
```csharp
|
|
// Command
|
|
public record DeleteUserCommand
|
|
{
|
|
public int UserId { get; init; }
|
|
}
|
|
|
|
// Handler
|
|
public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
|
|
public DeleteUserCommandHandler(IUserRepository userRepository)
|
|
{
|
|
_userRepository = userRepository;
|
|
}
|
|
|
|
public async Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken)
|
|
{
|
|
await _userRepository.DeleteAsync(command.UserId, cancellationToken);
|
|
// No return value
|
|
}
|
|
}
|
|
|
|
// Registration
|
|
builder.Services.AddCommand<DeleteUserCommand, DeleteUserCommandHandler>();
|
|
```
|
|
|
|
HTTP response:
|
|
|
|
```
|
|
204 No Content
|
|
```
|
|
|
|
## Dependency Injection
|
|
|
|
Handlers support full dependency injection:
|
|
|
|
```csharp
|
|
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IEmailService _emailService;
|
|
private readonly ILogger<CreateUserCommandHandler> _logger;
|
|
|
|
public CreateUserCommandHandler(
|
|
IUserRepository userRepository,
|
|
IEmailService emailService,
|
|
ILogger<CreateUserCommandHandler> logger)
|
|
{
|
|
_userRepository = userRepository;
|
|
_emailService = emailService;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogInformation("Creating user: {Email}", command.Email);
|
|
|
|
var user = new User { Name = command.Name, Email = command.Email };
|
|
var userId = await _userRepository.AddAsync(user, cancellationToken);
|
|
|
|
await _emailService.SendWelcomeEmailAsync(user.Email, cancellationToken);
|
|
|
|
return userId;
|
|
}
|
|
}
|
|
```
|
|
|
|
All dependencies are resolved from the DI container.
|
|
|
|
## Best Practices
|
|
|
|
### ✅ DO
|
|
|
|
- **Use records** - Immutable data structures
|
|
- **Name clearly** - Use imperative verbs (Create, Update, Delete)
|
|
- **Keep commands simple** - Just data, no logic
|
|
- **Validate in handlers** - Or use validators (next guide)
|
|
- **Return meaningful results** - IDs, confirmation data
|
|
- **Use async/await** - Even for synchronous operations
|
|
- **Accept CancellationToken** - Enable request cancellation
|
|
|
|
### ❌ DON'T
|
|
|
|
- **Don't put logic in commands** - Commands are just data
|
|
- **Don't return domain entities** - Use DTOs or primitives
|
|
- **Don't ignore CancellationToken** - Always pass it through
|
|
- **Don't use constructors** - Use init-only properties
|
|
- **Don't make properties mutable** - Use `init` instead of `set`
|
|
|
|
## Troubleshooting
|
|
|
|
### Endpoint Not Found
|
|
|
|
**Problem:** `404 Not Found` when calling `/api/command/createUser`
|
|
|
|
**Solutions:**
|
|
1. Ensure you called `app.UseSvrntyCqrs()` in Program.cs
|
|
2. Verify the command is registered with `AddCommand<>()`
|
|
3. Check the command name matches the endpoint (or use `[CommandName]`)
|
|
|
|
### Handler Not Executing
|
|
|
|
**Problem:** Endpoint exists but handler doesn't run
|
|
|
|
**Solutions:**
|
|
1. Verify handler is registered in DI
|
|
2. Check for exceptions in handler constructor (DI failure)
|
|
3. Ensure handler implements correct interface
|
|
|
|
### JSON Deserialization Fails
|
|
|
|
**Problem:** `400 Bad Request` with serialization error
|
|
|
|
**Solutions:**
|
|
1. Check property names match JSON (case-insensitive by default)
|
|
2. Ensure all properties have public getters
|
|
3. Use `init` instead of private setters
|
|
|
|
## What's Next?
|
|
|
|
Now that you can create commands, let's learn how to query data!
|
|
|
|
**Continue to [Your First Query](04-first-query.md) →**
|
|
|
|
## See Also
|
|
|
|
- [Commands Overview](../core-features/commands/README.md) - Deep dive into commands
|
|
- [Command Registration](../core-features/commands/command-registration.md) - Advanced registration patterns
|
|
- [Best Practices: Command Design](../best-practices/command-design.md) - Command design patterns
|
|
- [Validation](05-adding-validation.md) - Add FluentValidation to your commands
|