10 KiB
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:
namespace MyApp.Commands;
public record CreateUserCommand
{
public string Name { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
}
Key Points:
- ✅ Use
recordfor 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:
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:
ICommandHandler<TCommand, TResult>
TCommand: Your command type (CreateUserCommand)TResult: Return type (int for user ID)
For commands without a return value, use:
ICommandHandler<TCommand>
Step 3: Register the Handler
In your Program.cs, register the command handler:
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:
// 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:
dotnet run
The command is automatically exposed at:
POST /api/command/createUser
Test with curl:
curl -X POST http://localhost:5000/api/command/createUser \
-H "Content-Type: application/json" \
-d '{
"name": "Alice Smith",
"email": "alice@example.com"
}'
Expected response:
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
// 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
// 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
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
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:
- Strips "Command" suffix
- Converts to lowerCamelCase
- Prefixes with
/api/command/
Custom Endpoint Names
Use the [CommandName] attribute to customize:
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:
// 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:
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
initinstead ofset
Troubleshooting
Endpoint Not Found
Problem: 404 Not Found when calling /api/command/createUser
Solutions:
- Ensure you called
app.UseSvrntyCqrs()in Program.cs - Verify the command is registered with
AddCommand<>() - Check the command name matches the endpoint (or use
[CommandName])
Handler Not Executing
Problem: Endpoint exists but handler doesn't run
Solutions:
- Verify handler is registered in DI
- Check for exceptions in handler constructor (DI failure)
- Ensure handler implements correct interface
JSON Deserialization Fails
Problem: 400 Bad Request with serialization error
Solutions:
- Check property names match JSON (case-insensitive by default)
- Ensure all properties have public getters
- Use
initinstead of private setters
What's Next?
Now that you can create commands, let's learn how to query data!
Continue to Your First Query →
See Also
- Commands Overview - Deep dive into commands
- Command Registration - Advanced registration patterns
- Best Practices: Command Design - Command design patterns
- Validation - Add FluentValidation to your commands