diff --git a/FunctionalUseCases.AspNetCore/ExecutionResultHttpExtensions.cs b/FunctionalUseCases.AspNetCore/ExecutionResultHttpExtensions.cs new file mode 100644 index 0000000..5f7f01b --- /dev/null +++ b/FunctionalUseCases.AspNetCore/ExecutionResultHttpExtensions.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace FunctionalUseCases.AspNetCore; + +public static class ExecutionResultHttpExtensions +{ + public static IActionResult ToActionResult( + this ExecutionResult result, + ExecutionResultHttpOptions? options = null) + where T : notnull => + result.ExecutionSucceeded + ? new OkObjectResult(result.CheckedValue) + : CreateErrorResult(result.Error, options); + + public static IActionResult ToActionResult( + this ExecutionResult result, + ExecutionResultHttpOptions? options = null) => + result.ExecutionSucceeded + ? new NoContentResult() + : CreateErrorResult(result.Error, options); + + public static ProblemDetails ToProblemDetails( + this ExecutionError error, + ExecutionResultHttpOptions? options = null) + { + ArgumentNullException.ThrowIfNull(error); + + options ??= new ExecutionResultHttpOptions(); + var statusCode = options.StatusCodeSelector(error); + var problemDetails = new ProblemDetails + { + Status = statusCode, + Title = "Use case execution failed", + Detail = error.Message + }; + + if (error.ErrorCode is not null) + { + problemDetails.Extensions["errorCode"] = error.ErrorCode; + } + + foreach (var property in error.Properties) + { + problemDetails.Extensions[property.Key] = property.Value; + } + + if (options.IncludeExceptionDetails && error.Exception is not null) + { + problemDetails.Extensions["exceptionType"] = error.Exception.GetType().FullName; + problemDetails.Extensions["exception"] = error.Exception.ToString(); + } + + return problemDetails; + } + + private static ObjectResult CreateErrorResult( + ExecutionError? error, + ExecutionResultHttpOptions? options) + { + error ??= new ExecutionError("Unknown Error"); + var problemDetails = error.ToProblemDetails(options); + return new ObjectResult(problemDetails) + { + StatusCode = problemDetails.Status ?? StatusCodes.Status500InternalServerError + }; + } +} diff --git a/FunctionalUseCases.AspNetCore/ExecutionResultHttpOptions.cs b/FunctionalUseCases.AspNetCore/ExecutionResultHttpOptions.cs new file mode 100644 index 0000000..751ee2a --- /dev/null +++ b/FunctionalUseCases.AspNetCore/ExecutionResultHttpOptions.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; + +namespace FunctionalUseCases.AspNetCore; + +public sealed class ExecutionResultHttpOptions +{ + public Func StatusCodeSelector { get; init; } = DefaultStatusCodeSelector; + + public bool IncludeExceptionDetails { get; init; } + + private static int DefaultStatusCodeSelector(ExecutionError error) + { + if (error.Properties.TryGetValue("statusCode", out var statusCode) && + statusCode is int propertyStatusCode) + { + return propertyStatusCode; + } + + return int.TryParse(error.ErrorCode, out var errorCode) && + errorCode is >= 400 and <= 599 + ? errorCode + : StatusCodes.Status500InternalServerError; + } +} diff --git a/FunctionalUseCases.AspNetCore/FunctionalUseCases.AspNetCore.csproj b/FunctionalUseCases.AspNetCore/FunctionalUseCases.AspNetCore.csproj new file mode 100644 index 0000000..082a500 --- /dev/null +++ b/FunctionalUseCases.AspNetCore/FunctionalUseCases.AspNetCore.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + + FunctionalUseCases.AspNetCore + Thomas Berghold-Wieser + ASP.NET Core result mapping extensions for FunctionalUseCases. + https://github.com/ThomasBergholdWieser/FunctionalUseCases + https://github.com/ThomasBergholdWieser/FunctionalUseCases + git + MIT + aspnetcore;functional;use-cases;problem-details + README.md + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/FunctionalUseCases.Tests/AspNetCoreExecutionResultExtensionsTests.cs b/FunctionalUseCases.Tests/AspNetCoreExecutionResultExtensionsTests.cs new file mode 100644 index 0000000..ea3ebc1 --- /dev/null +++ b/FunctionalUseCases.Tests/AspNetCoreExecutionResultExtensionsTests.cs @@ -0,0 +1,64 @@ +using FunctionalUseCases.AspNetCore; +using Microsoft.AspNetCore.Mvc; + +namespace FunctionalUseCases.Tests; + +public class AspNetCoreExecutionResultExtensionsTests +{ + [Fact] + public void ToActionResult_WithSuccess_ShouldReturnOkObjectResult() + { + var result = Execution.Success("value"); + + var actionResult = result.ToActionResult(); + + var okResult = actionResult.ShouldBeOfType(); + okResult.Value.ShouldBe("value"); + } + + [Fact] + public void ToActionResult_WithFailure_ShouldReturnProblemDetails() + { + var result = Execution.Failure( + "Customer missing", + "CUSTOMER_NOT_FOUND", + properties: new Dictionary + { + ["statusCode"] = 404, + ["customerId"] = 42 + }); + + var actionResult = result.ToActionResult(); + + var objectResult = actionResult.ShouldBeOfType(); + objectResult.StatusCode.ShouldBe(404); + var problemDetails = objectResult.Value.ShouldBeOfType(); + problemDetails.Detail.ShouldBe("Customer missing"); + problemDetails.Extensions["errorCode"].ShouldBe("CUSTOMER_NOT_FOUND"); + problemDetails.Extensions["customerId"].ShouldBe(42); + } + + [Fact] + public void ToProblemDetails_ShouldHideExceptionByDefault() + { + var error = Execution.Failure("Failed", new InvalidOperationException("Sensitive")).CheckedError; + + var problemDetails = error.ToProblemDetails(); + + problemDetails.Extensions.ShouldNotContainKey("exception"); + } + + [Fact] + public void ToProblemDetails_WithOption_ShouldIncludeExceptionDetails() + { + var error = Execution.Failure("Failed", new InvalidOperationException("Sensitive")).CheckedError; + + var problemDetails = error.ToProblemDetails(new ExecutionResultHttpOptions + { + IncludeExceptionDetails = true + }); + + problemDetails.Extensions["exceptionType"].ShouldBe(typeof(InvalidOperationException).FullName); + problemDetails.Extensions["exception"].ShouldBeOfType().ShouldContain("Sensitive"); + } +} diff --git a/FunctionalUseCases.Tests/ExecutionErrorTests.cs b/FunctionalUseCases.Tests/ExecutionErrorTests.cs index 2e808c5..73d397a 100644 --- a/FunctionalUseCases.Tests/ExecutionErrorTests.cs +++ b/FunctionalUseCases.Tests/ExecutionErrorTests.cs @@ -67,12 +67,14 @@ public void ExecutionError_Properties_ShouldBeSettable() var error = new ExecutionError("test"); // Act - error.ErrorCode = 404; + error.ErrorCode = "NOT_FOUND"; error.LogLevel = LogLevel.Warning; + error.Properties["resource"] = "customer"; // Assert - error.ErrorCode.ShouldBe(404); + error.ErrorCode.ShouldBe("NOT_FOUND"); error.LogLevel.ShouldBe(LogLevel.Warning); + error.Properties["resource"].ShouldBe("customer"); } } @@ -105,4 +107,4 @@ public void ExecutionError_Generic_WithEnumerable_ShouldWork() error.Message.ShouldBe("100; 200"); error.Messages.Count.ShouldBe(2); } -} \ No newline at end of file +} diff --git a/FunctionalUseCases.Tests/ExecutionExceptionTests.cs b/FunctionalUseCases.Tests/ExecutionExceptionTests.cs index 3ac996d..66fea04 100644 --- a/FunctionalUseCases.Tests/ExecutionExceptionTests.cs +++ b/FunctionalUseCases.Tests/ExecutionExceptionTests.cs @@ -36,4 +36,14 @@ public void ExecutionException_ShouldInheritFromException() // Act & Assert exception.ShouldBeAssignableTo(); } -} \ No newline at end of file + + [Fact] + public void ExecutionException_Constructor_ShouldSetInnerException() + { + var innerException = new InvalidOperationException("Original"); + + var exception = new ExecutionException("Wrapped", innerException); + + exception.InnerException.ShouldBeSameAs(innerException); + } +} diff --git a/FunctionalUseCases.Tests/ExecutionResultExtensionsTests.cs b/FunctionalUseCases.Tests/ExecutionResultExtensionsTests.cs index 1cfac54..a2ac089 100644 --- a/FunctionalUseCases.Tests/ExecutionResultExtensionsTests.cs +++ b/FunctionalUseCases.Tests/ExecutionResultExtensionsTests.cs @@ -133,6 +133,19 @@ public void Log_WithFailedResult_UsingTestLogger_ShouldLogCorrectMessage() result.Error!.Logged.ShouldBeTrue(); } + [Fact] + public void Log_WithException_ShouldPassOriginalExceptionToLogger() + { + var exception = new InvalidOperationException("Original"); + var result = Execution.Failure("Failed", exception); + var testLogger = new TestLogger(); + + result.Log(testLogger); + + testLogger.LoggedMessages.ShouldHaveSingleItem(); + testLogger.LoggedMessages[0].Exception.ShouldBeSameAs(exception); + } + private class TestLogger : ILogger { public List LoggedMessages { get; } = new(); @@ -146,7 +159,8 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except LoggedMessages.Add(new LogEntry { LogLevel = logLevel, - Message = formatter(state, exception) + Message = formatter(state, exception), + Exception = exception }); } @@ -154,6 +168,7 @@ public class LogEntry { public LogLevel LogLevel { get; set; } public string Message { get; set; } = string.Empty; + public Exception? Exception { get; set; } } } } diff --git a/FunctionalUseCases.Tests/ExecutionResultGenericTests.cs b/FunctionalUseCases.Tests/ExecutionResultGenericTests.cs index 35bf4dc..82eaa5e 100644 --- a/FunctionalUseCases.Tests/ExecutionResultGenericTests.cs +++ b/FunctionalUseCases.Tests/ExecutionResultGenericTests.cs @@ -38,10 +38,58 @@ public void ExecutionResult_Failure_ShouldReturnFailedResult() public void ExecutionResult_CheckedValue_ShouldThrowWhenFailed() { // Arrange - var result = Execution.Failure("Test error"); + var originalException = new InvalidOperationException("Original error"); + var result = Execution.Failure("Test error", originalException); // Act & Assert - Should.Throw(() => result.CheckedValue); + var exception = Should.Throw(() => result.CheckedValue); + exception.Message.ShouldContain("Test error"); + exception.InnerException.ShouldBeSameAs(originalException); + } + + [Fact] + public void ExecutionResult_Match_ShouldHandleSuccessAndFailure() + { + var success = Execution.Success(21); + var failure = Execution.Failure("No value"); + + success.Match(value => value * 2, _ => -1).ShouldBe(42); + failure.Match(value => value * 2, error => error.Message.Length).ShouldBe(8); + } + + [Fact] + public void ExecutionResult_Map_ShouldTransformSuccessAndPreserveFailure() + { + var originalException = new InvalidOperationException("Original"); + var success = Execution.Success(21); + var failure = Execution.Failure("No value", originalException); + + success.Map(value => value * 2).CheckedValue.ShouldBe(42); + + var mappedFailure = failure.Map(value => value * 2); + mappedFailure.ExecutionFailed.ShouldBeTrue(); + mappedFailure.Error!.Exception.ShouldBeSameAs(originalException); + } + + [Fact] + public void ExecutionResult_Bind_ShouldComposeResults() + { + var success = Execution.Success(21); + + var result = success.Bind(value => Execution.Success(value * 2)); + + result.CheckedValue.ShouldBe(42); + } + + [Fact] + public void ExecutionResult_GetValueOrThrow_ShouldIncludeCustomMessage() + { + var result = Execution.Failure("No value"); + + var exception = Should.Throw(() => + result.GetValueOrThrow("Cannot continue")); + + exception.Message.ShouldBe("Cannot continue: No value"); } [Fact] @@ -98,4 +146,4 @@ public void ExecutionResult_Combine_ShouldReturnFailureWhenAnyFails() // Assert combined.ExecutionFailed.ShouldBeTrue(); } -} \ No newline at end of file +} diff --git a/FunctionalUseCases.Tests/ExecutionResultTests.cs b/FunctionalUseCases.Tests/ExecutionResultTests.cs index 9d91446..85797ba 100644 --- a/FunctionalUseCases.Tests/ExecutionResultTests.cs +++ b/FunctionalUseCases.Tests/ExecutionResultTests.cs @@ -34,11 +34,13 @@ public void ExecutionResult_Failure_ShouldReturnFailedResult() public void ExecutionResult_ThrowIfFailed_ShouldThrowWhenFailed() { // Arrange - var result = Execution.Failure("Test error"); + var originalException = new InvalidOperationException("Original"); + var result = Execution.Failure("Test error", originalException); // Act & Assert var exception = Should.Throw(() => result.ThrowIfFailed()); exception.Message.ShouldContain("Test error"); + exception.InnerException.ShouldBeSameAs(originalException); } [Fact] @@ -50,4 +52,4 @@ public void ExecutionResult_ThrowIfFailed_ShouldNotThrowWhenSuccessful() // Act & Assert (no exception should be thrown) result.ThrowIfFailed(); } -} \ No newline at end of file +} diff --git a/FunctionalUseCases.Tests/ExecutionTests.cs b/FunctionalUseCases.Tests/ExecutionTests.cs index f3a9a2c..4f9cf0c 100644 --- a/FunctionalUseCases.Tests/ExecutionTests.cs +++ b/FunctionalUseCases.Tests/ExecutionTests.cs @@ -77,7 +77,7 @@ public void Execution_Failure_WithErrorCodeAndLogLevel_ShouldSetProperties() // Assert result.Error.ShouldNotBeNull(); - result.Error.ErrorCode.ShouldBe(errorCode); + result.Error.ErrorCode.ShouldBe("404"); result.Error.LogLevel.ShouldBe(logLevel); } @@ -95,6 +95,7 @@ public void Execution_Failure_WithException_ShouldExtractMessage() result.ExecutionFailed.ShouldBeTrue(); result.Error.ShouldNotBeNull(); result.Error.Message.ShouldContain("Test exception"); + result.Error.Exception.ShouldBeSameAs(exception); } [Fact] @@ -111,6 +112,25 @@ public void Execution_Failure_WithMessageAndException_ShouldCombineMessages() result.Error.ShouldNotBeNull(); result.Error.Message.ShouldContain("Custom error"); result.Error.Message.ShouldContain("Test exception"); + result.Error.Exception.ShouldBeSameAs(exception); + } + + [Fact] + public void Execution_Failure_WithStringCodeAndProperties_ShouldSetStructuredMetadata() + { + // Arrange + var properties = new Dictionary { ["customerId"] = 42 }; + + // Act + var result = Execution.Failure( + "Customer missing", + "CUSTOMER_NOT_FOUND", + properties: properties); + + // Assert + result.Error.ShouldNotBeNull(); + result.Error.ErrorCode.ShouldBe("CUSTOMER_NOT_FOUND"); + result.Error.Properties["customerId"].ShouldBe(42); } [Fact] @@ -182,4 +202,4 @@ public void Execution_Combine_WithMultipleFailures_ShouldCombineMessages() combined.Error.Message.ShouldContain("Error 1"); combined.Error.Message.ShouldContain("Error 2"); } -} \ No newline at end of file +} diff --git a/FunctionalUseCases.Tests/FunctionalUseCases.Tests.csproj b/FunctionalUseCases.Tests/FunctionalUseCases.Tests.csproj index e183071..4166bc7 100644 --- a/FunctionalUseCases.Tests/FunctionalUseCases.Tests.csproj +++ b/FunctionalUseCases.Tests/FunctionalUseCases.Tests.csproj @@ -11,7 +11,6 @@ - @@ -26,7 +25,9 @@ + + - + diff --git a/FunctionalUseCases.Tests/UseCaseDispatcherTests.cs b/FunctionalUseCases.Tests/UseCaseDispatcherTests.cs index 3fcbc23..92addc5 100644 --- a/FunctionalUseCases.Tests/UseCaseDispatcherTests.cs +++ b/FunctionalUseCases.Tests/UseCaseDispatcherTests.cs @@ -155,6 +155,25 @@ public async Task ExecuteAsync_WithMockedServiceProvider_ShouldHandleServiceReso .MustHaveHappenedOnceExactly(); } + [Fact] + public async Task ExecuteAsync_WhenUseCaseThrows_ShouldPreserveOriginalException() + { + // Arrange + var services = new ServiceCollection(); + services.AddTransient, ThrowingUseCase>(); + var dispatcher = new UseCaseDispatcher(services.BuildServiceProvider()); + + // Act + var result = await dispatcher.ExecuteAsync(new ThrowingUseCaseParameter()); + + // Assert + result.ExecutionFailed.ShouldBeTrue(); + result.Error!.Exception.ShouldBeOfType(); + result.Error.Exception.ShouldNotBeOfType(); + result.Error.Exception.StackTrace.ShouldNotBeNull(); + result.Error.Exception.StackTrace.ShouldContain(nameof(ThrowingUseCase.ExecuteAsync)); + } + // Test helper classes public class TestUseCaseParameter : IUseCaseParameter { @@ -193,4 +212,16 @@ public async Task> ExecuteAsync(TestUseCaseParameter use return result; } } -} \ No newline at end of file + + public class ThrowingUseCaseParameter : IUseCaseParameter + { + } + + public class ThrowingUseCase : IUseCase + { + public Task> ExecuteAsync( + ThrowingUseCaseParameter useCaseParameter, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Handler failed"); + } +} diff --git a/FunctionalUseCases.sln b/FunctionalUseCases.sln index bec2ebb..cad4c6f 100644 --- a/FunctionalUseCases.sln +++ b/FunctionalUseCases.sln @@ -7,8 +7,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalUseCases", "Funct EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample", "Sample\Sample.csproj", "{7F4F006B-27E4-43E9-8B69-68294705CB8B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalUseCases.Tests", "FunctionalUseCases.Tests\FunctionalUseCases.Tests.csproj", "{328E0F3F-68D2-4090-BB86-0CA8516C80B8}" -EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalUseCases.Tests", "FunctionalUseCases.Tests\FunctionalUseCases.Tests.csproj", "{328E0F3F-68D2-4090-BB86-0CA8516C80B8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalUseCases.AspNetCore", "FunctionalUseCases.AspNetCore\FunctionalUseCases.AspNetCore.csproj", "{DCAFA6CF-1B20-4D6B-AAB5-B80D483C6BF8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -28,7 +30,11 @@ Global {7F4F006B-27E4-43E9-8B69-68294705CB8B}.Release|Any CPU.Build.0 = Release|Any CPU {328E0F3F-68D2-4090-BB86-0CA8516C80B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {328E0F3F-68D2-4090-BB86-0CA8516C80B8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {328E0F3F-68D2-4090-BB86-0CA8516C80B8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {328E0F3F-68D2-4090-BB86-0CA8516C80B8}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal + {328E0F3F-68D2-4090-BB86-0CA8516C80B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {328E0F3F-68D2-4090-BB86-0CA8516C80B8}.Release|Any CPU.Build.0 = Release|Any CPU + {DCAFA6CF-1B20-4D6B-AAB5-B80D483C6BF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DCAFA6CF-1B20-4D6B-AAB5-B80D483C6BF8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DCAFA6CF-1B20-4D6B-AAB5-B80D483C6BF8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DCAFA6CF-1B20-4D6B-AAB5-B80D483C6BF8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/FunctionalUseCases/Execution.cs b/FunctionalUseCases/Execution.cs index 81336f6..6278201 100644 --- a/FunctionalUseCases/Execution.cs +++ b/FunctionalUseCases/Execution.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using System.Globalization; namespace FunctionalUseCases; @@ -12,55 +13,106 @@ public static ExecutionResult Success() => VoidSuccess; public static ExecutionResult Failure(IEnumerable messages, int? errorCode = null, LogLevel logLevel = LogLevel.Error) where TResult : notnull => - new(new ExecutionError(messages) { ErrorCode = errorCode, LogLevel = logLevel }); + Failure(messages, ToErrorCode(errorCode), logLevel); public static ExecutionResult Failure(IEnumerable messages, int? errorCode = null, LogLevel logLevel = LogLevel.Error) => - new(new ExecutionError(messages) { ErrorCode = errorCode, LogLevel = logLevel }); + Failure(messages, ToErrorCode(errorCode), logLevel); + + public static ExecutionResult Failure(IEnumerable messages, string? errorCode, LogLevel logLevel = LogLevel.Error, IDictionary? properties = null) where TResult : notnull => + new(new ExecutionError(messages) { ErrorCode = errorCode, LogLevel = logLevel, Properties = CopyProperties(properties) }); + + public static ExecutionResult Failure(IEnumerable messages, string? errorCode, LogLevel logLevel = LogLevel.Error, IDictionary? properties = null) => + new(new ExecutionError(messages) { ErrorCode = errorCode, LogLevel = logLevel, Properties = CopyProperties(properties) }); public static ExecutionResult Failure(Exception exception, LogLevel logLevel = LogLevel.Error, bool suppressPipelineLogging = false) where TResult : notnull => - Failure(GetExceptionMessages(exception), logLevel: logLevel); + new(new ExecutionError(GetExceptionMessages(exception)) { Exception = exception, LogLevel = logLevel }); public static ExecutionResult Failure(string message, Exception ex, LogLevel logLevel = LogLevel.Error) where TResult : notnull => - Failure(new[] { message }.Concat(GetExceptionMessages(ex)), logLevel: logLevel); + new(new ExecutionError(new[] { message }.Concat(GetExceptionMessages(ex))) { Exception = ex, LogLevel = logLevel }); public static ExecutionResult Failure(string message, int? errorCode = null, LogLevel logLevel = LogLevel.Error) where TResult : notnull => Failure(new[] { message }, errorCode, logLevel); - public static ExecutionResult Failure(ExecutionResult result, int? errorCode = null, + public static ExecutionResult Failure(string message, string errorCode, LogLevel logLevel = LogLevel.Error, IDictionary? properties = null) where TResult : notnull => + Failure(new[] { message }, errorCode, logLevel, properties); + + public static ExecutionResult Failure(ExecutionResult result, string? errorCode = null, LogLevel logLevel = LogLevel.Error) where TResult : notnull => - Failure(result.CheckedError.Messages, result.CheckedError.Logged, errorCode ?? result.CheckedError.ErrorCode, logLevel); + Failure(result.CheckedError, errorCode, logLevel); - public static ExecutionResult Failure(ExecutionResult result, int? errorCode = null, LogLevel logLevel = LogLevel.Error) => - Failure(result.CheckedError.Messages, result.CheckedError.Logged, errorCode ?? result.CheckedError.ErrorCode, logLevel); + public static ExecutionResult Failure(ExecutionResult result, string? errorCode = null, LogLevel logLevel = LogLevel.Error) => + Failure(result.CheckedError, errorCode, logLevel); public static ExecutionResult Failure(string message, int? errorCode = null, LogLevel logLevel = LogLevel.Error) => Failure(new[] { message }, errorCode, logLevel); + public static ExecutionResult Failure(string message, string errorCode, LogLevel logLevel = LogLevel.Error, IDictionary? properties = null) => + Failure(new[] { message }, errorCode, logLevel, properties); + public static ExecutionResult Failure(string message, Exception ex, int? errorCode = null, LogLevel logLevel = LogLevel.Error) => - Failure(new[] { message }.Concat(GetExceptionMessages(ex)), errorCode, logLevel); + new(new ExecutionError(new[] { message }.Concat(GetExceptionMessages(ex))) + { + ErrorCode = ToErrorCode(errorCode), + Exception = ex, + LogLevel = logLevel + }); public static ExecutionResult Failure(Exception ex, int? errorCode = null, LogLevel logLevel = LogLevel.Error) => - Failure(GetExceptionMessages(ex).ToArray(), errorCode, logLevel); + new(new ExecutionError(GetExceptionMessages(ex)) + { + ErrorCode = ToErrorCode(errorCode), + Exception = ex, + LogLevel = logLevel + }); public static ExecutionResult Combine(params T[] results) where T : ExecutionResult => results.All(x => x.ExecutionSucceeded) ? Success() - : Failure(ConcatMessages(results), ConcatErrorCode(results)); + : new ExecutionResult(ConcatError(results)); - private static ExecutionResult Failure(IEnumerable messages, bool logged, int? errorCode, LogLevel logLevel) where T : notnull => - new(new ExecutionError(messages) { ErrorCode = errorCode, LogLevel = logLevel, Logged = logged }); + private static ExecutionResult Failure(ExecutionError error, string? errorCode, LogLevel logLevel) where T : notnull => + new(CopyError(error, errorCode, logLevel)); - private static ExecutionResult Failure(IEnumerable messages, bool logged, int? errorCode, LogLevel logLevel) => - new(new ExecutionError(messages) { ErrorCode = errorCode, LogLevel = logLevel, Logged = logged }); + private static ExecutionResult Failure(ExecutionError error, string? errorCode, LogLevel logLevel) => + new(CopyError(error, errorCode, logLevel)); - private static int? ConcatErrorCode(params T[] results) - where T : ExecutionResult => - results.Select(x => x.Error?.ErrorCode).FirstOrDefault(x => x is not null); + private static ExecutionError ConcatError(params T[] results) + where T : ExecutionResult + { + var errors = results.Select(x => x.Error).Where(x => x is not null).Cast().ToArray(); + var properties = errors + .SelectMany(x => x.Properties) + .GroupBy(x => x.Key, StringComparer.Ordinal) + .ToDictionary(x => x.Key, x => x.Last().Value, StringComparer.Ordinal); - private static List ConcatMessages(params T[] results) - where T : ExecutionResult => - results.SelectMany(x => x.Error?.Messages ?? new List()).ToList(); + return new ExecutionError(errors.SelectMany(x => x.Messages)) + { + ErrorCode = errors.Select(x => x.ErrorCode).FirstOrDefault(x => x is not null), + Exception = errors.Select(x => x.Exception).FirstOrDefault(x => x is not null), + LogLevel = errors.Select(x => x.LogLevel).DefaultIfEmpty(LogLevel.Error).Max(), + Logged = errors.All(x => x.Logged), + Properties = properties + }; + } + + private static ExecutionError CopyError(ExecutionError error, string? errorCode, LogLevel logLevel) => + new(error.Messages) + { + ErrorCode = errorCode ?? error.ErrorCode, + Exception = error.Exception, + LogLevel = logLevel, + Logged = error.Logged, + Properties = CopyProperties(error.Properties) + }; + + private static Dictionary CopyProperties(IDictionary? properties) => + properties is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(properties, StringComparer.Ordinal); + + private static string? ToErrorCode(int? errorCode) => + errorCode?.ToString(CultureInfo.InvariantCulture); private static IEnumerable GetExceptionMessages(Exception ex) { @@ -89,4 +141,4 @@ private static IEnumerable GetExceptionMessages(Exception ex) yield return innerMessage; } } -} \ No newline at end of file +} diff --git a/FunctionalUseCases/ExecutionContext.cs b/FunctionalUseCases/ExecutionContext.cs index 324221f..9efad38 100644 --- a/FunctionalUseCases/ExecutionContext.cs +++ b/FunctionalUseCases/ExecutionContext.cs @@ -85,111 +85,12 @@ internal async Task> ExecuteInternalAsync).MakeGenericType(useCaseParameterType, typeof(TResult)); - - var useCase = _serviceProvider.GetService(useCaseType); - if (useCase == null) - { - return Execution.Failure($"No use case registered for parameter type '{useCaseParameterType.Name}'"); - } - - // Get global execution behaviors - var behaviorType = typeof(IExecutionBehavior<,>).MakeGenericType(useCaseParameterType, typeof(TResult)); - var globalBehaviors = _serviceProvider.GetServices(behaviorType).ToArray(); - - // Process per-call behaviors - resolve open generic types and filter for applicable ones - var applicablePerCallBehaviors = new List(); - foreach (var behavior in _perCallBehaviors) - { - if (behavior is OpenGenericBehaviorDescriptor descriptor) - { - // Resolve the open generic type with the current parameter and result types - try - { - var concreteType = descriptor.OpenGenericType.MakeGenericType(useCaseParameterType, typeof(TResult)); - var resolvedBehavior = _serviceProvider.GetService(concreteType); - if (resolvedBehavior != null) - { - applicablePerCallBehaviors.Add(resolvedBehavior); - } - else - { - return Execution.Failure($"Failed to resolve open generic behavior {descriptor.OpenGenericType.Name}<{useCaseParameterType.Name},{typeof(TResult).Name}>: Service not registered"); - } - } - catch (Exception ex) - { - // Log and skip behaviors that can't be resolved - return Execution.Failure($"Failed to resolve open generic behavior {descriptor.OpenGenericType.Name}: {ex.Message}", ex); - } - } - else if (behaviorType.IsInstanceOfType(behavior)) - { - // Handle concrete behavior instances - applicablePerCallBehaviors.Add(behavior); - } - } - - // Combine global and per-call behaviors (per-call behaviors run first) - var allBehaviors = applicablePerCallBehaviors.Concat(globalBehaviors).ToArray(); - - // Build the pipeline by chaining behaviors - PipelineBehaviorDelegate pipeline = async () => - { - var executeMethod = useCaseType.GetMethod("ExecuteAsync"); - if (executeMethod == null) - { - return Execution.Failure($"ExecuteAsync method not found on use case for parameter type '{useCaseParameterType.Name}'"); - } - - // Use reflection to call ExecuteAsync - var task = (Task>?)executeMethod.Invoke(useCase, new object[] { useCaseParameter, cancellationToken }); - if (task == null) - { - return Execution.Failure($"ExecuteAsync method returned null for parameter type '{useCaseParameterType.Name}'"); - } - - return await task.ConfigureAwait(false); - }; - - // Wrap the pipeline with behaviors in reverse order (so they execute in registration order) - for (int i = allBehaviors.Length - 1; i >= 0; i--) - { - var behavior = allBehaviors[i]; - var currentPipeline = pipeline; - - // Create a new pipeline that wraps the current one with this behavior - pipeline = () => - { - // Check if this is a scoped behavior that needs execution scope - var scopedBehaviorType = typeof(IScopedExecutionBehavior<,>).MakeGenericType(useCaseParameterType, typeof(TResult)); - if (scopedBehaviorType.IsInstanceOfType(behavior)) - { - var executeMethod = scopedBehaviorType.GetMethod("ExecuteAsync", - new[] { useCaseParameterType, typeof(IExecutionScope), typeof(PipelineBehaviorDelegate), typeof(CancellationToken) }); - if (executeMethod != null) - { - var task = (Task>?)executeMethod.Invoke(behavior, new object[] { useCaseParameter, scope, currentPipeline, cancellationToken }); - return task ?? currentPipeline(); - } - } - - // Fall back to standard behavior execution - var executeStandardMethod = behaviorType.GetMethod("ExecuteAsync"); - if (executeStandardMethod == null) - { - return currentPipeline(); - } - - var standardTask = (Task>?)executeStandardMethod.Invoke(behavior, new object[] { useCaseParameter, currentPipeline, cancellationToken }); - return standardTask ?? currentPipeline(); - }; - } - - // Execute the complete pipeline - var result = await pipeline().ConfigureAwait(false); - return result; + return await UseCasePipelineInvoker.ExecuteAsync( + _serviceProvider, + useCaseParameter, + _perCallBehaviors, + scope, + cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -256,4 +157,4 @@ public async Task> ExecuteAsync(IUseCaseParame var typedContext = new ExecutionContext(_dispatcher, _serviceProvider, _perCallBehaviors); return await typedContext.ExecuteAsync(useCaseParameter, cancellationToken); } -} \ No newline at end of file +} diff --git a/FunctionalUseCases/ExecutionError.cs b/FunctionalUseCases/ExecutionError.cs index 1b71e09..1e72d8d 100644 --- a/FunctionalUseCases/ExecutionError.cs +++ b/FunctionalUseCases/ExecutionError.cs @@ -29,9 +29,14 @@ public ExecutionError(params T[] messages) public IList Messages { get; set; } = new List(); - public int? ErrorCode { get; set; } + public string? ErrorCode { get; set; } public LogLevel LogLevel { get; set; } = LogLevel.Error; + public Exception? Exception { get; set; } + + public IDictionary Properties { get; set; } = + new Dictionary(StringComparer.Ordinal); + public bool Logged { get; internal set; } = false; -} \ No newline at end of file +} diff --git a/FunctionalUseCases/ExecutionException.cs b/FunctionalUseCases/ExecutionException.cs index cb2e1e5..8444646 100644 --- a/FunctionalUseCases/ExecutionException.cs +++ b/FunctionalUseCases/ExecutionException.cs @@ -1,4 +1,15 @@ namespace FunctionalUseCases; [Serializable] -public class ExecutionException(string message) : Exception(message); \ No newline at end of file +public class ExecutionException : Exception +{ + public ExecutionException(string message) + : base(message) + { + } + + public ExecutionException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/FunctionalUseCases/ExecutionResult.cs b/FunctionalUseCases/ExecutionResult.cs index 3b5f29e..081404e 100644 --- a/FunctionalUseCases/ExecutionResult.cs +++ b/FunctionalUseCases/ExecutionResult.cs @@ -10,7 +10,36 @@ public record ExecutionResult(ExecutionError? Error = null) : ExecutionResult public override bool ExecutionFailed => this.Error is not null || this.Value is null; - public T CheckedValue => this.ExecutionSucceeded ? this.Value! : throw new NullReferenceException(); + public T CheckedValue => this.GetValueOrThrow(); + + public T GetValueOrThrow(string? exceptionMessage = null) + { + if (this.ExecutionSucceeded) + { + return this.Value!; + } + + throw this.CreateExecutionException(exceptionMessage); + } + + public TResult Match( + Func onSuccess, + Func onFailure) => + this.ExecutionSucceeded + ? onSuccess(this.Value!) + : onFailure(this.Error ?? new ExecutionError("Unknown Error")); + + public ExecutionResult Map(Func map) + where TResult : notnull => + this.ExecutionSucceeded + ? Execution.Success(map(this.Value!)) + : Execution.Failure(this); + + public ExecutionResult Bind(Func> bind) + where TResult : notnull => + this.ExecutionSucceeded + ? bind(this.Value!) + : Execution.Failure(this); public static implicit operator ExecutionResult(T value) => new() { Value = value }; @@ -41,16 +70,19 @@ public void ThrowIfFailed(string? exceptionMessage = null) return; } - string BuildInternalMessage() => - this.Error is null - ? "Unknown Error" - : this.Error.Message; + throw this.CreateExecutionException(exceptionMessage); + } - throw new ExecutionException(exceptionMessage is null - ? BuildInternalMessage() - : exceptionMessage + ": " + BuildInternalMessage()); + protected ExecutionException CreateExecutionException(string? exceptionMessage = null) + { + var internalMessage = this.Error?.Message ?? "Unknown Error"; + var message = exceptionMessage is null + ? internalMessage + : exceptionMessage + ": " + internalMessage; + + return new ExecutionException(message, this.Error?.Exception); } public static ExecutionResult operator +(ExecutionResult left, ExecutionResult right) => Execution.Combine(left, right); -} \ No newline at end of file +} diff --git a/FunctionalUseCases/Extensions/ExecutionResultExtensions.cs b/FunctionalUseCases/Extensions/ExecutionResultExtensions.cs index bf87027..8dc3a92 100644 --- a/FunctionalUseCases/Extensions/ExecutionResultExtensions.cs +++ b/FunctionalUseCases/Extensions/ExecutionResultExtensions.cs @@ -28,7 +28,7 @@ public static T Log(this T result, ILogger logger) return result; } - Action logFunc = result.Error.LogLevel switch + Action logFunc = result.Error.LogLevel switch { LogLevel.Error => LogExtensions.Error, LogLevel.Trace => LogExtensions.Trace, @@ -36,12 +36,12 @@ public static T Log(this T result, ILogger logger) LogLevel.Information => LogExtensions.Information, LogLevel.Warning => LogExtensions.Warning, LogLevel.Critical => LogExtensions.Critical, - LogLevel.None => (_, _) => { } + LogLevel.None => (_, _, _) => { } , _ => throw new ArgumentOutOfRangeException() }; - logFunc(logger, result.Error.Message); + logFunc(logger, result.Error.Exception, result.Error.Message); result.CheckedError.Logged = true; @@ -52,20 +52,20 @@ public static T Log(this T result, ILogger logger) static partial class LogExtensions { [LoggerMessage(LogLevel.Information, "ExecutionResult: {Message}")] - public static partial void Information(this ILogger logger, string message); + public static partial void Information(this ILogger logger, Exception? exception, string message); [LoggerMessage(LogLevel.Error, "ExecutionResult: {Message}")] - public static partial void Error(this ILogger logger, string message); + public static partial void Error(this ILogger logger, Exception? exception, string message); [LoggerMessage(LogLevel.Debug, "ExecutionResult: {Message}")] - public static partial void Debug(this ILogger logger, string message); + public static partial void Debug(this ILogger logger, Exception? exception, string message); [LoggerMessage(LogLevel.Warning, "ExecutionResult: {Message}")] - public static partial void Warning(this ILogger logger, string message); + public static partial void Warning(this ILogger logger, Exception? exception, string message); [LoggerMessage(LogLevel.Critical, "ExecutionResult: {Message}")] - public static partial void Critical(this ILogger logger, string message); + public static partial void Critical(this ILogger logger, Exception? exception, string message); [LoggerMessage(LogLevel.Trace, "ExecutionResult: {Message}")] - public static partial void Trace(this ILogger logger, string message); -} \ No newline at end of file + public static partial void Trace(this ILogger logger, Exception? exception, string message); +} diff --git a/FunctionalUseCases/UseCaseChain.cs b/FunctionalUseCases/UseCaseChain.cs index 5a95500..59c6398 100644 --- a/FunctionalUseCases/UseCaseChain.cs +++ b/FunctionalUseCases/UseCaseChain.cs @@ -254,7 +254,7 @@ public async Task> ExecuteAsync(CancellationToken cance // Otherwise, return the failure result return Execution.Failure(error?.Message ?? "Unknown error in chain execution", - error?.ErrorCode ?? 0, error?.LogLevel ?? LogLevel.Error); + error?.ErrorCode ?? "0", error?.LogLevel ?? LogLevel.Error); } currentResult = result; diff --git a/FunctionalUseCases/UseCaseDispatcher.cs b/FunctionalUseCases/UseCaseDispatcher.cs index 069afe8..1f6b62d 100644 --- a/FunctionalUseCases/UseCaseDispatcher.cs +++ b/FunctionalUseCases/UseCaseDispatcher.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.DependencyInjection; - namespace FunctionalUseCases; /// @@ -29,61 +27,12 @@ public async Task> ExecuteAsync(IUseCaseParame try { - var useCaseParameterType = useCaseParameter.GetType(); - var useCaseType = typeof(IUseCase<,>).MakeGenericType(useCaseParameterType, typeof(TResult)); - - var useCase = ServiceProvider.GetService(useCaseType); - if (useCase == null) - { - return Execution.Failure($"No use case registered for parameter type '{useCaseParameterType.Name}'"); - } - - // Get all execution behaviors for this use case parameter and result type - var behaviorType = typeof(IExecutionBehavior<,>).MakeGenericType(useCaseParameterType, typeof(TResult)); - var behaviors = ServiceProvider.GetServices(behaviorType).ToArray(); - - // Build the pipeline by chaining behaviors - PipelineBehaviorDelegate pipeline = async () => - { - var executeMethod = useCaseType.GetMethod("ExecuteAsync"); - if (executeMethod == null) - { - return Execution.Failure($"ExecuteAsync method not found on use case for parameter type '{useCaseParameterType.Name}'"); - } - - // Use reflection to call ExecuteAsync - var task = (Task>?)executeMethod.Invoke(useCase, new object[] { useCaseParameter, cancellationToken }); - if (task == null) - { - return Execution.Failure($"ExecuteAsync method returned null for parameter type '{useCaseParameterType.Name}'"); - } - - return await task.ConfigureAwait(false); - }; - - // Wrap the pipeline with behaviors in reverse order (so they execute in registration order) - for (int i = behaviors.Length - 1; i >= 0; i--) - { - var behavior = behaviors[i]; - var currentPipeline = pipeline; - - // Create a new pipeline that wraps the current one with this behavior - pipeline = () => - { - var executeMethod = behaviorType.GetMethod("ExecuteAsync"); - if (executeMethod == null) - { - return currentPipeline(); - } - - var task = (Task>?)executeMethod.Invoke(behavior, new object[] { useCaseParameter, currentPipeline, cancellationToken }); - return task ?? currentPipeline(); - }; - } - - // Execute the complete pipeline - var result = await pipeline().ConfigureAwait(false); - return result; + return await UseCasePipelineInvoker.ExecuteAsync( + ServiceProvider, + useCaseParameter, + perCallBehaviors: null, + ExecutionScope.SingleUseCase, + cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/FunctionalUseCases/UseCasePipelineInvoker.cs b/FunctionalUseCases/UseCasePipelineInvoker.cs new file mode 100644 index 0000000..cfc5a4c --- /dev/null +++ b/FunctionalUseCases/UseCasePipelineInvoker.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; + +namespace FunctionalUseCases; + +internal static class UseCasePipelineInvoker + where TResult : notnull +{ + private static readonly ConcurrentDictionary Invokers = new(); + + public static Task> ExecuteAsync( + IServiceProvider serviceProvider, + IUseCaseParameter useCaseParameter, + IEnumerable? perCallBehaviors, + IExecutionScope scope, + CancellationToken cancellationToken) + { + var parameterType = useCaseParameter.GetType(); + var invoker = Invokers.GetOrAdd(parameterType, static type => + { + var invokerType = typeof(UseCasePipelineInvoker<,>).MakeGenericType(type, typeof(TResult)); + return (IInvoker)Activator.CreateInstance(invokerType)!; + }); + + return invoker.ExecuteAsync( + serviceProvider, + useCaseParameter, + perCallBehaviors ?? [], + scope, + cancellationToken); + } + + internal interface IInvoker + { + Task> ExecuteAsync( + IServiceProvider serviceProvider, + IUseCaseParameter useCaseParameter, + IEnumerable perCallBehaviors, + IExecutionScope scope, + CancellationToken cancellationToken); + } + +} + +internal sealed class UseCasePipelineInvoker : + UseCasePipelineInvoker.IInvoker + where TUseCaseParameter : IUseCaseParameter + where TResult : notnull +{ + public async Task> ExecuteAsync( + IServiceProvider serviceProvider, + IUseCaseParameter useCaseParameter, + IEnumerable perCallBehaviors, + IExecutionScope scope, + CancellationToken cancellationToken) + { + var parameter = (TUseCaseParameter)useCaseParameter; + var useCase = serviceProvider.GetService>(); + if (useCase is null) + { + return Execution.Failure( + $"No use case registered for parameter type '{typeof(TUseCaseParameter).Name}'"); + } + + var applicablePerCallBehaviors = new List>(); + foreach (var behavior in perCallBehaviors) + { + if (behavior is OpenGenericBehaviorDescriptor descriptor) + { + try + { + var concreteType = descriptor.OpenGenericType.MakeGenericType( + typeof(TUseCaseParameter), + typeof(TResult)); + var resolvedBehavior = serviceProvider.GetService(concreteType); + if (resolvedBehavior is not IExecutionBehavior typedBehavior) + { + return Execution.Failure( + $"Failed to resolve open generic behavior {descriptor.OpenGenericType.Name}<{typeof(TUseCaseParameter).Name},{typeof(TResult).Name}>: Service not registered"); + } + + applicablePerCallBehaviors.Add(typedBehavior); + } + catch (Exception ex) + { + return Execution.Failure( + $"Failed to resolve open generic behavior {descriptor.OpenGenericType.Name}: {ex.Message}", + ex); + } + } + else if (behavior is IExecutionBehavior typedBehavior) + { + applicablePerCallBehaviors.Add(typedBehavior); + } + } + + var behaviors = applicablePerCallBehaviors + .Concat(serviceProvider.GetServices>()) + .ToArray(); + + PipelineBehaviorDelegate pipeline = () => + useCase.ExecuteAsync(parameter, cancellationToken); + + for (var i = behaviors.Length - 1; i >= 0; i--) + { + var behavior = behaviors[i]; + var next = pipeline; + pipeline = behavior is IScopedExecutionBehavior scopedBehavior + ? () => scopedBehavior.ExecuteAsync(parameter, scope, next, cancellationToken) + : () => behavior.ExecuteAsync(parameter, next, cancellationToken); + } + + return await pipeline().ConfigureAwait(false); + } +} diff --git a/README.md b/README.md index 707add7..fce6541 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,11 @@ public record ExecutionResult(ExecutionError? Error = null) : ExecutionResult { public bool ExecutionSucceeded { get; } public bool ExecutionFailed { get; } - public T CheckedValue { get; } // Throws if failed + public T CheckedValue { get; } // Throws ExecutionException if failed + public T GetValueOrThrow(string? exceptionMessage = null); + public TResult Match(Func onSuccess, Func onFailure); + public ExecutionResult Map(Func map); + public ExecutionResult Bind(Func> bind); } // Non-generic variant @@ -170,15 +174,22 @@ ExecutionResult result = "Hello World"; // Automatically creates success Rich error information with support for multiple messages, error codes, and logging levels: ```csharp -public record ExecutionError( - string Message, - string? ErrorCode = null, - LogLevel LogLevel = LogLevel.Error, - Exception? Exception = null, - IDictionary? Properties = null -); +public record ExecutionError : ExecutionError; + +public record ExecutionError +{ + public string Message { get; } + public IList Messages { get; set; } + public string? ErrorCode { get; set; } + public LogLevel LogLevel { get; set; } + public Exception? Exception { get; set; } + public IDictionary Properties { get; set; } +} ``` +Exceptions passed to `Execution.Failure(...)` remain available through +`ExecutionError.Exception`, including original type and stack trace. + ### IUseCaseDispatcher Mediator that resolves and executes use cases: @@ -887,7 +898,7 @@ var result = await dispatcher.ExecuteAsync(useCaseParameter); // Pattern 1: Check success and access value if (result.ExecutionSucceeded) { - var value = result.CheckedValue; // Safe access to value + var value = result.GetValueOrThrow(); Console.WriteLine(value); } @@ -909,6 +920,14 @@ if (result.ExecutionFailed) // Pattern 3: Throw on failure result.ThrowIfFailed("Custom error message"); + +// Pattern 4: Functional composition +var displayName = result + .Map(value => value.ToString()) + .Bind(value => string.IsNullOrWhiteSpace(value) + ? Execution.Failure("Display name is empty", "EMPTY_DISPLAY_NAME") + : Execution.Success(value)) + .Match(value => value, error => $"Failed: {error.Message}"); ``` ### Logging Integration @@ -918,10 +937,26 @@ var result = Execution.Failure("Database connection failed", errorCode: "DB_001", logLevel: LogLevel.Critical); -// Use logging extensions -result.LogIfFailed(logger, "Failed to process user request"); +// Use logging extension. Preserved exceptions are passed to ILogger. +result.Log(logger); +``` + +### ASP.NET Core Mapping + +Install optional `FunctionalUseCases.AspNetCore` package to map results without +adding ASP.NET Core dependencies to core package: + +```csharp +using FunctionalUseCases.AspNetCore; + +return result.ToActionResult(); ``` +Failures become RFC-style `ProblemDetails`. Numeric HTTP error codes map +directly; domain codes can provide `Properties["statusCode"]` or a custom +`ExecutionResultHttpOptions.StatusCodeSelector`. Exception details remain hidden +unless `IncludeExceptionDetails` is enabled. + ## Example Use Cases The library includes a comprehensive sample implementation demonstrating the pattern: