From bbbc37a14f5b2d9aca22f5fc0190f1f0f63d452b Mon Sep 17 00:00:00 2001 From: Mark Derman Date: Sat, 11 Apr 2026 16:01:20 +0200 Subject: [PATCH] Refactor query abstractions into a separate project; implement `ServiceProviderQueryDispatcher` and `FakeQueryHandler` for enhanced query dispatch handling. --- Odin.sln | 7 ++ .../IQuery.cs | 3 +- .../Queries.Abstractions/IQueryDispatcher.cs | 21 ++++ .../IQueryHandler.cs | 6 +- .../Odin.Patterns.Queries.Abstractions.csproj | 19 ++++ Patterns/Queries/FakeQueryHandler.cs | 32 ++++++ Patterns/Queries/Odin.Patterns.Queries.csproj | 13 ++- .../Queries/ServiceProviderQueryDispatcher.cs | 105 ++++++++++++++++++ 8 files changed, 200 insertions(+), 6 deletions(-) rename Patterns/{Queries => Queries.Abstractions}/IQuery.cs (81%) create mode 100644 Patterns/Queries.Abstractions/IQueryDispatcher.cs rename Patterns/{Queries => Queries.Abstractions}/IQueryHandler.cs (76%) create mode 100644 Patterns/Queries.Abstractions/Odin.Patterns.Queries.Abstractions.csproj create mode 100644 Patterns/Queries/FakeQueryHandler.cs create mode 100644 Patterns/Queries/ServiceProviderQueryDispatcher.cs diff --git a/Odin.sln b/Odin.sln index 7a01577..97aaa17 100644 --- a/Odin.sln +++ b/Odin.sln @@ -122,6 +122,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Odin.Testing.NUnitUtility", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Odin.Patterns.Commands.Abstractions", "Patterns\Commands.Abstractions\Odin.Patterns.Commands.Abstractions.csproj", "{7749636C-2D34-4E0B-81C9-9E89C5630858}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Odin.Patterns.Queries.Abstractions", "Patterns\Queries.Abstractions\Odin.Patterns.Queries.Abstractions.csproj", "{6B3B86A4-CB53-4E25-8681-31245BA33961}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -280,6 +282,10 @@ Global {7749636C-2D34-4E0B-81C9-9E89C5630858}.Debug|Any CPU.Build.0 = Debug|Any CPU {7749636C-2D34-4E0B-81C9-9E89C5630858}.Release|Any CPU.ActiveCfg = Release|Any CPU {7749636C-2D34-4E0B-81C9-9E89C5630858}.Release|Any CPU.Build.0 = Release|Any CPU + {6B3B86A4-CB53-4E25-8681-31245BA33961}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B3B86A4-CB53-4E25-8681-31245BA33961}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B3B86A4-CB53-4E25-8681-31245BA33961}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B3B86A4-CB53-4E25-8681-31245BA33961}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {CE323D9C-635B-EFD3-5B3F-7CE371D8A86A} = {BF440C74-E223-3CBF-8FA7-83F7D164F7C3} @@ -320,5 +326,6 @@ Global {1491A6A6-ED85-4AD0-A6EE-57F4CAF3192B} = {605A7674-8EA4-458D-9FEB-A86C927AC0F0} {46BCDE44-FA4E-4AE0-A6C0-9AFAE1BE09DF} = {51D0FF83-33F9-490F-A0CB-A32DC1CC48AF} {7749636C-2D34-4E0B-81C9-9E89C5630858} = {605A7674-8EA4-458D-9FEB-A86C927AC0F0} + {6B3B86A4-CB53-4E25-8681-31245BA33961} = {605A7674-8EA4-458D-9FEB-A86C927AC0F0} EndGlobalSection EndGlobal diff --git a/Patterns/Queries/IQuery.cs b/Patterns/Queries.Abstractions/IQuery.cs similarity index 81% rename from Patterns/Queries/IQuery.cs rename to Patterns/Queries.Abstractions/IQuery.cs index 956eea9..36b1281 100644 --- a/Patterns/Queries/IQuery.cs +++ b/Patterns/Queries.Abstractions/IQuery.cs @@ -2,8 +2,7 @@ namespace Odin.Patterns.Queries; /// /// Defines a query request that returns -/// one or other for of data or a result. +/// one or other form of data or a result. /// /// public interface IQuery { } - diff --git a/Patterns/Queries.Abstractions/IQueryDispatcher.cs b/Patterns/Queries.Abstractions/IQueryDispatcher.cs new file mode 100644 index 0000000..277f0ea --- /dev/null +++ b/Patterns/Queries.Abstractions/IQueryDispatcher.cs @@ -0,0 +1,21 @@ +namespace Odin.Patterns.Queries; + +/// +/// Dispatches queries to their registered query handlers. +/// +public interface IQueryDispatcher +{ + /// + /// Dispatches a query that returns a result + /// to its registered query handler. + /// + /// + /// + /// + /// + /// + Task DispatchAsync( + TQuery query, + CancellationToken ct = default) + where TQuery : IQuery; +} diff --git a/Patterns/Queries/IQueryHandler.cs b/Patterns/Queries.Abstractions/IQueryHandler.cs similarity index 76% rename from Patterns/Queries/IQueryHandler.cs rename to Patterns/Queries.Abstractions/IQueryHandler.cs index b82db74..732614b 100644 --- a/Patterns/Queries/IQueryHandler.cs +++ b/Patterns/Queries.Abstractions/IQueryHandler.cs @@ -1,11 +1,11 @@ namespace Odin.Patterns.Queries; /// -/// Defines the handling implementation for a command request that returns a Result. +/// Defines the handling implementation for a query request that returns a result. /// /// /// -public interface IQueryHandler +public interface IQueryHandler where TQuery : IQuery { /// @@ -15,4 +15,4 @@ public interface IQueryHandler /// /// Task HandleAsync(TQuery query, CancellationToken ct = default); -} \ No newline at end of file +} diff --git a/Patterns/Queries.Abstractions/Odin.Patterns.Queries.Abstractions.csproj b/Patterns/Queries.Abstractions/Odin.Patterns.Queries.Abstractions.csproj new file mode 100644 index 0000000..f79aec0 --- /dev/null +++ b/Patterns/Queries.Abstractions/Odin.Patterns.Queries.Abstractions.csproj @@ -0,0 +1,19 @@ + + + net8.0;net9.0;net10.0 + true + enable + icon.png + README.md + Odin.Patterns.Queries + All abstractions for the query dispatch pattern. + + + 1591;1573; + + + + + + + diff --git a/Patterns/Queries/FakeQueryHandler.cs b/Patterns/Queries/FakeQueryHandler.cs new file mode 100644 index 0000000..177f4fc --- /dev/null +++ b/Patterns/Queries/FakeQueryHandler.cs @@ -0,0 +1,32 @@ +namespace Odin.Patterns.Queries; + +/// +/// Returns the result passed in via the constructor. +/// +/// +/// +public class FakeQueryHandler : IQueryHandler + where TQuery : IQuery +{ + private readonly TResult _resultToReturn; + + /// + /// Initialise to return 'result' on HandleAsync. + /// + /// + public FakeQueryHandler(TResult result) + { + _resultToReturn = result; + } + + /// + /// Does nothing. + /// + /// + /// + /// + public async Task HandleAsync(TQuery query, CancellationToken ct = default) + { + return await Task.FromResult(_resultToReturn); + } +} \ No newline at end of file diff --git a/Patterns/Queries/Odin.Patterns.Queries.csproj b/Patterns/Queries/Odin.Patterns.Queries.csproj index 746cd50..2d71dcd 100644 --- a/Patterns/Queries/Odin.Patterns.Queries.csproj +++ b/Patterns/Queries/Odin.Patterns.Queries.csproj @@ -5,12 +5,23 @@ enable icon.png README.md - + Implementation of a query dispatch pattern. 1591;1573; + + + + + + + + + + + diff --git a/Patterns/Queries/ServiceProviderQueryDispatcher.cs b/Patterns/Queries/ServiceProviderQueryDispatcher.cs new file mode 100644 index 0000000..ec0f5be --- /dev/null +++ b/Patterns/Queries/ServiceProviderQueryDispatcher.cs @@ -0,0 +1,105 @@ +using Microsoft.Extensions.DependencyInjection; +using Odin.DesignContracts; +using Odin.Logging; + +namespace Odin.Patterns.Queries; + +/// +/// Dispatches queries by resolving their matching query handler from an . +/// +public sealed class ServiceProviderQueryDispatcher : IQueryDispatcher +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILoggerWrapper _logger; + + /// + /// Creates a new dispatcher. + /// + /// The service provider used to resolve query handlers. + /// The logger used to record dispatch activity. + public ServiceProviderQueryDispatcher( + IServiceProvider serviceProvider, + ILoggerWrapper logger) + { + Precondition.RequiresNotNull(serviceProvider); + Precondition.RequiresNotNull(logger); + + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + public async Task DispatchAsync( + TQuery query, + CancellationToken ct = default) + where TQuery : IQuery + { + Precondition.RequiresNotNull(query); + + Type queryType = typeof(TQuery); + Type handlerInterfaceType = typeof(IQueryHandler); + + _logger.LogDebug( + "Dispatching query {QueryType} using handler interface {HandlerInterface}.", + FormatTypeName(queryType), + FormatTypeName(handlerInterfaceType)); + + IQueryHandler handler = + ResolveHandler>(queryType, handlerInterfaceType); + + try + { + TQueryResult result = await handler.HandleAsync(query, ct).ConfigureAwait(false); + + _logger.LogDebug( + "Query {QueryType} completed successfully using handler {HandlerType}.", + FormatTypeName(queryType), + FormatTypeName(handler.GetType())); + + return result; + } + catch (OperationCanceledException ex) when (ct.IsCancellationRequested) + { + _logger.LogDebug( + ex, + "Query {QueryType} was cancelled while executing handler {HandlerType}.", + FormatTypeName(queryType), + FormatTypeName(handler.GetType())); + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Query {QueryType} failed while executing handler {HandlerType}.", + FormatTypeName(queryType), + FormatTypeName(handler.GetType())); + throw; + } + } + + private THandler ResolveHandler(Type queryType, Type handlerInterfaceType) + where THandler : class + { + THandler[] handlers = _serviceProvider.GetServices().ToArray(); + + if (handlers.Length == 1) + { + return handlers[0]; + } + + string message = handlers.Length == 0 + ? $"No query handler was registered for query type '{FormatTypeName(queryType)}'. " + + $"Expected handler interface: '{FormatTypeName(handlerInterfaceType)}'." + : $"Multiple query handlers were registered for query type '{FormatTypeName(queryType)}'. " + + $"Expected handler interface: '{FormatTypeName(handlerInterfaceType)}'. Found {handlers.Length} registrations."; + + _logger.LogError(message); + throw new InvalidOperationException(message); + } + + private static string FormatTypeName(Type type) + { + return type.FullName ?? type.Name; + } +}