dotnet-cqrs/docs/getting-started/03-first-command.md

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