From a91fefd7c38b66029cac0189ee1c74ddbb765623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Sun, 31 May 2026 12:29:43 +0300 Subject: [PATCH] feat(mediator): add ExceptionHandlingBehavior pipeline Catches unhandled handler exceptions and converts them to Result.Failure using Error.Exception(ex). Propagates OperationCanceledException always. Zero overhead when TResponse is not Result/Result (fast path skips wrapping entirely). Registered as singleton after LoggingBehavior so callers always receive a typed failure instead of a raw exception. Co-Authored-By: Claude Sonnet 4.6 --- .../csharpessentials-mediator/SKILL.md | 61 +++++- .../Behaviors/ExceptionHandlingBehavior.cs | 108 ++++++++++ .../DependencyInjection/MediatorExtensions.cs | 8 + .../ExceptionHandlingBehaviorTests.cs | 186 ++++++++++++++++++ README.MD | 2 +- docs/API_REFERENCE.md | 53 ++++- 6 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 CSharpEssentials.Mediator/Behaviors/ExceptionHandlingBehavior.cs create mode 100644 CSharpEssentials.Tests/Mediator/ExceptionHandlingBehaviorTests.cs diff --git a/.well-known/agent-skills/csharpessentials-mediator/SKILL.md b/.well-known/agent-skills/csharpessentials-mediator/SKILL.md index 3885d6d..ebe8755 100644 --- a/.well-known/agent-skills/csharpessentials-mediator/SKILL.md +++ b/.well-known/agent-skills/csharpessentials-mediator/SKILL.md @@ -1,6 +1,6 @@ --- name: csharpessentials-mediator -description: Use when adding cross-cutting pipeline behaviors to CQRS handlers — ValidationBehavior (CSharpEssentials.Validation, throws EnhancedValidationException), LoggingBehavior (ILoggableRequest), CachingBehavior (ICacheable with IDistributedCache), and TransactionScopeBehavior (ITransactionalRequest). +description: Use when adding cross-cutting pipeline behaviors to CQRS handlers — ValidationBehavior (CSharpEssentials.Validation, throws EnhancedValidationException), LoggingBehavior (ILoggableRequest), ExceptionHandlingBehavior (auto-converts exceptions to Result.Failure for Result-returning handlers), CachingBehavior (ICacheable with IDistributedCache), and TransactionScopeBehavior (ITransactionalRequest). --- # CSharpEssentials.Mediator @@ -27,11 +27,12 @@ using Microsoft.Extensions.DependencyInjection; ```csharp // Program.cs builder.Services.AddMediator(); // Mediator source generator -builder.Services.AddMediatorBehaviors(); // all 4 behaviors +builder.Services.AddMediatorBehaviors(); // all 5 behaviors // Or selectively builder.Services.AddMediatorValidationBehavior(); builder.Services.AddMediatorLoggingBehavior(); +builder.Services.AddMediatorExceptionHandlingBehavior(); builder.Services.AddMediatorCachingBehavior(); builder.Services.AddMediatorTransactionBehavior(); ``` @@ -87,6 +88,61 @@ public record SendEmailCommand(string To, string Body) --- +## ExceptionHandlingBehavior — automatic for Result-returning handlers + +Registered as a singleton pipeline behavior between `LoggingBehavior` and `CachingBehavior`. When a handler throws, the behavior catches the exception and converts it to `Result.Failure(Error.Exception(ex))` — keeping the caller on the Result railway instead of forcing a try/catch at every call site. `OperationCanceledException` always propagates and is never caught. + +No interface needed — the behavior activates automatically for any handler whose `TResponse` is `Result` or `Result`. Handlers returning other types (plain DTOs, etc.) pass through with zero overhead. + +### Pipeline execution order + +| Position | Behavior | Activation | +|----------|----------|-----------| +| 1 | `ValidationBehavior` | Auto (all handlers) | +| 2 | `LoggingBehavior` | `ILoggableRequest` | +| 3 | `ExceptionHandlingBehavior` | Auto (`Result` / `Result` return types) | +| 4 | `CachingBehavior` | `ICacheable` | +| 5 | `TransactionScopeBehavior` | `ITransactionalRequest` | + +### Error shape + +`Error.Exception(ex)` produces an error with: +- `ErrorType`: `Failure` +- `Code`: exception type name (e.g. `"InvalidOperationException"`) +- `Description`: exception message + +```csharp +// No interface needed — automatically applied to all handlers returning Result or Result +public record ProcessPaymentCommand(Guid OrderId, decimal Amount) + : ICommand; + +public class ProcessPaymentHandler : ICommandHandler +{ + private readonly IPaymentGateway _paymentGateway; + + public ProcessPaymentHandler(IPaymentGateway paymentGateway) + => _paymentGateway = paymentGateway; + + public async ValueTask Handle(ProcessPaymentCommand command, CancellationToken ct) + { + // If this throws, ExceptionHandlingBehavior converts it to Result.Failure(Error.Exception(ex)) + // instead of letting the exception propagate to the caller. + await _paymentGateway.ChargeAsync(command.OrderId, command.Amount, ct); + return Result.Success(); + } +} + +// Caller always receives a Result — no try/catch needed +Result result = await mediator.Send(new ProcessPaymentCommand(orderId, 99.99m)); +if (result.IsFailure) +{ + // result.Error.Code => "HttpRequestException" + // result.Error.Description => "Payment gateway timed out" +} +``` + +--- + ## CachingBehavior — ICacheable Requires `IDistributedCache` registration. @@ -124,6 +180,7 @@ public record PlaceOrderCommand(OrderDto Order) ## Best Practices - Register `ValidationBehavior` first — invalid requests should never reach the handler +- `ExceptionHandlingBehavior` requires no setup; it activates automatically for `Result` / `Result` handlers — do not add try/catch inside handlers that already return `Result` - Set `CacheFailures = false` — transient failures should not be cached - `ITransactionalRequest` only on commands writing to multiple tables in one operation - Use `IRequestLoggable` (not `IRequestResponseLoggable`) when the response contains PII diff --git a/CSharpEssentials.Mediator/Behaviors/ExceptionHandlingBehavior.cs b/CSharpEssentials.Mediator/Behaviors/ExceptionHandlingBehavior.cs new file mode 100644 index 0000000..d0b4dc0 --- /dev/null +++ b/CSharpEssentials.Mediator/Behaviors/ExceptionHandlingBehavior.cs @@ -0,0 +1,108 @@ +using System.Linq.Expressions; +using System.Reflection; + +using Mediator; + +using CSharpEssentials.Errors; +using CSharpEssentials.ResultPattern; + +namespace CSharpEssentials.Mediator; + +/// +/// Pipeline behavior that catches unhandled handler exceptions and converts them +/// to / failures. +/// +/// When cannot carry error information (e.g. a plain DTO), +/// the exception propagates unchanged — this behavior adds zero overhead in that case. +/// always propagates regardless of +/// . +/// +/// +public sealed class ExceptionHandlingBehavior + : IPipelineBehavior + where TRequest : IMessage +{ + private readonly Type _responseType = typeof(TResponse); + + // Computed once at construction time — never pays the IsGenericType check on every request. + private readonly bool _canHandleResponse; + + public ExceptionHandlingBehavior() + { + Type t = typeof(TResponse); + _canHandleResponse = t == ValidationBehaviorCache.ResultType + || t.IsGenericType && t.GetGenericTypeDefinition() == ValidationBehaviorCache.GenericResultType; + } + + public ValueTask Handle( + TRequest message, + MessageHandlerDelegate next, + CancellationToken cancellationToken) + { + // When TResponse cannot carry error info, skip exception wrapping entirely — zero overhead. + if (!_canHandleResponse) + return next(message, cancellationToken); + + try + { + ValueTask vt = next(message, cancellationToken); + // Sync-complete fast path — no state-machine allocation on the happy path. + if (vt.IsCompletedSuccessfully) + return vt; + return WrapAsync(vt, _responseType); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new ValueTask(BuildFailureResponse(Error.Exception(ex), _responseType)); + } + + static async ValueTask WrapAsync(ValueTask vt, Type responseType) + { + try + { + return await vt.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return BuildFailureResponse(Error.Exception(ex), responseType); + } + } + } + + /// + /// Maps a single to : + /// + /// Result.Failure(error) returned directly. + /// Result<T>.Failure(error) via compiled factory. + /// + /// + private static TResponse BuildFailureResponse(Error error, Type responseType) + { + if (responseType == ValidationBehaviorCache.ResultType) + return (TResponse)(object)Result.Failure(error); + + // Result — use compiled factory cached per concrete closed generic type. + Type genericType = ValidationBehaviorCache.GenericResultType.MakeGenericType(responseType.GenericTypeArguments[0]); + + Func factory = ValidationBehaviorCache.FailureFactories.GetOrAdd(genericType, static type => + { + MethodInfo method = type.GetMethod(nameof(Result.Failure), [typeof(IEnumerable)]) + ?? throw new InvalidOperationException($"Method {nameof(Result.Failure)} not found on {type.FullName}."); + ParameterExpression param = Expression.Parameter(typeof(Error[]), "errors"); + UnaryExpression asEnumerable = Expression.Convert(param, typeof(IEnumerable)); + MethodCallExpression call = Expression.Call(method, asEnumerable); + UnaryExpression boxed = Expression.Convert(call, typeof(object)); + return Expression.Lambda>(boxed, param).Compile(); + }); + + return (TResponse)factory([error]); + } +} diff --git a/CSharpEssentials.Mediator/DependencyInjection/MediatorExtensions.cs b/CSharpEssentials.Mediator/DependencyInjection/MediatorExtensions.cs index ab47c8a..c498f8a 100644 --- a/CSharpEssentials.Mediator/DependencyInjection/MediatorExtensions.cs +++ b/CSharpEssentials.Mediator/DependencyInjection/MediatorExtensions.cs @@ -8,6 +8,7 @@ public static class MediatorExtensions [ typeof(CSharpEssentials.Mediator.ValidationBehavior<,>), typeof(CSharpEssentials.Mediator.LoggingBehavior<,>), + typeof(CSharpEssentials.Mediator.ExceptionHandlingBehavior<,>), typeof(CSharpEssentials.Mediator.CachingBehavior<,>), typeof(CSharpEssentials.Mediator.TransactionScopeBehavior<,>) ]; @@ -16,6 +17,7 @@ public static IServiceCollection AddMediatorBehaviors(this IServiceCollection se { services.AddScoped(typeof(IPipelineBehavior<,>), typeof(CSharpEssentials.Mediator.ValidationBehavior<,>)); services.AddSingleton(typeof(IPipelineBehavior<,>), typeof(CSharpEssentials.Mediator.LoggingBehavior<,>)); + services.AddSingleton(typeof(IPipelineBehavior<,>), typeof(CSharpEssentials.Mediator.ExceptionHandlingBehavior<,>)); services.AddSingleton(typeof(IPipelineBehavior<,>), typeof(CSharpEssentials.Mediator.CachingBehavior<,>)); services.AddSingleton(typeof(IPipelineBehavior<,>), typeof(CSharpEssentials.Mediator.TransactionScopeBehavior<,>)); return services; @@ -33,6 +35,12 @@ public static IServiceCollection AddMediatorLoggingBehavior(this IServiceCollect return services; } + public static IServiceCollection AddMediatorExceptionHandlingBehavior(this IServiceCollection services) + { + services.AddSingleton(typeof(IPipelineBehavior<,>), typeof(CSharpEssentials.Mediator.ExceptionHandlingBehavior<,>)); + return services; + } + public static IServiceCollection AddMediatorCachingBehavior(this IServiceCollection services) { services.AddSingleton(typeof(IPipelineBehavior<,>), typeof(CSharpEssentials.Mediator.CachingBehavior<,>)); diff --git a/CSharpEssentials.Tests/Mediator/ExceptionHandlingBehaviorTests.cs b/CSharpEssentials.Tests/Mediator/ExceptionHandlingBehaviorTests.cs new file mode 100644 index 0000000..7a62e72 --- /dev/null +++ b/CSharpEssentials.Tests/Mediator/ExceptionHandlingBehaviorTests.cs @@ -0,0 +1,186 @@ +using Mediator; + +using CSharpEssentials.Errors; +using CSharpEssentials.Mediator; +using CSharpEssentials.ResultPattern; + +using FluentAssertions; + +namespace CSharpEssentials.Tests.Mediator; + +internal sealed record TestExceptionCommand(string Name) : ICommand; + +public class ExceptionHandlingBehaviorTests +{ + private static readonly MessageHandlerDelegate SuccessNext = + (message, ct) => new ValueTask(Result.Success()); + + // ------------------------------------------------------------------------- + // Happy path — Result + // ------------------------------------------------------------------------- + + [Fact] + public async Task Handle_Should_PassThrough_When_Handler_Returns_Success() + { + var behavior = new ExceptionHandlingBehavior(); + var command = new TestExceptionCommand("test"); + + Result result = await behavior.Handle(command, SuccessNext, default); + + result.IsSuccess.Should().BeTrue(); + } + + // ------------------------------------------------------------------------- + // Happy path — Result + // ------------------------------------------------------------------------- + + [Fact] + public async Task Handle_Should_PassThrough_When_Handler_Returns_GenericResultSuccess() + { + var behavior = new ExceptionHandlingBehavior>(); + var command = new TestExceptionCommand("test"); + MessageHandlerDelegate> next = + (_, _) => new ValueTask>(Result.Success(42)); + + Result result = await behavior.Handle(command, next, default); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + } + + // ------------------------------------------------------------------------- + // Exception → Result failure + // ------------------------------------------------------------------------- + + [Fact] + public async Task Handle_Should_ReturnFailure_When_Handler_Throws_And_ResponseIsResult() + { + var behavior = new ExceptionHandlingBehavior(); + var command = new TestExceptionCommand("test"); + MessageHandlerDelegate throwingNext = + (_, _) => throw new InvalidOperationException("boom"); + + Result result = await behavior.Handle(command, throwingNext, default); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.Failure); + } + + [Fact] + public async Task Handle_Should_SetErrorCode_ToExceptionTypeName_When_Handler_Throws() + { + var behavior = new ExceptionHandlingBehavior(); + var command = new TestExceptionCommand("test"); + MessageHandlerDelegate throwingNext = + (_, _) => throw new InvalidOperationException("boom"); + + Result result = await behavior.Handle(command, throwingNext, default); + + result.FirstError.Code.Should().Be("InvalidOperationException"); + } + + [Fact] + public async Task Handle_Should_SetErrorDescription_ToExceptionMessage_When_Handler_Throws() + { + var behavior = new ExceptionHandlingBehavior(); + var command = new TestExceptionCommand("test"); + MessageHandlerDelegate throwingNext = + (_, _) => throw new InvalidOperationException("boom"); + + Result result = await behavior.Handle(command, throwingNext, default); + + result.FirstError.Description.Should().Be("boom"); + } + + [Fact] + public async Task Handle_Should_ReturnFailure_When_Handler_Throws_And_ResponseIsGenericResult() + { + var behavior = new ExceptionHandlingBehavior>(); + var command = new TestExceptionCommand("test"); + MessageHandlerDelegate> throwingNext = + (_, _) => throw new InvalidOperationException("boom"); + + Result result = await behavior.Handle(command, throwingNext, default); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.Failure); + } + + // ------------------------------------------------------------------------- + // OperationCanceledException always propagates + // ------------------------------------------------------------------------- + + [Fact] + public async Task Handle_Should_Propagate_OperationCanceledException_When_Handler_Throws_OCE() + { + var behavior = new ExceptionHandlingBehavior(); + var command = new TestExceptionCommand("test"); + MessageHandlerDelegate oceNext = + (_, _) => throw new OperationCanceledException(); + + Func act = () => behavior.Handle(command, oceNext, default).AsTask(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_Should_Propagate_OperationCanceledException_When_Token_Is_Cancelled() + { + var behavior = new ExceptionHandlingBehavior(); + var command = new TestExceptionCommand("test"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + MessageHandlerDelegate cancellingNext = + (_, ct) => + { + ct.ThrowIfCancellationRequested(); + return new ValueTask(Result.Success()); + }; + + Func act = () => behavior.Handle(command, cancellingNext, cts.Token).AsTask(); + + await act.Should().ThrowAsync(); + } + + // ------------------------------------------------------------------------- + // Non-Result TResponse — exception propagates unchanged + // ------------------------------------------------------------------------- + + [Fact] + public async Task Handle_Should_Propagate_Exception_When_ResponseType_IsNotResult() + { + var behavior = new ExceptionHandlingBehavior(); + var command = new TestExceptionCommand("test"); + MessageHandlerDelegate throwingNext = + (_, _) => throw new InvalidOperationException("should propagate"); + + Func act = () => behavior.Handle(command, throwingNext, default).AsTask(); + + await act.Should().ThrowAsync() + .WithMessage("should propagate"); + } + + // ------------------------------------------------------------------------- + // Truly async handler — exception caught after await + // ------------------------------------------------------------------------- + + [Fact] + public async Task Handle_Should_ReturnFailure_When_AsyncHandler_Throws_After_Await() + { + var behavior = new ExceptionHandlingBehavior(); + var command = new TestExceptionCommand("test"); + MessageHandlerDelegate asyncThrowingNext = + async (_, _) => + { + await Task.Yield(); + throw new ArgumentException("async boom"); + }; + + Result result = await behavior.Handle(command, asyncThrowingNext, default); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("ArgumentException"); + result.FirstError.Description.Should().Be("async boom"); + } +} diff --git a/README.MD b/README.MD index cd39116..9f57794 100644 --- a/README.MD +++ b/README.MD @@ -6,7 +6,7 @@ [![Build](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml/badge.svg)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) -[![Tests](https://img.shields.io/badge/tests-2861%20passing-brightgreen)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) +[![Tests](https://img.shields.io/badge/tests-2871%20passing-brightgreen)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.svg)](https://www.nuget.org/packages/CSharpEssentials) [![Downloads](https://img.shields.io/nuget/dt/CSharpEssentials.svg)](https://www.nuget.org/packages/CSharpEssentials) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/senrecep/CSharpEssentials/blob/main/LICENCE) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index ae613a7..1f973fc 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -933,9 +933,9 @@ Result user = await ResiliencePolicy ## 13. CSharpEssentials.Mediator — Pipeline Behaviors -**What it is:** MediatR pipeline behaviors for cross-cutting concerns: validation, logging, caching, and transactions. +**What it is:** MediatR pipeline behaviors for cross-cutting concerns: validation, logging, exception handling, caching, and transactions. -**Why it exists:** CQRS handlers often need the same cross-cutting logic — validate input, log execution, cache results, wrap in a transaction. Pipeline behaviors apply these concerns declaratively via marker interfaces rather than repeating code in every handler. +**Why it exists:** CQRS handlers often need the same cross-cutting logic — validate input, log execution, convert exceptions to Result failures, cache results, wrap in a transaction. Pipeline behaviors apply these concerns declaratively via marker interfaces rather than repeating code in every handler. ### Behaviors @@ -943,16 +943,63 @@ Result user = await ResiliencePolicy |----------|-----------------|-------------| | `ValidationBehavior` | — (auto for all) | Runs CSharpEssentials.Validation before handler; returns `Result.Failure` with validation errors | | `LoggingBehavior` | `ILoggableRequest` | Logs request/response details | +| `ExceptionHandlingBehavior` | — (auto for `Result` / `Result`) | Catches handler exceptions; converts to `Result.Failure(Error.Exception(ex))`; `OperationCanceledException` always propagates | | `CachingBehavior` | `ICacheable` | Caches handler responses using `CacheKey` and `CacheDuration` | | `TransactionScopeBehavior` | `ITransactionalRequest` | Wraps handler execution in `TransactionScope` | +### ExceptionHandlingBehavior + +Singleton behavior that sits between `LoggingBehavior` and `CachingBehavior`. No interface or attribute needed — it activates automatically when `TResponse` is `Result` or `Result`. Handlers returning other types pass through with zero overhead. + +`Error.Exception(ex)` shape: + +| Property | Value | +|----------|-------| +| `ErrorType` | `Failure` | +| `Code` | Exception type name (e.g. `"InvalidOperationException"`) | +| `Description` | Exception message | + +```csharp +// Registration +builder.Services.AddMediatorExceptionHandlingBehavior(); // singleton + +// Handler — no try/catch needed; exceptions become Result.Failure +public record ProcessPaymentCommand(Guid OrderId, decimal Amount) + : ICommand; + +public class ProcessPaymentHandler : ICommandHandler +{ + private readonly IPaymentGateway _paymentGateway; + + public ProcessPaymentHandler(IPaymentGateway paymentGateway) + => _paymentGateway = paymentGateway; + + public async ValueTask Handle(ProcessPaymentCommand command, CancellationToken ct) + { + // Unhandled exceptions are caught by ExceptionHandlingBehavior + // and returned as Result.Failure(Error.Exception(ex)) + await _paymentGateway.ChargeAsync(command.OrderId, command.Amount, ct); + return Result.Success(); + } +} + +// Caller — stays on the Result railway; no try/catch required +Result result = await mediator.Send(new ProcessPaymentCommand(orderId, 99.99m)); +if (result.IsFailure) +{ + // result.Error.Code => "HttpRequestException" + // result.Error.Description => "Payment gateway timed out" +} +``` + ### Registration | Method | What It Does | |--------|-------------| -| `AddMediatorBehaviors()` | Registers all four behaviors | +| `AddMediatorBehaviors()` | Registers all five behaviors | | `AddMediatorValidationBehavior()` | Registers validation only | | `AddMediatorLoggingBehavior()` | Registers logging only | +| `AddMediatorExceptionHandlingBehavior()` | Registers exception handling only (singleton) | | `AddMediatorCachingBehavior()` | Registers caching only | | `AddMediatorTransactionBehavior()` | Registers transaction only |