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

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 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:

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:

  1. Strips "Command" suffix
  2. Converts to lowerCamelCase
  3. 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 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

See Also