dotnet-cqrs/docs/GETTING_STARTED.md
2026-03-08 14:02:19 -04:00

13 KiB

Getting Started

Step-by-step guide to building a CQRS application with Svrnty.CQRS on .NET 10.

Prerequisites

  • .NET 10 SDK
  • A text editor or IDE with C# support

1. Create a New Project

dotnet new web -n MyCqrsApp
cd MyCqrsApp

Add the required packages:

dotnet add package Svrnty.CQRS
dotnet add package Svrnty.CQRS.Abstractions
dotnet add package Svrnty.CQRS.MinimalApi
dotnet add package Svrnty.CQRS.FluentValidation

2. Define Commands and Queries

Command with Result

A command represents an action that changes state. Implement ICommandHandler<TCommand, TResult> for commands that return a value.

using Svrnty.CQRS.Abstractions;

// The command (a plain record/class)
public record CreateUserCommand
{
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public int Age { get; set; }
}

// The handler
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
    public Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken = default)
    {
        // Your business logic here -- persist to database, etc.
        return Task.FromResult(123); // Return the new user ID
    }
}

Command without Result

For commands that do not return a value, implement ICommandHandler<TCommand>:

public record DeleteUserCommand
{
    public int UserId { get; set; }
}

public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
{
    public Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken = default)
    {
        // Delete the user
        return Task.CompletedTask;
    }
}

Query

A query retrieves data without side effects. Implement IQueryHandler<TQuery, TResult>:

public record GetUserQuery
{
    public int UserId { get; set; }
}

public record UserDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
}

public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
    public Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken = default)
    {
        return Task.FromResult(new UserDto
        {
            Id = query.UserId,
            Name = "John Doe",
            Email = "john@example.com"
        });
    }
}

3. Register Handlers

In Program.cs, register your handlers with the DI container:

using Svrnty.CQRS;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.MinimalApi;

var builder = WebApplication.CreateBuilder(args);

// Register command and query handlers
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddCommand<DeleteUserCommand, DeleteUserCommandHandler>();
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();

// Configure CQRS with MinimalApi transport
builder.Services.AddSvrntyCqrs(cqrs =>
{
    cqrs.AddMinimalApi();
});

var app = builder.Build();

// Map all CQRS endpoints
app.UseSvrntyCqrs();

app.Run();

This will expose:

  • POST /api/command/CreateUser -- executes CreateUserCommand
  • POST /api/command/DeleteUser -- executes DeleteUserCommand
  • POST /api/query/GetUser -- executes GetUserQuery

4. Add FluentValidation

Add validators to enforce business rules before handler execution:

using FluentValidation;

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required")
            .EmailAddress().WithMessage("Email must be valid");

        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Name is required");

        RuleFor(x => x.Age)
            .GreaterThan(0).WithMessage("Age must be greater than 0");
    }
}

Register the command with its validator using the 4-type-parameter overload:

builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler, CreateUserCommandValidator>();

Validation errors are returned as RFC 7807 Problem Details (HTTP) or Google Rich Error Model (gRPC).

5. gRPC Setup

Add the gRPC packages:

dotnet add package Svrnty.CQRS.Grpc
dotnet add package Svrnty.CQRS.Grpc.Generators
dotnet add package Svrnty.CQRS.Grpc.Abstractions

Configure Kestrel for dual-protocol support and enable gRPC:

using Microsoft.AspNetCore.Server.Kestrel.Core;
using Svrnty.CQRS.Grpc;

var builder = WebApplication.CreateBuilder(args);

// Configure dual ports
builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenLocalhost(6000, o => o.Protocols = HttpProtocols.Http2);   // gRPC
    options.ListenLocalhost(6001, o => o.Protocols = HttpProtocols.Http1);   // HTTP API
});

// Register handlers (same as before)
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();

// Enable both gRPC and MinimalApi
builder.Services.AddSvrntyCqrs(cqrs =>
{
    cqrs.AddGrpc(grpc =>
    {
        grpc.EnableReflection(); // Enable gRPC reflection for tools like grpcurl
    });

    cqrs.AddMinimalApi();
});

var app = builder.Build();
app.UseSvrntyCqrs();
app.Run();

The Svrnty.CQRS.Grpc.Generators package automatically generates .proto files and gRPC service implementations from your registered command/query types at build time.

Excluding Commands from gRPC

Use the [GrpcIgnore] attribute to prevent a command or query from being exposed via gRPC:

using Svrnty.CQRS.Grpc.Abstractions.Attributes;

[GrpcIgnore]
public record InternalCommand
{
    public string Data { get; set; } = string.Empty;
}

6. DynamicQuery Usage

Dynamic queries provide automatic filtering, sorting, grouping, and pagination for entity collections.

Add the packages:

dotnet add package Svrnty.CQRS.DynamicQuery
dotnet add package Svrnty.CQRS.DynamicQuery.Abstractions
dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi

Define a Queryable Provider

Implement IQueryableProvider<T> to supply the data source:

using Svrnty.CQRS.DynamicQuery.Abstractions;

public class UserQueryableProvider : IQueryableProvider<UserDto>
{
    private readonly MyDbContext _db;

    public UserQueryableProvider(MyDbContext db)
    {
        _db = db;
    }

    public Task<IQueryable<UserDto>> GetQueryableAsync(object query, CancellationToken cancellationToken = default)
    {
        return Task.FromResult(_db.Users.AsQueryable());
    }
}

Register the Provider

using Svrnty.CQRS.DynamicQuery;

// Register PoweredSoft dependencies
builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, MyAsyncQueryableService>();
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();

// Register the dynamic query provider
builder.Services.AddDynamicQueryWithProvider<UserDto, UserQueryableProvider>();

