Softoverse.CqrsKit 10.0.0

dotnet add package Softoverse.CqrsKit --version 10.0.0
                    
NuGet\Install-Package Softoverse.CqrsKit -Version 10.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Softoverse.CqrsKit" Version="10.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Softoverse.CqrsKit" Version="10.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Softoverse.CqrsKit" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Softoverse.CqrsKit --version 10.0.0
                    
#r "nuget: Softoverse.CqrsKit, 10.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Softoverse.CqrsKit@10.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Softoverse.CqrsKit&version=10.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Softoverse.CqrsKit&version=10.0.0
                    
Install as a Cake Tool

CqrsKit

NuGet License .NET

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, and OnEnd methods 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:

  1. ExecutionFilter.OnExecuting - Global pre-execution filter
  2. AsyncExecutionFilter.OnExecuting - Async pre-execution filter (if configured)
  3. Approval Flow (if required) - OR proceed to next step
  4. Handler.Validate - Validation logic (commands only)
  5. Handler.OnStart - Pre-execution hook
  6. Handler.Handle - Main business logic ⭐
  7. Handler.OnEnd - Post-execution hook
  8. AsyncExecutionFilter.OnExecuted - Async post-execution filter (if configured)
  9. 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:

  1. Update your target framework to net10.0 in all project files
  2. Update package reference: <PackageReference Include="Softoverse.CqrsKit" Version="10.0.0" />
  3. Update Microsoft.Extensions.* packages to version 10.0.0 or later
  4. Rebuild your solution

No breaking API changes were introduced in version 10.0.0.

Best Practices

  1. Keep Handlers Focused - Each handler should have a single responsibility
  2. Use Dependency Injection - Inject repositories and services into handlers
  3. Leverage Lifecycle Hooks - Use OnStart for initialization, OnEnd for cleanup
  4. Validate Early - Use the ValidateAsync method in command handlers for validation
  5. Use Result Pattern - Always return Result<T> for consistent error handling
  6. Organize with Groups - Use [Group] attributes for better organization
  7. Apply Filters Wisely - Use global filters for common concerns, specific filters for unique cases
  8. Handle Cancellation - Always pass and respect CancellationToken parameters

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
10.0.0 116 1/24/2026
9.0.0 115 12/31/2025
5.1.5 239 10/13/2025
5.1.4 236 8/14/2025
5.1.3 210 8/10/2025
5.1.1 261 8/8/2025
5.1.0 257 8/8/2025
5.0.1 258 4/3/2025
5.0.0 203 3/16/2025
1.2.0 297 3/6/2025
1.1.0 185 2/9/2025
1.0.0 183 2/5/2025