515 lines
13 KiB
Markdown
515 lines
13 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
dotnet new web -n MyCqrsApp
|
|
cd MyCqrsApp
|
|
```
|
|
|
|
Add the required packages:
|
|
|
|
```bash
|
|
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.
|
|
|
|
```csharp
|
|
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>`:
|
|
|
|
```csharp
|
|
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>`:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
dotnet add package Svrnty.CQRS.Events.Abstractions
|
|
dotnet add package Svrnty.CQRS.Events.RabbitMQ # or implement your own IDomainEventPublisher
|
|
```
|
|
|
|
### Define an Event
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```bash
|
|
dotnet add package Svrnty.CQRS.Notifications.Abstractions
|
|
dotnet add package Svrnty.CQRS.Notifications.Grpc
|
|
```
|
|
|
|
### Define a Notification
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```bash
|
|
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.
|