Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## softaware.Cqs.DependencyInjection.SourceGenerated

### 1.0.0

A **compile-time** alternative to `softaware.Cqs.DependencyInjection` that uses a Roslyn source generator instead of Scrutor-based runtime reflection to register CQS handlers and decorators.

See [the package-specific README](./src/softaware.Cqs.DependencyInjection.SourceGenerated/README.md) for details.

## All packages

### 4.0.0
Expand Down
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
This project provides a library for the command-query separation pattern.
Commands and queries will be separated on class-level and will be represented by the `ICommand` and `IQuery<TResult>` interfaces.

## Table of Contents

- [Usage](#usage)
- [Queries and Commands](#queries-and-commands)
- [Dependency Injection](#dependency-injection)
- [Microsoft.Extensions.DependencyInjection](#microsoftextensionsdependencyinjection)
- [Microsoft.Extensions.DependencyInjection (Source Generator)](#microsoftextensionsdependencyinjection-source-generator)
- [SimpleInjector](#simpleinjector)
- [Executing Commands/Queries](#executing-commandsqueries)
- [Packages](#packages)
- [Breaking changes in version 4.0](#breaking-changes-in-version-40)

## Usage

### Queries and Commands
Expand Down Expand Up @@ -68,7 +80,7 @@ It is possible to define decorators for specific query or command types (`IReque

### Dependency Injection

The software CQS packages support two dependency injection frameworks:
The softaware CQS packages support two dependency injection frameworks:
1. [Dependency injection in .NET](https://docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection) from the `Microsoft.Extensions.DependencyInjection` package (recommended).
2. [Simple Injector](https://simpleinjector.org/).

Expand Down Expand Up @@ -107,6 +119,21 @@ services

The decorators wrap the handler in the order they are added here (so they are called in opposite order). In this case, the `FluentValidationRequestHandlerDecorator` is the first decorator to be called. The `CommandLoggingDecorator` is the last one and calls the actual handler.

#### Microsoft.Extensions.DependencyInjection (Source Generator)

`softaware.CQS.DependencyInjection.SourceGenerated` is a compile-time alternative to the runtime package above. Instead of using Scrutor's assembly scanning at startup, a Roslyn source generator discovers all handlers at compile time and emits explicit `IServiceCollection` registrations. This eliminates the startup scanning cost and resolves handlers without any runtime reflection.

The API is intentionally identical, with one key difference: **`IncludeTypesFrom` requires a `typeof()` expression** (not an `Assembly`):

```csharp
services
.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyMarkerType)))
.AddDecorators(b => b
.AddRequestHandlerDecorator(typeof(CommandLoggingDecorator<,>)));
```

See the [package README](src/softaware.Cqs.DependencyInjection.SourceGenerated/README.md) for the full migration guide from the runtime package, a list of limitations, and debugging tips.

#### SimpleInjector

When using the `softaware.CQS.SimpleInjector` library, use `AddRequestHandlerDecorator` method to register decorators in the Simple Injector container:
Expand Down Expand Up @@ -169,6 +196,7 @@ The project consists of several separate packages, which allows flexible usage o
| [`softaware.CQS.Analyzers`](src/softaware.Cqs.Analyzers) | | Roslyn analyzers that ensure correct usage of the library. (Shipped with core library.) |
| [`softaware.CQS.SimpleInjector`](src/softaware.Cqs.SimpleInjector) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.SimpleInjector.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.SimpleInjector/) | Adds support for dynamic resolving of commands handlers and query handlers via SimpleInjector. |
| [`softaware.CQS.DependencyInjection`](src/softaware.Cqs.DependencyInjection) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.DependencyInjection.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.DependencyInjection/) | Adds support for dynamic resolving of commands handlers and query handlers via `Microsoft.Extensions.DependencyInjection`. |
| [`softaware.CQS.DependencyInjection.SourceGenerated`](src/softaware.Cqs.DependencyInjection.SourceGenerated) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.DependencyInjection.SourceGenerated.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.DependencyInjection.SourceGenerated/) | Compile-time alternative to `softaware.CQS.DependencyInjection` using a Roslyn source generator. No runtime assembly scanning. |
| [`softaware.CQS.Decorators.Transaction`](src/softaware.Cqs.Decorators.Transaction) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.Decorators.Transaction.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.Decorators.Transaction/) | A decorator for command-query architecture, which supports transactions. |
| [`softaware.CQS.Decorators.Transaction.DependencyInjection`](src/softaware.Cqs.Decorators.Transaction.DependencyInjection) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.Decorators.Transaction.DependencyInjection.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.Decorators.Transaction.DependencyInjection/) | Builder extensions for adding decorators to Microsoft's DI. |
| [`softaware.CQS.Decorators.Validation`](src/softaware.Cqs.Decorators.Validation) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.Decorators.Validation.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.Decorators.Validation/) | A decorator for command-query architecture, which supports validation of data annotations. |
Expand Down
4 changes: 2 additions & 2 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ variables:

steps:
- task: UseDotNet@2
displayName: 'Use .NET 6'
displayName: 'Use .NET 10'
inputs:
version: 6.0.x
version: 10.0.x

- script: dotnet build $(solutionFile) --configuration $(buildConfiguration)
displayName: 'dotnet build $(buildConfiguration)'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
Expand All @@ -11,8 +11,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="1.1.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.2.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="1.1.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ public class IRequestShouldNotBeImplementedDirectlyAnalyzer : DiagnosticAnalyzer
private static readonly LocalizableString Description = new LocalizableResourceString(nameof(Resources.AnalyzerDescription), Resources.ResourceManager, typeof(Resources));
private const string Category = "Usage";

#pragma warning disable IDE0090 // Use 'new(...)' https://github.com/dotnet/roslyn-analyzers/issues/5828
private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description);
#pragma warning restore IDE0090 // Use 'new(...)'
private static readonly DiagnosticDescriptor Rule = new(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

Expand Down
11 changes: 6 additions & 5 deletions src/softaware.Cqs.Analyzers/softaware.Cqs.Analyzers.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>softaware.CQS.Analyzers</PackageId>
<PackageVersion>4.0.0</PackageVersion>
<PackageVersion>4.0.1</PackageVersion>
<Authors>softaware gmbh</Authors>
<Company>softaware gmbh</Company>
<PackageIcon>package-icon.png</PackageIcon>
Expand All @@ -19,17 +19,18 @@
<PackageTags>softaware, CQS, command-query-separation</PackageTags>
<DevelopmentDependency>true</DevelopmentDependency>
<NoPackageAnalysis>true</NoPackageAnalysis>
<FileVersion>4.0.0.0</FileVersion>
<AssemblyVersion>4.0.0.0</AssemblyVersion>
<FileVersion>4.0.1.0</FileVersion>
<AssemblyVersion>4.0.1.0</AssemblyVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);_AddAnalyzersToOutput</TargetsForTfmSpecificContentInPackage>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3">
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.2.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
Expand Down
21 changes: 21 additions & 0 deletions src/softaware.Cqs.Benchmarks/Contracts/Commands/Commands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace softaware.Cqs.Benchmarks.Contracts.Commands;

public class SimpleCommand : ICommand
{
public int Value { get; set; }
}

public class AccessCheckedCommand : ICommand, IAccessChecked
{
public bool AccessCheckEvaluated { get; set; }
}

public class HighPriorityCommand : ICommand, IPrioritized
{
public int Priority { get; set; } = 1;
}

public class CommandWithResult : ICommand<int>
{
public int Input { get; set; }
}
17 changes: 17 additions & 0 deletions src/softaware.Cqs.Benchmarks/Contracts/Interfaces.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace softaware.Cqs.Benchmarks.Contracts;

/// <summary>
/// Marker interface for access-checked requests (command or query).
/// </summary>
public interface IAccessChecked
{
bool AccessCheckEvaluated { get; set; }
}

/// <summary>
/// Marker interface for prioritized requests.
/// </summary>
public interface IPrioritized
{
int Priority { get; set; }
}
16 changes: 16 additions & 0 deletions src/softaware.Cqs.Benchmarks/Contracts/Queries/Queries.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace softaware.Cqs.Benchmarks.Contracts.Queries;

public class GetSquare : IQuery<int>
{
public int Value { get; set; }
}

public class GetGreeting : IQuery<string>
{
public string Name { get; set; } = "";
}

public class AccessCheckedQuery : IQuery<bool>, IAccessChecked
{
public bool AccessCheckEvaluated { get; set; }
}
78 changes: 78 additions & 0 deletions src/softaware.Cqs.Benchmarks/CqsBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using softaware.Cqs.Benchmarks.Contracts.Commands;
using softaware.Cqs.Benchmarks.Contracts.Queries;

namespace softaware.Cqs.Benchmarks;

[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class CqsBenchmarks
{
private IRequestProcessor runtimeProcessor = null!;
private IRequestProcessor generatedProcessor = null!;
private IServiceProvider runtimeServiceProvider = null!;
private IServiceProvider generatedServiceProvider = null!;

[GlobalSetup]
public void Setup()
{
// Setup runtime (Scrutor-based) approach
this.runtimeServiceProvider = RuntimeSetup.CreateServiceProvider();
this.runtimeProcessor = this.runtimeServiceProvider.GetRequiredService<IRequestProcessor>();

// Setup compile-time (source-generated) approach
this.generatedServiceProvider = SourceGeneratedSetup.CreateServiceProvider();
this.generatedProcessor = this.generatedServiceProvider.GetRequiredService<IRequestProcessor>();
}

// --- Startup benchmarks ---

[Benchmark(Description = "Runtime: DI Container Build")]
public static IServiceProvider Runtime_ContainerBuild()
=> RuntimeSetup.CreateServiceProvider();

[Benchmark(Description = "Generated: DI Container Build")]
public static IServiceProvider Generated_ContainerBuild()
=> SourceGeneratedSetup.CreateServiceProvider();

// --- Command execution benchmarks ---

[Benchmark(Description = "Runtime: Execute SimpleCommand")]
public async Task Runtime_SimpleCommand()
=> await this.runtimeProcessor.HandleAsync(new SimpleCommand { Value = 1 }, default);

[Benchmark(Description = "Generated: Execute SimpleCommand")]
public async Task Generated_SimpleCommand()
=> await this.generatedProcessor.HandleAsync(new SimpleCommand { Value = 1 }, default);

// --- Query execution benchmarks ---

[Benchmark(Description = "Runtime: Execute GetSquare")]
public async Task<int> Runtime_GetSquare()
=> await this.runtimeProcessor.HandleAsync(new GetSquare { Value = 5 }, default);

[Benchmark(Description = "Generated: Execute GetSquare")]
public async Task<int> Generated_GetSquare()
=> await this.generatedProcessor.HandleAsync(new GetSquare { Value = 5 }, default);

// --- Access-checked command (multiple decorator constraints) ---

[Benchmark(Description = "Runtime: Execute AccessCheckedCommand")]
public async Task Runtime_AccessCheckedCommand()
=> await this.runtimeProcessor.HandleAsync(new AccessCheckedCommand(), default);

[Benchmark(Description = "Generated: Execute AccessCheckedCommand")]
public async Task Generated_AccessCheckedCommand()
=> await this.generatedProcessor.HandleAsync(new AccessCheckedCommand(), default);

// --- Query with caching + logging decorators ---

[Benchmark(Description = "Runtime: Execute GetGreeting")]
public async Task<string> Runtime_GetGreeting()
=> await this.runtimeProcessor.HandleAsync(new GetGreeting { Name = "World" }, default);

[Benchmark(Description = "Generated: Execute GetGreeting")]
public async Task<string> Generated_GetGreeting()
=> await this.generatedProcessor.HandleAsync(new GetGreeting { Name = "World" }, default);
}
84 changes: 84 additions & 0 deletions src/softaware.Cqs.Benchmarks/Decorators/Decorators.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using softaware.Cqs.Benchmarks.Contracts;

namespace softaware.Cqs.Benchmarks.Decorators;

/// <summary>
/// Applies to ALL requests (where TRequest : IRequest&lt;TResult&gt;).
/// Simulates a logging/telemetry decorator.
/// </summary>
public class LoggingDecorator<TRequest, TResult> : IRequestHandler<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly IRequestHandler<TRequest, TResult> decoratee;

public LoggingDecorator(IRequestHandler<TRequest, TResult> decoratee)
=> this.decoratee = decoratee;

public Task<TResult> HandleAsync(TRequest request, CancellationToken ct)
=> this.decoratee.HandleAsync(request, ct);
}

/// <summary>
/// Applies only to COMMANDS (where TRequest : ICommand&lt;TResult&gt;).
/// Simulates a transaction decorator.
/// </summary>
public class TransactionDecorator<TRequest, TResult> : IRequestHandler<TRequest, TResult>
where TRequest : ICommand<TResult>
{
private readonly IRequestHandler<TRequest, TResult> decoratee;

public TransactionDecorator(IRequestHandler<TRequest, TResult> decoratee)
=> this.decoratee = decoratee;

public Task<TResult> HandleAsync(TRequest request, CancellationToken ct)
=> this.decoratee.HandleAsync(request, ct);
}

/// <summary>
/// Applies only to QUERIES (where TRequest : IQuery&lt;TResult&gt;).
/// Simulates a caching decorator.
/// </summary>
public class CachingDecorator<TRequest, TResult> : IRequestHandler<TRequest, TResult>
where TRequest : IQuery<TResult>
{
private readonly IRequestHandler<TRequest, TResult> decoratee;

public CachingDecorator(IRequestHandler<TRequest, TResult> decoratee)
=> this.decoratee = decoratee;

public Task<TResult> HandleAsync(TRequest request, CancellationToken ct)
=> this.decoratee.HandleAsync(request, ct);
}

/// <summary>
/// Applies only to requests implementing IAccessChecked (multiple interface constraints).
/// </summary>
public class AccessCheckDecorator<TRequest, TResult> : IRequestHandler<TRequest, TResult>
where TRequest : IRequest<TResult>, IAccessChecked
{
private readonly IRequestHandler<TRequest, TResult> decoratee;

public AccessCheckDecorator(IRequestHandler<TRequest, TResult> decoratee)
=> this.decoratee = decoratee;

public Task<TResult> HandleAsync(TRequest request, CancellationToken ct)
{
request.AccessCheckEvaluated = true;
return this.decoratee.HandleAsync(request, ct);
}
}

/// <summary>
/// Applies only to prioritized commands (two constraints: ICommand + IPrioritized).
/// </summary>
public class PriorityDecorator<TRequest, TResult> : IRequestHandler<TRequest, TResult>
where TRequest : ICommand<TResult>, IPrioritized
{
private readonly IRequestHandler<TRequest, TResult> decoratee;

public PriorityDecorator(IRequestHandler<TRequest, TResult> decoratee)
=> this.decoratee = decoratee;

public Task<TResult> HandleAsync(TRequest request, CancellationToken ct)
=> this.decoratee.HandleAsync(request, ct);
}
8 changes: 8 additions & 0 deletions src/softaware.Cqs.Benchmarks/GlobalSuppressions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.

using System.Diagnostics.CodeAnalysis;

[assembly: SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "For benchmarking purposes", Scope = "namespaceanddescendants", Target = "~N:softaware.Cqs.Benchmarks")]
1 change: 1 addition & 0 deletions src/softaware.Cqs.Benchmarks/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using Microsoft.Extensions.DependencyInjection;
Loading