This exposes a POST endpoint that accepts filter, sort, group, and pagination parameters, returning paged results automatically.

Entity Framework Integration

For EF Core projects, add the EF integration package:

dotnet add package Svrnty.CQRS.DynamicQuery.EntityFramework

This provides a ready-made IAsyncQueryableService backed by EF Core.

7. Domain Events

Domain events allow you to publish side effects after a command completes.

Add the packages:

dotnet add package Svrnty.CQRS.Events.Abstractions
dotnet add package Svrnty.CQRS.Events.RabbitMQ   # or implement your own IDomainEventPublisher

Define an Event

using Svrnty.CQRS.Events.Abstractions;

public record UserCreatedEvent : IDomainEvent
{
    public Guid EventId { get; } = Guid.NewGuid();
    public DateTime OccurredAt { get; } = DateTime.UtcNow;

    public int UserId { get; init; }
    public string Email { get; init; } = string.Empty;
}

Publish from a Command Handler

public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
    private readonly IDomainEventPublisher _events;

    public CreateUserCommandHandler(IDomainEventPublisher events)
    {
        _events = events;
    }

    public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken ct = default)
    {
        var userId = 123; // persist user

        await _events.PublishAsync(new UserCreatedEvent
        {
            UserId = userId,
            Email = command.Email
        }, ct);

        return userId;
    }
}

8. Saga Pattern

Sagas orchestrate multi-step workflows with automatic compensation (rollback) on failure.

Add the packages:

dotnet add package Svrnty.CQRS.Sagas
dotnet add package Svrnty.CQRS.Sagas.Abstractions
dotnet add package Svrnty.CQRS.Sagas.RabbitMQ   # for distributed sagas

Define Saga Data

using Svrnty.CQRS.Sagas.Abstractions;

public class CreateOrderSagaData : ISagaData
{
    public Guid CorrelationId { get; set; }
    public int OrderId { get; set; }
    public int PaymentId { get; set; }
    public decimal Amount { get; set; }
}

Define a Saga

public class CreateOrderSaga : ISaga<CreateOrderSagaData>
{
    public void Configure(ISagaBuilder<CreateOrderSagaData> builder)
    {
        builder
            .Step("CreateOrder")
                .Execute(async (data, ctx, ct) =>
                {
                    // Create the order
                    data.OrderId = 42;
                })
                .Compensate(async (data, ctx, ct) =>
                {
                    // Cancel the order on rollback
                })
                .Then()

            .Step("ProcessPayment")
                .Execute(async (data, ctx, ct) =>
                {
                    // Charge payment
                    data.PaymentId = 99;
                })
                .Compensate(async (data, ctx, ct) =>
                {
                    // Refund payment on rollback
                })
                .Then();
    }
}

Execute a Saga

using Svrnty.CQRS.Sagas.Abstractions;

public class OrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
{
    private readonly ISagaOrchestrator _orchestrator;

    public OrderCommandHandler(ISagaOrchestrator orchestrator)
    {
        _orchestrator = orchestrator;
    }

    public async Task<int> HandleAsync(PlaceOrderCommand command, CancellationToken ct = default)
    {
        var state = await _orchestrator.StartAsync<CreateOrderSaga, CreateOrderSagaData>(
            new CreateOrderSagaData { Amount = command.Amount }, ct);

        // state.Status will be Completed or Compensated
        return state.Status == SagaStatus.Completed ? 1 : 0;
    }
}

Saga statuses: NotStarted -> InProgress -> Completed (success) or Failed -> Compensating -> Compensated (rolled back).

Remote Steps (Distributed Sagas)

For steps that execute on remote services via RabbitMQ:

builder
    .SendCommand<ChargePaymentCommand, PaymentResult>("ChargePayment")
        .WithCommand((data, ctx) => new ChargePaymentCommand { Amount = data.Amount })
        .OnResponse(async (data, ctx, result, ct) =>
        {
            data.PaymentId = result.PaymentId;
        })
        .Compensate<RefundPaymentCommand>((data, ctx) =>
            new RefundPaymentCommand { PaymentId = data.PaymentId })
        .WithTimeout(TimeSpan.FromSeconds(30))
        .WithRetry(maxRetries: 3, delay: TimeSpan.FromSeconds(2))
        .Then();

9. Real-Time Notifications

For pushing real-time updates to clients via gRPC streaming:

dotnet add package Svrnty.CQRS.Notifications.Abstractions
dotnet add package Svrnty.CQRS.Notifications.Grpc

Define a Notification

using Svrnty.CQRS.Notifications.Abstractions;

[StreamingNotification(SubscriptionKey = "user-updates")]
public record UserUpdatedNotification
{
    public int UserId { get; init; }
    public string NewEmail { get; init; } = string.Empty;
}

Publish a Notification

public class UpdateUserCommandHandler : ICommandHandler<UpdateUserCommand>
{
    private readonly INotificationPublisher _notifications;

    public UpdateUserCommandHandler(INotificationPublisher notifications)
    {
        _notifications = notifications;
    }

    public async Task HandleAsync(UpdateUserCommand command, CancellationToken ct = default)
    {
        // Update user...

        await _notifications.PublishAsync(new UserUpdatedNotification
        {
            UserId = command.UserId,
            NewEmail = command.NewEmail
        }, ct);
    }
}

Running the Sample App

The repository includes a complete sample application:

cd Svrnty.Sample
dotnet run

This starts:

  • gRPC server on http://localhost:6000 (HTTP/2)
  • HTTP API on http://localhost:6001 (HTTP/1.1)
  • Swagger UI at http://localhost:6001/swagger

The sample demonstrates commands with validation, queries, gRPC reflection, MinimalApi endpoints, and dynamic queries.