Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using Compendium.Application.CQRS.Behaviors;
using Compendium.Core.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace Compendium.Application.CQRS;

Expand Down Expand Up @@ -40,15 +42,23 @@ Task<Result<TResult>> DispatchAsync<TCommand, TResult>(TCommand command, Cancell
public sealed class CommandDispatcher : ICommandDispatcher
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<CommandDispatcher> _logger;

/// <summary>
/// Initializes a new instance of the <see cref="CommandDispatcher"/> class.
/// </summary>
/// <param name="serviceProvider">The service provider for resolving handlers and behaviors.</param>
/// <exception cref="ArgumentNullException">Thrown when serviceProvider is null.</exception>
/// <remarks>
/// P0-02: The logger is resolved from <paramref name="serviceProvider"/> rather than injected
/// directly so that the existing public single-parameter constructor remains binary- and
/// source-compatible for downstream consumers. When the container has no logging configured
/// (e.g. in lightweight unit tests) a <see cref="NullLogger{T}"/> is used as a safe fallback.
/// </remarks>
public CommandDispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_logger = _serviceProvider.GetService<ILogger<CommandDispatcher>>() ?? NullLogger<CommandDispatcher>.Instance;
}

/// <summary>
Expand Down Expand Up @@ -139,7 +149,20 @@ public async Task<Result> DispatchAsync<TCommand>(TCommand command, Cancellation
activity?.SetTag("exception.type", ex.GetType().FullName);
activity?.SetTag("exception.message", ex.Message);

return Result.Failure(Error.Failure("Command.ExecutionFailed", ex.Message));
// P0-02: Log the swallowed exception with stack trace BEFORE wrapping it into a
// Result.Failure. Without this, production debugging is blind — the exception is
// converted to a failure Result and the only trace is on OTel spans.
_logger.LogError(
ex,
"Command handler for {CommandType} threw an unhandled exception after {ElapsedMs}ms; converting to Result.Failure({ErrorCode})",
typeof(TCommand).Name,
sw.Elapsed.TotalMilliseconds,
"Command.ExecutionFailed");

return Result.Failure(Error.Failure(
"Command.ExecutionFailed",
ex.Message,
BuildExceptionMetadata(ex)));
}
}

Expand Down Expand Up @@ -232,10 +255,39 @@ public async Task<Result<TResult>> DispatchAsync<TCommand, TResult>(TCommand com
activity?.SetTag("exception.type", ex.GetType().FullName);
activity?.SetTag("exception.message", ex.Message);

return Result.Failure<TResult>(Error.Failure("Command.ExecutionFailed", ex.Message));
// P0-02: Log the swallowed exception with stack trace BEFORE wrapping it into a
// Result.Failure. Without this, production debugging is blind — the exception is
// converted to a failure Result and the only trace is on OTel spans.
_logger.LogError(
ex,
"Command handler for {CommandType} returning {ResultType} threw an unhandled exception after {ElapsedMs}ms; converting to Result.Failure({ErrorCode})",
typeof(TCommand).Name,
typeof(TResult).Name,
sw.Elapsed.TotalMilliseconds,
"Command.ExecutionFailed");

return Result.Failure<TResult>(Error.Failure(
"Command.ExecutionFailed",
ex.Message,
BuildExceptionMetadata(ex)));
}
}

/// <summary>
/// Builds the error metadata dictionary attached to an execution-failure error, capturing the
/// exception type so downstream consumers can discriminate the underlying failure cause without
/// parsing the message string. P0-02.
/// </summary>
/// <param name="exception">The exception that was thrown by the handler pipeline.</param>
/// <returns>A read-only metadata dictionary containing the exception type name.</returns>
private static IReadOnlyDictionary<string, object> BuildExceptionMetadata(Exception exception)
{
return new Dictionary<string, object>
{
["exceptionType"] = exception.GetType().FullName ?? exception.GetType().Name,
};
}

/// <summary>
/// Executes the command handler for commands that don't return a value.
/// </summary>
Expand Down
41 changes: 40 additions & 1 deletion src/Application/Compendium.Application/CQRS/QueryDispatcher.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Diagnostics;
using Compendium.Core.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace Compendium.Application.CQRS;

Expand Down Expand Up @@ -29,15 +31,23 @@ Task<Result<TResult>> DispatchAsync<TQuery, TResult>(TQuery query, CancellationT
public sealed class QueryDispatcher : IQueryDispatcher
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<QueryDispatcher> _logger;

/// <summary>
/// Initializes a new instance of the <see cref="QueryDispatcher"/> class.
/// </summary>
/// <param name="serviceProvider">The service provider for resolving handlers.</param>
/// <exception cref="ArgumentNullException">Thrown when serviceProvider is null.</exception>
/// <remarks>
/// P0-02: The logger is resolved from <paramref name="serviceProvider"/> rather than injected
/// directly so that the existing public single-parameter constructor remains binary- and
/// source-compatible for downstream consumers. When the container has no logging configured
/// (e.g. in lightweight unit tests) a <see cref="NullLogger{T}"/> is used as a safe fallback.
/// </remarks>
public QueryDispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_logger = _serviceProvider.GetService<ILogger<QueryDispatcher>>() ?? NullLogger<QueryDispatcher>.Instance;
}

/// <summary>
Expand Down Expand Up @@ -116,7 +126,36 @@ public async Task<Result<TResult>> DispatchAsync<TQuery, TResult>(TQuery query,
activity?.SetTag("exception.type", ex.GetType().FullName);
activity?.SetTag("exception.message", ex.Message);

return Result.Failure<TResult>(Error.Failure("Query.ExecutionFailed", ex.Message));
// P0-02: Log the swallowed exception with stack trace BEFORE wrapping it into a
// Result.Failure. Without this, production debugging is blind — the exception is
// converted to a failure Result and the only trace is on OTel spans.
_logger.LogError(
ex,
"Query handler for {QueryType} returning {ResultType} threw an unhandled exception after {ElapsedMs}ms; converting to Result.Failure({ErrorCode})",
typeof(TQuery).Name,
typeof(TResult).Name,
sw.Elapsed.TotalMilliseconds,
"Query.ExecutionFailed");

return Result.Failure<TResult>(Error.Failure(
"Query.ExecutionFailed",
ex.Message,
BuildExceptionMetadata(ex)));
}
}

/// <summary>
/// Builds the error metadata dictionary attached to an execution-failure error, capturing the
/// exception type so downstream consumers can discriminate the underlying failure cause without
/// parsing the message string. P0-02.
/// </summary>
/// <param name="exception">The exception that was thrown by the handler.</param>
/// <returns>A read-only metadata dictionary containing the exception type name.</returns>
private static IReadOnlyDictionary<string, object> BuildExceptionMetadata(Exception exception)
{
return new Dictionary<string, object>
{
["exceptionType"] = exception.GetType().FullName ?? exception.GetType().Name,
};
}
}
63 changes: 63 additions & 0 deletions tests/Unit/Compendium.Application.Tests/CQRS/CapturingLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// -----------------------------------------------------------------------
// <copyright file="CapturingLogger.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

using Microsoft.Extensions.Logging;

namespace Compendium.Application.Tests.CQRS;

/// <summary>
/// A minimal in-memory <see cref="ILogger{T}"/> implementation that records every log entry it
/// receives. Used to assert that the CQRS dispatchers log swallowed handler exceptions (P0-02)
/// before wrapping them into a <see cref="Result"/> failure. Avoids taking a dependency on
/// <c>Microsoft.Extensions.Diagnostics.Testing</c> just for the <c>FakeLogger</c> type.
/// </summary>
/// <typeparam name="T">The category type for the logger.</typeparam>
public sealed class CapturingLogger<T> : ILogger<T>
{
private readonly List<CapturedLogEntry> _entries = [];

/// <summary>
/// Gets the captured log entries in the order they were recorded.
/// </summary>
public IReadOnlyList<CapturedLogEntry> Entries => _entries;

/// <inheritdoc />
public IDisposable BeginScope<TState>(TState state)
where TState : notnull => NullScope.Instance;

/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;

/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
_entries.Add(new CapturedLogEntry(logLevel, exception, formatter(state, exception)));
}

/// <summary>
/// Represents a single captured log entry.
/// </summary>
/// <param name="Level">The log level.</param>
/// <param name="Exception">The exception associated with the entry, if any.</param>
/// <param name="Message">The rendered log message.</param>
public sealed record CapturedLogEntry(LogLevel Level, Exception? Exception, string Message);

private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();

