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
61 changes: 59 additions & 2 deletions .well-known/agent-skills/csharpessentials-mediator/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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();
```
Expand Down Expand Up @@ -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<T>`. 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<T>` 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<T>
public record ProcessPaymentCommand(Guid OrderId, decimal Amount)
: ICommand<Result>;

public class ProcessPaymentHandler : ICommandHandler<ProcessPaymentCommand, Result>
{
private readonly IPaymentGateway _paymentGateway;

public ProcessPaymentHandler(IPaymentGateway paymentGateway)
=> _paymentGateway = paymentGateway;

public async ValueTask<Result> 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.
Expand Down Expand Up @@ -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<T>` 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
108 changes: 108 additions & 0 deletions CSharpEssentials.Mediator/Behaviors/ExceptionHandlingBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Linq.Expressions;
using System.Reflection;

using Mediator;

using CSharpEssentials.Errors;
using CSharpEssentials.ResultPattern;

namespace CSharpEssentials.Mediator;

/// <summary>
/// Pipeline behavior that catches unhandled handler exceptions and converts them
/// to <see cref="Result"/> / <see cref="Result{T}"/> failures.
/// <para>
/// When <typeparamref name="TResponse"/> cannot carry error information (e.g. a plain DTO),
/// the exception propagates unchanged — this behavior adds zero overhead in that case.
/// <see cref="OperationCanceledException"/> always propagates regardless of
/// <typeparamref name="TResponse"/>.
/// </para>
/// </summary>
public sealed class ExceptionHandlingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
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<TResponse> Handle(
TRequest message,
MessageHandlerDelegate<TRequest, TResponse> next,
CancellationToken cancellationToken)
{
// When TResponse cannot carry error info, skip exception wrapping entirely — zero overhead.
if (!_canHandleResponse)
return next(message, cancellationToken);

try
{
ValueTask<TResponse> 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<TResponse>(BuildFailureResponse(Error.Exception(ex), _responseType));
}

static async ValueTask<TResponse> WrapAsync(ValueTask<TResponse> vt, Type responseType)
{
try
{
return await vt.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return BuildFailureResponse(Error.Exception(ex), responseType);
}
}
}

/// <summary>
/// Maps a single <paramref name="error"/> to <typeparamref name="TResponse"/>:
/// <list type="bullet">
/// <item><see cref="Result"/> — <c>Result.Failure(error)</c> returned directly.</item>
/// <item><see cref="Result{T}"/> — <c>Result&lt;T&gt;.Failure(error)</c> via compiled factory.</item>
/// </list>
/// </summary>
private static TResponse BuildFailureResponse(Error error, Type responseType)
{
if (responseType == ValidationBehaviorCache.ResultType)
return (TResponse)(object)Result.Failure(error);

// Result<T> — use compiled factory cached per concrete closed generic type.
Type genericType = ValidationBehaviorCache.GenericResultType.MakeGenericType(responseType.GenericTypeArguments[0]);

Func<Error[], object> factory = ValidationBehaviorCache.FailureFactories.GetOrAdd(genericType, static type =>
{
MethodInfo method = type.GetMethod(nameof(Result.Failure), [typeof(IEnumerable<Error>)])
?? 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<Error>));
MethodCallExpression call = Expression.Call(method, asEnumerable);
UnaryExpression boxed = Expression.Convert(call, typeof(object));
return Expression.Lambda<Func<Error[], object>>(boxed, param).Compile();
});

return (TResponse)factory([error]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<,>)
];
Expand All @@ -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;
Expand All @@ -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<,>));
Expand Down
Loading
Loading