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 CreateUserCommandPOST /api/command/DeleteUser-- executes DeleteUserCommandPOST /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.