From 362dd2f0570e98761ac753cb912c41084bd31990 Mon Sep 17 00:00:00 2001 From: Enes Hoxha Date: Thu, 24 Jul 2025 14:34:53 +0200 Subject: [PATCH] feat(mediator:119): add support for command return types (#119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make ICommand and ICommandHandler generic (TResult) - Update IMediator / Mediator to return TResult from commands - Generalize pipeline & logging behaviors to TResult - CommandPipelineBehavior - LoggingCommandBehavior - add LoggingQueryBehavior - add Common/Unit.cs to represent “void” command results - Update DI helpers (MediatorOptions*, ServiceCollectionExtensions) - README updated BREAKING CHANGE: All command handlers must now implement ICommandHandler. For commands that previously returned nothing, return Unit. --- .../Behaviors/LoggingCommandBehavior.cs | 16 +++--- .../Behaviors/LoggingQueryBehavior.cs | 56 +++++++++++++++++++ src/Cortex.Mediator/Commands/ICommand.cs | 5 +- .../Commands/ICommandHandler.cs | 7 ++- .../Commands/ICommandPipelineBehavior.cs | 10 ++-- src/Cortex.Mediator/Common/Unit.cs | 8 +++ .../DependencyInjection/MediatorOptions.cs | 39 ++++++++++++- .../MediatorOptionsExtensions.cs | 5 +- .../ServiceCollectionExtensions.cs | 4 +- src/Cortex.Mediator/IMediator.cs | 6 +- src/Cortex.Mediator/Mediator.cs | 28 +++++----- src/Cortex.Mediator/README.md | 16 +++--- src/Cortex.Tests/Cortex.Tests.csproj | 10 ++-- 13 files changed, 158 insertions(+), 52 deletions(-) create mode 100644 src/Cortex.Mediator/Behaviors/LoggingQueryBehavior.cs create mode 100644 src/Cortex.Mediator/Common/Unit.cs diff --git a/src/Cortex.Mediator/Behaviors/LoggingCommandBehavior.cs b/src/Cortex.Mediator/Behaviors/LoggingCommandBehavior.cs index a5d9546..2948667 100644 --- a/src/Cortex.Mediator/Behaviors/LoggingCommandBehavior.cs +++ b/src/Cortex.Mediator/Behaviors/LoggingCommandBehavior.cs @@ -10,19 +10,19 @@ namespace Cortex.Mediator.Behaviors /// /// Pipeline behavior for logging command/query execution. /// - public sealed class LoggingCommandBehavior : ICommandPipelineBehavior - where TCommand : ICommand + public sealed class LoggingCommandBehavior : ICommandPipelineBehavior + where TCommand : ICommand { - private readonly ILogger> _logger; + private readonly ILogger> _logger; - public LoggingCommandBehavior(ILogger> logger) + public LoggingCommandBehavior(ILogger> logger) { _logger = logger; } - public async Task Handle( + public async Task Handle( TCommand command, - CommandHandlerDelegate next, + CommandHandlerDelegate next, CancellationToken cancellationToken) { var commandName = typeof(TCommand).Name; @@ -31,13 +31,15 @@ public async Task Handle( var stopwatch = Stopwatch.StartNew(); // start timing try { - await next(); + var result = await next(); stopwatch.Stop(); _logger.LogInformation( "Command {CommandName} executed successfully in {ElapsedMilliseconds} ms", commandName, stopwatch.ElapsedMilliseconds); + + return result; } catch (Exception ex) { diff --git a/src/Cortex.Mediator/Behaviors/LoggingQueryBehavior.cs b/src/Cortex.Mediator/Behaviors/LoggingQueryBehavior.cs new file mode 100644 index 0000000..7c2b275 --- /dev/null +++ b/src/Cortex.Mediator/Behaviors/LoggingQueryBehavior.cs @@ -0,0 +1,56 @@ +using Cortex.Mediator.Queries; +using Microsoft.Extensions.Logging; +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Cortex.Mediator.Behaviors +{ + /// + /// Pipeline behavior for logging command/query execution. + /// + public sealed class LoggingQueryBehavior : IQueryPipelineBehavior + where TQuery : IQuery + { + private readonly ILogger> _logger; + + public LoggingQueryBehavior(ILogger> logger) + { + _logger = logger; + } + + public async Task Handle( + TQuery command, + QueryHandlerDelegate next, + CancellationToken cancellationToken) + { + var queryName = typeof(TQuery).Name; + _logger.LogInformation("Executing query {QueryName}", queryName); + + var stopwatch = Stopwatch.StartNew(); // start timing + try + { + var result = await next(); + + stopwatch.Stop(); + _logger.LogInformation( + "Query {QueryName} executed successfully in {ElapsedMilliseconds} ms", + queryName, + stopwatch.ElapsedMilliseconds); + + return result; + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError( + ex, + "Error executing query {QueryName} after {ElapsedMilliseconds} ms", + queryName, + stopwatch.ElapsedMilliseconds); + throw; + } + } + } +} diff --git a/src/Cortex.Mediator/Commands/ICommand.cs b/src/Cortex.Mediator/Commands/ICommand.cs index 643631c..fdc2226 100644 --- a/src/Cortex.Mediator/Commands/ICommand.cs +++ b/src/Cortex.Mediator/Commands/ICommand.cs @@ -2,9 +2,10 @@ { /// /// Represents a command in the CQRS pattern. - /// Commands are used to change the system state and do not return a value. + /// Commands are used to change the system state and do return a value. + /// Please note that this is not a common practice in CQRS, as commands typically do not return values. /// - public interface ICommand + public interface ICommand { } } diff --git a/src/Cortex.Mediator/Commands/ICommandHandler.cs b/src/Cortex.Mediator/Commands/ICommandHandler.cs index 8b908b5..574386c 100644 --- a/src/Cortex.Mediator/Commands/ICommandHandler.cs +++ b/src/Cortex.Mediator/Commands/ICommandHandler.cs @@ -7,14 +7,15 @@ namespace Cortex.Mediator.Commands /// Defines a handler for a command. /// /// The type of command being handled. - public interface ICommandHandler - where TCommand : ICommand + /// The type of command that is being returned. + public interface ICommandHandler + where TCommand : ICommand { /// /// Handles the specified command. /// /// The command to handle. /// The cancellation token. - Task Handle(TCommand command, CancellationToken cancellationToken); + Task Handle(TCommand command, CancellationToken cancellationToken); } } diff --git a/src/Cortex.Mediator/Commands/ICommandPipelineBehavior.cs b/src/Cortex.Mediator/Commands/ICommandPipelineBehavior.cs index 3fd3d08..b1e4a9c 100644 --- a/src/Cortex.Mediator/Commands/ICommandPipelineBehavior.cs +++ b/src/Cortex.Mediator/Commands/ICommandPipelineBehavior.cs @@ -7,20 +7,20 @@ namespace Cortex.Mediator.Commands /// Defines a pipeline behavior for wrapping command handlers. /// /// The type of command being handled. - public interface ICommandPipelineBehavior - where TCommand : ICommand + public interface ICommandPipelineBehavior + where TCommand : ICommand { /// /// Handles the command and invokes the next behavior in the pipeline. /// - Task Handle( + Task Handle( TCommand command, - CommandHandlerDelegate next, + CommandHandlerDelegate next, CancellationToken cancellationToken); } /// /// Represents a delegate that wraps the command handler execution. /// - public delegate Task CommandHandlerDelegate(); + public delegate Task CommandHandlerDelegate(); } diff --git a/src/Cortex.Mediator/Common/Unit.cs b/src/Cortex.Mediator/Common/Unit.cs new file mode 100644 index 0000000..3829888 --- /dev/null +++ b/src/Cortex.Mediator/Common/Unit.cs @@ -0,0 +1,8 @@ +namespace Cortex.Mediator +{ + public readonly struct Unit + { + public static readonly Unit Value = new(); + public override string ToString() => "()"; + } +} diff --git a/src/Cortex.Mediator/DependencyInjection/MediatorOptions.cs b/src/Cortex.Mediator/DependencyInjection/MediatorOptions.cs index f8a69b3..415dfea 100644 --- a/src/Cortex.Mediator/DependencyInjection/MediatorOptions.cs +++ b/src/Cortex.Mediator/DependencyInjection/MediatorOptions.cs @@ -13,18 +13,37 @@ public class MediatorOptions public bool OnlyPublicClasses { get; set; } = true; + + /// + /// Register a *closed* command pipeline behavior. + /// public MediatorOptions AddCommandPipelineBehavior() - where TBehavior : ICommandPipelineBehavior // Add constraint + where TBehavior : class // Add constraint { var behaviorType = typeof(TBehavior); + if (behaviorType.IsGenericTypeDefinition) { throw new ArgumentException("Open generic types must be registered using AddOpenCommandPipelineBehavior"); } + + var implementsInterface = behaviorType + .GetInterfaces() + .Any(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(ICommandPipelineBehavior<,>)); + + if (!implementsInterface) + { + throw new ArgumentException("Type must implement ICommandPipelineBehavior<,>"); + } + CommandBehaviors.Add(behaviorType); return this; } + /// + /// Register an *open generic* command pipeline behavior, e.g. typeof(LoggingCommandBehavior<,>). + /// public MediatorOptions AddOpenCommandPipelineBehavior(Type openGenericBehaviorType) { if (!openGenericBehaviorType.IsGenericTypeDefinition) @@ -32,6 +51,24 @@ public MediatorOptions AddOpenCommandPipelineBehavior(Type openGenericBehaviorTy throw new ArgumentException("Type must be an open generic type definition"); } + var implementsInterface = openGenericBehaviorType + .GetInterfaces() + .Any(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(ICommandPipelineBehavior<,>)); + + // For open generics, interface might not appear in GetInterfaces() yet; check by definition instead. + if (!implementsInterface && + !(openGenericBehaviorType.IsGenericTypeDefinition && + openGenericBehaviorType.GetGenericTypeDefinition() == openGenericBehaviorType)) + { + // Fall back to checking generic arguments count to give a clear error + var ok = openGenericBehaviorType.GetGenericArguments().Length == 2; + if (!ok) + { + throw new ArgumentException("Type must implement ICommandPipelineBehavior<,>"); + } + } + CommandBehaviors.Add(openGenericBehaviorType); return this; } diff --git a/src/Cortex.Mediator/DependencyInjection/MediatorOptionsExtensions.cs b/src/Cortex.Mediator/DependencyInjection/MediatorOptionsExtensions.cs index 7424eaa..76f8d07 100644 --- a/src/Cortex.Mediator/DependencyInjection/MediatorOptionsExtensions.cs +++ b/src/Cortex.Mediator/DependencyInjection/MediatorOptionsExtensions.cs @@ -1,5 +1,4 @@ using Cortex.Mediator.Behaviors; -using Cortex.Mediator.Commands; namespace Cortex.Mediator.DependencyInjection { @@ -8,7 +7,9 @@ public static class MediatorOptionsExtensions public static MediatorOptions AddDefaultBehaviors(this MediatorOptions options) { return options - .AddCommandPipelineBehavior>(); + // Register the open generic logging behavior for commands that return TResult + .AddOpenCommandPipelineBehavior(typeof(LoggingCommandBehavior<,>)) + .AddOpenQueryPipelineBehavior(typeof(LoggingQueryBehavior<,>)); } } } diff --git a/src/Cortex.Mediator/DependencyInjection/ServiceCollectionExtensions.cs b/src/Cortex.Mediator/DependencyInjection/ServiceCollectionExtensions.cs index 4f5fd0a..fc54ea6 100644 --- a/src/Cortex.Mediator/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Cortex.Mediator/DependencyInjection/ServiceCollectionExtensions.cs @@ -45,7 +45,7 @@ private static void RegisterHandlers( services.Scan(scan => scan .FromAssemblies(assemblies) .AddClasses(classes => classes - .AssignableTo(typeof(ICommandHandler<>)), options.OnlyPublicClasses) + .AssignableTo(typeof(ICommandHandler<,>)), options.OnlyPublicClasses) .AsImplementedInterfaces() .WithScopedLifetime()); @@ -69,7 +69,7 @@ private static void RegisterPipelineBehaviors(IServiceCollection services, Media // Command behaviors foreach (var behaviorType in options.CommandBehaviors) { - services.AddTransient(typeof(ICommandPipelineBehavior<>), behaviorType); + services.AddTransient(typeof(ICommandPipelineBehavior<,>), behaviorType); } // Query behaviors (if needed) diff --git a/src/Cortex.Mediator/IMediator.cs b/src/Cortex.Mediator/IMediator.cs index 899679e..7eb104c 100644 --- a/src/Cortex.Mediator/IMediator.cs +++ b/src/Cortex.Mediator/IMediator.cs @@ -11,12 +11,12 @@ namespace Cortex.Mediator /// public interface IMediator { - Task SendAsync( + Task SendCommandAsync( TCommand command, CancellationToken cancellationToken = default) - where TCommand : ICommand; + where TCommand : ICommand; - Task SendAsync( + Task SendQueryAsync( TQuery query, CancellationToken cancellationToken = default) where TQuery : IQuery; diff --git a/src/Cortex.Mediator/Mediator.cs b/src/Cortex.Mediator/Mediator.cs index 63f070a..cd0c521 100644 --- a/src/Cortex.Mediator/Mediator.cs +++ b/src/Cortex.Mediator/Mediator.cs @@ -21,20 +21,20 @@ public Mediator(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } - public async Task SendAsync(TCommand command, CancellationToken cancellationToken = default) - where TCommand : ICommand + public async Task SendCommandAsync(TCommand command, CancellationToken cancellationToken = default) + where TCommand : ICommand { - var handler = _serviceProvider.GetRequiredService>(); + var handler = _serviceProvider.GetRequiredService>(); - foreach (var behavior in _serviceProvider.GetServices>().Reverse()) + foreach (var behavior in _serviceProvider.GetServices>().Reverse()) { - handler = new PipelineBehaviorNextDelegate(behavior, handler); + handler = new PipelineBehaviorNextDelegate(behavior, handler); } - await handler.Handle(command, cancellationToken); + return await handler.Handle(command, cancellationToken); } - public async Task SendAsync(TQuery query, CancellationToken cancellationToken = default) + public async Task SendQueryAsync(TQuery query, CancellationToken cancellationToken = default) where TQuery : IQuery { var handler = _serviceProvider.GetRequiredService>(); @@ -57,21 +57,21 @@ public async Task PublishAsync( await Task.WhenAll(tasks); } - private class PipelineBehaviorNextDelegate : ICommandHandler - where TCommand : ICommand + private class PipelineBehaviorNextDelegate : ICommandHandler + where TCommand : ICommand { - private readonly ICommandPipelineBehavior _behavior; - private readonly ICommandHandler _next; + private readonly ICommandPipelineBehavior _behavior; + private readonly ICommandHandler _next; public PipelineBehaviorNextDelegate( - ICommandPipelineBehavior behavior, - ICommandHandler next) + ICommandPipelineBehavior behavior, + ICommandHandler next) { _behavior = behavior; _next = next; } - public Task Handle(TCommand command, CancellationToken cancellationToken) + public Task Handle(TCommand command, CancellationToken cancellationToken) { return _behavior.Handle( command, diff --git a/src/Cortex.Mediator/README.md b/src/Cortex.Mediator/README.md index d6ddb74..ede9704 100644 --- a/src/Cortex.Mediator/README.md +++ b/src/Cortex.Mediator/README.md @@ -9,9 +9,8 @@ Built as part of the [Cortex Data Framework](https://github.com/buildersoftio/co - ✅ Commands & Queries - ✅ Notifications (Events) - ✅ Pipeline Behaviors -- ✅ FluentValidation +- ✅ FluentValidation - Coming in the next release v1.8 - ✅ Logging -- ✅ Transaction Handling --- @@ -35,7 +34,7 @@ In `Program.cs` or `Startup.cs`: builder.Services.AddCortexMediator( builder.Configuration, new[] { typeof(Program) }, // Assemblies to scan for handlers - options => options.AddDefaultBehaviors() // Logging, Validation, Transaction + options => options.AddDefaultBehaviors() // Logging ); ``` @@ -52,7 +51,7 @@ Features/ ## ✏️ Defining a Command ```csharp -public class CreateUserCommand : ICommand +public class CreateUserCommand : ICommand { public string UserName { get; set; } public string Email { get; set; } @@ -61,16 +60,16 @@ public class CreateUserCommand : ICommand ### Handler ```csharp -public class CreateUserCommandHandler : ICommandHandler +public class CreateUserCommandHandler : ICommandHandler { - public async Task Handle(CreateUserCommand command, CancellationToken cancellationToken) + public async Task Handle(CreateUserCommand command, CancellationToken cancellationToken) { // Logic here } } ``` -### Validator (Optional, via FluentValidation) +### Validator (Optional, via FluentValidation) - Coming in the next release v1.8 ```csharp public class CreateUserValidator : AbstractValidator { @@ -126,9 +125,8 @@ await mediator.PublishAsync(new UserCreatedNotification { UserName = "Andy" }); ## 🔧 Pipeline Behaviors (Built-in) Out of the box, Cortex.Mediator supports: -- `ValidationCommandBehavior` +- `ValidationCommandBehavior` - Coming in the next release v1.8 - `LoggingCommandBehavior` -- `TransactionCommandBehavior` You can also register custom behaviors: ```csharp diff --git a/src/Cortex.Tests/Cortex.Tests.csproj b/src/Cortex.Tests/Cortex.Tests.csproj index 406cf1b..6208286 100644 --- a/src/Cortex.Tests/Cortex.Tests.csproj +++ b/src/Cortex.Tests/Cortex.Tests.csproj @@ -9,6 +9,12 @@ true + + + + + + all @@ -32,8 +38,4 @@ - - - -