public void Dispose()
{
// No-op.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Compendium.Application.CQRS;
using Compendium.Application.CQRS.Behaviors;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Compendium.Application.Tests.CQRS;

Expand Down Expand Up @@ -133,6 +134,116 @@ public async Task DispatchAsync_WhenHandlerThrows_ReturnsExecutionFailedFailure(
result.Error.Message.Should().Contain("kaboom");
}

[Fact]
public async Task DispatchAsync_WhenHandlerThrows_LogsExceptionBeforeWrappingInFailure()
{
// Arrange
var thrown = new InvalidOperationException("kaboom");
var handler = Substitute.For<ICommandHandler<TestCommand>>();
handler.HandleAsync(Arg.Any<TestCommand>(), Arg.Any<CancellationToken>())
.Returns<Task<Result>>(_ => throw thrown);

var logger = new CapturingLogger<CommandDispatcher>();
var sp = new ServiceCollection()
.AddSingleton(handler)
.AddSingleton<ILogger<CommandDispatcher>>(logger)
.BuildServiceProvider();
var dispatcher = new CommandDispatcher(sp);

// Act
var result = await dispatcher.DispatchAsync(new TestCommand(), CancellationToken.None);

// Assert — the Result contract is preserved (P0-02: never rethrow)
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Command.ExecutionFailed");

// Assert — the exception was logged with its stack trace, not silently swallowed
var errorEntry = logger.Entries.Should().ContainSingle(e => e.Level == LogLevel.Error).Subject;
errorEntry.Exception.Should().BeSameAs(thrown);
errorEntry.Message.Should().Contain(nameof(TestCommand));

// Assert — the exception type is preserved in the error metadata (non-breaking enrichment)
result.Error.Metadata.Should().ContainKey("exceptionType");
result.Error.Metadata["exceptionType"].Should().Be(typeof(InvalidOperationException).FullName);
}

[Fact]
public async Task DispatchAsync_WhenNoLoggerRegistered_StillReturnsFailureWithoutThrowing()
{
// Arrange — no ILogger registered; dispatcher must fall back to NullLogger and not throw
var handler = Substitute.For<ICommandHandler<TestCommand>>();
handler.HandleAsync(Arg.Any<TestCommand>(), Arg.Any<CancellationToken>())
.Returns<Task<Result>>(_ => throw new InvalidOperationException("kaboom"));

var sp = new ServiceCollection()
.AddSingleton(handler)
.BuildServiceProvider();
var dispatcher = new CommandDispatcher(sp);

// Act
var result = await dispatcher.DispatchAsync(new TestCommand(), CancellationToken.None);

// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Command.ExecutionFailed");
}

[Fact]
public async Task DispatchAsyncTResult_WhenHandlerThrows_LogsExceptionBeforeWrappingInFailure()
{
// Arrange
var thrown = new InvalidOperationException("oops");
var handler = Substitute.For<ICommandHandler<TestCommandWithResult, int>>();
handler.HandleAsync(Arg.Any<TestCommandWithResult>(), Arg.Any<CancellationToken>())
.Returns<Task<Result<int>>>(_ => throw thrown);

var logger = new CapturingLogger<CommandDispatcher>();
var sp = new ServiceCollection()
.AddSingleton(handler)
.AddSingleton<ILogger<CommandDispatcher>>(logger)
.BuildServiceProvider();
var dispatcher = new CommandDispatcher(sp);

// Act
var result = await dispatcher.DispatchAsync<TestCommandWithResult, int>(
new TestCommandWithResult { Value = 1 },
CancellationToken.None);

// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Command.ExecutionFailed");

var errorEntry = logger.Entries.Should().ContainSingle(e => e.Level == LogLevel.Error).Subject;
errorEntry.Exception.Should().BeSameAs(thrown);
errorEntry.Message.Should().Contain(nameof(TestCommandWithResult));

result.Error.Metadata.Should().ContainKey("exceptionType");
result.Error.Metadata["exceptionType"].Should().Be(typeof(InvalidOperationException).FullName);
}

[Fact]
public async Task DispatchAsync_WhenHandlerSucceeds_DoesNotLogError()
{
// Arrange
var handler = Substitute.For<ICommandHandler<TestCommand>>();
handler.HandleAsync(Arg.Any<TestCommand>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Success()));

var logger = new CapturingLogger<CommandDispatcher>();
var sp = new ServiceCollection()
.AddSingleton(handler)
.AddSingleton<ILogger<CommandDispatcher>>(logger)
.BuildServiceProvider();
var dispatcher = new CommandDispatcher(sp);

// Act
var result = await dispatcher.DispatchAsync(new TestCommand(), CancellationToken.None);

// Assert
result.IsSuccess.Should().BeTrue();
logger.Entries.Should().NotContain(e => e.Level == LogLevel.Error);
}

[Fact]
public async Task DispatchAsync_WhenBehaviorsRegistered_InvokesPipelineInRegistrationOrder()
{
Expand Down
Loading
Loading