Softoverse.CqrsKit
10.0.0
dotnet add package Softoverse.CqrsKit --version 10.0.0
NuGet\Install-Package Softoverse.CqrsKit -Version 10.0.0
<PackageReference Include="Softoverse.CqrsKit" Version="10.0.0" />
<PackageVersion Include="Softoverse.CqrsKit" Version="10.0.0" />
<PackageReference Include="Softoverse.CqrsKit" />
paket add Softoverse.CqrsKit --version 10.0.0
#r "nuget: Softoverse.CqrsKit, 10.0.0"
#:package Softoverse.CqrsKit@10.0.0
#addin nuget:?package=Softoverse.CqrsKit&version=10.0.0
#tool nuget:?package=Softoverse.CqrsKit&version=10.0.0
CqrsKit
CqrsKit is a lightweight, flexible library for implementing the CQRS (Command Query Responsibility Segregation) pattern in .NET applications with dependency injection. It provides a clean and intuitive API for separating read and write operations while offering powerful features like execution filters, approval flows, result patterns, and comprehensive lifecycle hooks.
Features
- ✅ Clean CQRS Implementation - Separate Commands and Queries with distinct handlers
- ✅ Dependency Injection Native - Seamless integration with Microsoft.Extensions.DependencyInjection
- ✅ Execution Filters - Global and specific filters for cross-cutting concerns (similar to ASP.NET Action Filters)
- ✅ Approval Flow Support - Built-in approval/rejection workflow for commands
- ✅ Result Pattern - Strongly-typed success/error results with detailed error handling
- ✅ Lifecycle Hooks -
OnStart,Validate,Handle, andOnEndmethods for fine-grained control - ✅ Async-First - Full async/await support throughout
- ✅ Minimal Overhead - Uses dependency injection to avoid runtime reflection overhead
- ✅ .NET 10 Support - Built for the latest .NET platform
Installation
Install via NuGet Package Manager:
dotnet add package Softoverse.CqrsKit
Or via Package Manager Console:
Install-Package Softoverse.CqrsKit
Quick Start
1. Register CqrsKit
In your Program.cs or Startup.cs:
using Softoverse.CqrsKit.Extensions;
var builder = WebApplication.CreateBuilder(args);
// Register CqrsKit and scan assemblies for handlers
builder.Services.AddCqrsKit(options =>
{
// Scan assembly containing your commands/queries/handlers
options.RegisterServicesFromAssemblyContaining<Program>();
// Optional: Enable logging
options.EnableLogging = true;
});
2. Create a Query
using Softoverse.CqrsKit.Models.Abstraction;
public class GetPersonByIdQuery : IQuery
{
public Guid Id { get; set; }
}
3. Create a Query Handler
using Softoverse.CqrsKit.Abstractions.Handlers;
using Softoverse.CqrsKit.Models;
using Softoverse.CqrsKit.Models.Utility;
[ScopedLifetime]
public class GetPersonByIdQueryHandler : QueryHandler<GetPersonByIdQuery, Person>
{
private readonly IPersonRepository _repository;
public GetPersonByIdQueryHandler(IPersonRepository repository)
{
_repository = repository;
}
public override async Task<Result<Person>> HandleAsync(
GetPersonByIdQuery query,
CqrsContext context,
CancellationToken ct = default)
{
var person = await _repository.GetByIdAsync(query.Id, ct);
return person != null
? Result<Person>.Success().WithPayload(person)
: Result<Person>.Error().WithMessage("Person not found");
}
}
4. Execute the Query
using Softoverse.CqrsKit.Extensions;
public class PersonService
{
private readonly IServiceProvider _serviceProvider;
public PersonService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<Person?> GetPersonAsync(Guid id)
{
var query = new GetPersonByIdQuery { Id = id };
var result = await _serviceProvider.ExecuteQueryAsync<GetPersonByIdQuery, Person>(query);
if (result.IsSuccess)
{
return result.Payload;
}
// Handle error
Console.WriteLine(result.Message);
return null;
}
}
Core Concepts
Commands vs Queries
Commands - Represent write operations that change state:
using Softoverse.CqrsKit.Models.Abstraction;
using Softoverse.CqrsKit.Models.Command;
public class CreatePersonCommand : Command<Person>
{
public CreatePersonCommand(Person person) : base(person) { }
}
Queries - Represent read operations that don't change state:
using Softoverse.CqrsKit.Models.Abstraction;
public class GetAllPersonsQuery : IQuery
{
public string? NameFilter { get; set; }
public int? AgeFilter { get; set; }
}
Handlers
Handlers contain the business logic for processing commands and queries.
Command Handler
using Softoverse.CqrsKit.Abstractions.Handlers;
using Softoverse.CqrsKit.Attributes;
using Softoverse.CqrsKit.Models;
using Softoverse.CqrsKit.Models.Utility;
[ScopedLifetime]
public class CreatePersonCommandHandler : CommandHandler<CreatePersonCommand, Person>
{
private readonly IPersonRepository _repository;
public CreatePersonCommandHandler(IPersonRepository repository)
{
_repository = repository;
}
// Optional: Validate before execution
public override async Task<Result<Person>> ValidateAsync(
CreatePersonCommand command,
CqrsContext context,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(command.Payload.Name))
{
return Result<Person>.Error()
.WithMessage("Name is required")
.WithErrors(new Dictionary<string, string[]>
{
["Name"] = new[] { "Name cannot be empty" }
});
}
return Result<Person>.Success();
}
// Optional: Execute before main handler
public override async Task<Result<Person>> OnStartAsync(
CreatePersonCommand command,
CqrsContext context,
CancellationToken ct = default)
{
// Logging, initialization, etc.
return Result<Person>.Success();
}
// Required: Main business logic
public override async Task<Result<Person>> HandleAsync(
CreatePersonCommand command,
CqrsContext context,
CancellationToken ct = default)
{
var person = await _repository.CreateAsync(command.Payload, ct);
return Result<Person>.Success()
.WithMessage("Person created successfully")
.WithPayload(person);
}
// Optional: Execute after main handler
public override async Task<Result<Person>> OnEndAsync(
CreatePersonCommand command,
CqrsContext context,
CancellationToken ct = default)
{
// Cleanup, notifications, etc.
return Result<Person>.Success();
}
}
Query Handler
[ScopedLifetime]
public class GetAllPersonsQueryHandler : QueryHandler<GetAllPersonsQuery, List<Person>>
{
private readonly IPersonRepository _repository;
public GetAllPersonsQueryHandler(IPersonRepository repository)
{
_repository = repository;
}
public override async Task<Result<List<Person>>> HandleAsync(
GetAllPersonsQuery query,
CqrsContext context,
CancellationToken ct = default)
{
var persons = await _repository.GetAllAsync(
query.NameFilter,
query.AgeFilter,
ct);
return Result<List<Person>>.Create(r => r.Payload?.Count > 0)
.WithPayload(persons)
.WithSuccessMessage($"Found {persons.Count} persons")
.WithErrorMessage("No persons found");
}
}
Execution Lifecycle
The execution lifecycle for commands follows this order:
- ExecutionFilter.OnExecuting - Global pre-execution filter
- AsyncExecutionFilter.OnExecuting - Async pre-execution filter (if configured)
- Approval Flow (if required) - OR proceed to next step
- Handler.Validate - Validation logic (commands only)
- Handler.OnStart - Pre-execution hook
- Handler.Handle - Main business logic ⭐
- Handler.OnEnd - Post-execution hook
- AsyncExecutionFilter.OnExecuted - Async post-execution filter (if configured)
- ExecutionFilter.OnExecuted - Global post-execution filter
For queries, the lifecycle is similar but without validation and approval flow steps.
CqrsContext
The CqrsContext provides contextual information throughout the execution pipeline:
public class MyCommandHandler : CommandHandler<MyCommand, MyResponse>
{
public override async Task<Result<MyResponse>> HandleAsync(
MyCommand command,
CqrsContext context,
CancellationToken ct = default)
{
// Access or store custom data
context.Items["UserId"] = "12345";
context.Items["Timestamp"] = DateTime.UtcNow;
// Access the request
var request = context.Request;
// Store result
context.Result = Result<MyResponse>.Success();
return context.ResultAs<MyResponse>();
}
}
Result Pattern
CqrsKit uses a Result pattern for handling success and error cases:
// Success result with payload
var result = Result<Person>.Success()
.WithMessage("Operation successful")
.WithPayload(person);
// Error result with message
var errorResult = Result<Person>.Error()
.WithMessage("Operation failed");
// Error result with validation errors
var validationResult = Result<Person>.Error()
.WithMessage("Validation failed")
.WithErrors(new Dictionary<string, string[]>
{
["Name"] = new[] { "Name is required" },
["Age"] = new[] { "Age must be positive" }
});
// Conditional result
var conditionalResult = Result<List<Person>>.Create(r => r.Payload?.Count > 0)
.WithPayload(persons)
.WithSuccessMessage("Data found")
.WithErrorMessage("No data found");
// Check result
if (result.IsSuccess)
{
var data = result.Payload;
Console.WriteLine(result.Message);
}
else
{
Console.WriteLine(result.Message);
foreach (var error in result.Errors)
{
Console.WriteLine($"{error.Key}: {string.Join(", ", error.Value)}");
}
}
Advanced Features
Execution Filters
Execution filters allow you to implement cross-cutting concerns like logging, authorization, validation, caching, etc.
Global Execution Filter
Create a global filter for all commands:
using Softoverse.CqrsKit.Abstractions.Filters;
using Softoverse.CqrsKit.Attributes;
using Softoverse.CqrsKit.Models;
using Softoverse.CqrsKit.Models.Abstraction;
using Softoverse.CqrsKit.Models.Utility;
[ScopedLifetime]
public class CommandExecutionFilter<TCommand, TResponse> : CommandExecutionFilterBase<TCommand, TResponse>
where TCommand : ICommand
{
private readonly ILogger<CommandExecutionFilter<TCommand, TResponse>> _logger;
public CommandExecutionFilter(ILogger<CommandExecutionFilter<TCommand, TResponse>> logger)
{
_logger = logger;
}
public override async Task<Result<TResponse>> OnExecutingAsync(
TCommand command,
CqrsContext context,
CancellationToken ct = default)
{
_logger.LogInformation("Executing command: {CommandType}", typeof(TCommand).Name);
return Result<TResponse>.Success();
}
public override async Task<Result<TResponse>> OnExecutedAsync(
TCommand command,
CqrsContext context,
CancellationToken ct = default)
{
_logger.LogInformation("Executed command: {CommandType}", typeof(TCommand).Name);
return Result<TResponse>.Success();
}
}
Specific Execution Filter
Create a filter for a specific command:
[ScopedLifetime]
public class CreatePersonCommandExecutionFilter : ExecutionFilterBase<CreatePersonCommand, Person>
{
public override async Task<Result<Person>> OnExecutingAsync(
CreatePersonCommand command,
CqrsContext context,
CancellationToken ct = default)
{
// Authorization check
var userId = context.Items["UserId"] as string;
if (string.IsNullOrEmpty(userId))
{
return Result<Person>.Error().WithMessage("Unauthorized");
}
return Result<Person>.Success();
}
public override async Task<Result<Person>> OnExecutedAsync(
CreatePersonCommand command,
CqrsContext context,
CancellationToken ct = default)
{
// Send notification, audit log, etc.
return Result<Person>.Success();
}
}
Async Execution Filter
For async operations that don't need access to the command/query:
[ScopedLifetime]
public class CommandAsyncExecutionFilter<TCommand, TResponse> : AsyncExecutionFilterBase<TCommand, TResponse>
where TCommand : ICommand
{
public override async Task OnExecutingAsync(CqrsContext context, CancellationToken ct = default)
{
// Start a transaction, open a connection, etc.
await Task.CompletedTask;
}
public override async Task OnExecutedAsync(CqrsContext context, CancellationToken ct = default)
{
// Commit transaction, close connection, etc.
await Task.CompletedTask;
}
}
Approval Flow
CqrsKit provides built-in support for approval workflows where commands require approval before execution.
Enable Approval Flow
public class DeletePersonCommand : Command<Guid>, IUniqueCommand
{
public DeletePersonCommand(Guid id) : base(id) { }
// Provide unique identification for approval tracking
public string GetUniqueIdentification()
{
return $"DeletePerson_{Payload}";
}
}
Approval Flow Handler
[TransientLifetime]
public class DeletePersonApprovalFlowHandler : ApprovalFlowHandler<DeletePersonCommand, Guid>
{
public override async Task<Result<Guid>> OnStartAsync(
DeletePersonCommand command,
CqrsContext context,
CancellationToken ct = default)
{
// Execute when approval flow starts
return Result<Guid>.Success()
.WithMessage("Approval flow initiated");
}
public override async Task<Result<Guid>> OnEndAsync(
DeletePersonCommand command,
CqrsContext context,
CancellationToken ct = default)
{
// Execute when approval flow ends (before accept/reject)
return Result<Guid>.Success();
}
public override async Task<Result<Guid>> AfterAcceptAsync(
DeletePersonCommand command,
CqrsContext context,
CancellationToken ct = default)
{
// Execute after approval is accepted
return Result<Guid>.Success()
.WithMessage("Delete operation approved");
}
public override async Task<Result<Guid>> AfterRejectAsync(
DeletePersonCommand command,
CqrsContext context,
CancellationToken ct = default)
{
// Execute after approval is rejected
return Result<Guid>.Success()
.WithMessage("Delete operation rejected");
}
}
Approval Flow Service
Implement IApprovalFlowService to control approval logic:
public class CustomApprovalFlowService : ApprovalFlowServiceBase
{
public override async Task<bool> IsApprovalFlowRequiredAsync(
CqrsContext context,
Type commandType,
CancellationToken ct = default)
{
// Determine if approval is required for this command
return commandType == typeof(DeletePersonCommand);
}
public override async Task<bool> IsApprovalFlowPendingTaskUniqueAsync(
ICommand command,
CqrsContext context,
CancellationToken ct = default)
{
// Check if this command is already pending approval
if (command is IUniqueCommand uniqueCommand)
{
var uniqueId = uniqueCommand.GetUniqueIdentification();
// Check against your pending approval store
return !await _approvalRepository.ExistsAsync(uniqueId, ct);
}
return true;
}
}
Dependency Injection Lifetime Attributes
Control the lifetime of your handlers and services:
using Softoverse.CqrsKit.Attributes;
// Scoped lifetime (default)
[ScopedLifetime]
public class MyCommandHandler : CommandHandler<MyCommand, MyResponse> { }
// Transient lifetime
[TransientLifetime]
public class MyQueryHandler : QueryHandler<MyQuery, MyResponse> { }
// Singleton lifetime
[SingletonLifetime]
public class MyCachedService { }
Grouping
Organize commands and queries with the [Group] attribute:
using Softoverse.CqrsKit.Attributes;
[Group("Person")]
[Description("Create a new person")]
public class CreatePersonCommand : Command<Person> { }
[Group("Person")]
[Description("Get person by ID")]
public class GetPersonByIdQuery : IQuery { }
In ASP.NET Core Controllers
using Microsoft.AspNetCore.Mvc;
using Softoverse.CqrsKit.Extensions;
[ApiController]
[Route("api/[controller]")]
public class PersonsController : ControllerBase
{
private readonly IServiceProvider _serviceProvider;
public PersonsController(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var query = new GetPersonByIdQuery { Id = id };
var result = await _serviceProvider.ExecuteQueryAsync<GetPersonByIdQuery, Person>(query, ct);
return result.IsSuccess
? Ok(result.Payload)
: NotFound(result.Message);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] Person person, CancellationToken ct)
{
var command = new CreatePersonCommand(person);
var result = await _serviceProvider.ExecuteCommandAsync<CreatePersonCommand, Person>(command, ct);
return result.IsSuccess
? CreatedAtAction(nameof(GetById), new { id = result.Payload.Id }, result.Payload)
: BadRequest(new { result.Message, result.Errors });
}
}
Migration from .NET 9
If you're upgrading from CqrsKit 9.x:
- Update your target framework to
net10.0in all project files - Update package reference:
<PackageReference Include="Softoverse.CqrsKit" Version="10.0.0" /> - Update Microsoft.Extensions.* packages to version
10.0.0or later - Rebuild your solution
No breaking API changes were introduced in version 10.0.0.
Best Practices
- Keep Handlers Focused - Each handler should have a single responsibility
- Use Dependency Injection - Inject repositories and services into handlers
- Leverage Lifecycle Hooks - Use
OnStartfor initialization,OnEndfor cleanup - Validate Early - Use the
ValidateAsyncmethod in command handlers for validation - Use Result Pattern - Always return
Result<T>for consistent error handling - Organize with Groups - Use
[Group]attributes for better organization - Apply Filters Wisely - Use global filters for common concerns, specific filters for unique cases
- Handle Cancellation - Always pass and respect
CancellationTokenparameters
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This project is licensed under the Apache-2.0 License - see the LICENSE file for details.
Author
Abir Mahmud - @mahmudabir
Repository
https://github.com/softoverse/CqrsKit
Support
If you find this library useful, please give it a ⭐ on GitHub!
For issues and questions, please use the GitHub Issues page.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.