diff --git a/DiagnosableExceptions.GenDoc/SolutionErrorDocumentationGenerator.cs b/DiagnosableExceptions.GenDoc/SolutionErrorDocumentationGenerator.cs index 6d05890f..e1227aa3 100644 --- a/DiagnosableExceptions.GenDoc/SolutionErrorDocumentationGenerator.cs +++ b/DiagnosableExceptions.GenDoc/SolutionErrorDocumentationGenerator.cs @@ -99,13 +99,19 @@ private static IReadOnlyList FilterProjects(IReadOnlyList included = new(); foreach (ProjectInfo project in projects) { - bool optedIn = TryReadOptInFromProjectFile(project.ProjectPath, options.OptInPropertyName); - if (optedIn) { + bool? optedIn = TryReadOptInFromProjectFile(project.ProjectPath, options.OptInPropertyName); + + if (optedIn == true) { included.Add(project); continue; } + // An explicit opt-out (property present and set to a falsy value) is always honored, even when + // IncludeProjectsWithoutOptIn is true. + if (optedIn == false) { continue; } + + // The opt-in property is absent: fall back to the global policy. if (options.IncludeProjectsWithoutOptIn) { included.Add(project); } @@ -114,10 +120,10 @@ private static IReadOnlyList FilterProjects(IReadOnlyList ExtractFromAssemblies(IReadOnlyLi results.AddRange(docs); } - return results.OrderBy(x => x.Code, StringComparer.OrdinalIgnoreCase); + // Deduplicate across assemblies as well: the same error Code declared in two assemblies must collapse to a + // single catalog entry, mirroring the per-assembly deduplication performed by the reader. + return results + .GroupBy(x => x.Code, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .OrderBy(x => x.Code, StringComparer.OrdinalIgnoreCase); } #endregion diff --git a/DiagnosableExceptions.GenDoc/SolutionGenerationOptions.cs b/DiagnosableExceptions.GenDoc/SolutionGenerationOptions.cs index 4a41e801..d7d69278 100644 --- a/DiagnosableExceptions.GenDoc/SolutionGenerationOptions.cs +++ b/DiagnosableExceptions.GenDoc/SolutionGenerationOptions.cs @@ -20,8 +20,8 @@ public sealed class SolutionGenerationOptions { public string OptInPropertyName { get; init; } = "GenerateErrorDocumentation"; /// - /// Additional arguments passed to "dotnet build". - /// Example: "--no-restore" + /// Additional arguments appended verbatim to the "dotnet build" command line (e.g. "--no-restore"). + /// Defaults to "--nologo". /// public string? DotNetBuildAdditionalArguments { get; init; } = "--nologo"; diff --git a/DiagnosableExceptions.UnitTests/AssemblyErrorDocumentationReaderTests.cs b/DiagnosableExceptions.UnitTests/AssemblyErrorDocumentationReaderTests.cs index 07266213..7ff4c694 100644 --- a/DiagnosableExceptions.UnitTests/AssemblyErrorDocumentationReaderTests.cs +++ b/DiagnosableExceptions.UnitTests/AssemblyErrorDocumentationReaderTests.cs @@ -13,8 +13,8 @@ namespace DiagnosableExceptions.UnitTests { public class AssemblyErrorDocumentationReaderTests { - [Fact] - public void Test1() { + [Fact(DisplayName = "The reader extracts the documented errors of an assembly, keyed by their error code.")] + public void TheReaderExtractsTheDocumentedErrorsOfAnAssembly() { // Setup Assembly assembly = Assembly.GetAssembly(typeof(Temperature))!; @@ -25,10 +25,13 @@ public void Test1() { ErrorDocumentation[] errorDocumentations = documentation.ToArray(); Check.That(errorDocumentations).CountIs(4); - ErrorDocumentation amountCurrencyMismatch = errorDocumentations[0]; - ErrorDocumentation bankTransactionFileDateOutOfStatementPeriod = errorDocumentations[1]; - ErrorDocumentation bankTransactionFileStatementTotalAmountMismatch = errorDocumentations[2]; - ErrorDocumentation temperatureBelowAbsoluteZero = errorDocumentations[3]; + + Dictionary byCode = errorDocumentations.ToDictionary(doc => doc.Code!, StringComparer.Ordinal); + + ErrorDocumentation amountCurrencyMismatch = byCode["AMOUNT_CURRENCY_MISMATCH"]; + ErrorDocumentation bankTransactionFileDateOutOfStatementPeriod = byCode["BANK_TRANSACTION_FILE_DATE_OUT_OF_STATEMENT_PERIOD"]; + ErrorDocumentation bankTransactionFileStatementTotalAmountMismatch = byCode["BANK_TRANSACTION_FILE_STATEMENT_TOTAL_AMOUNT_MISMATCH"]; + ErrorDocumentation temperatureBelowAbsoluteZero = byCode["TEMPERATURE_BELOW_ABSOLUTE_ZERO"]; // ------------------------------------------------------------------ // AMOUNT_CURRENCY_MISMATCH diff --git a/DiagnosableExceptions.UnitTests/ErrorDocumentationBuilderTests.cs b/DiagnosableExceptions.UnitTests/ErrorDocumentationBuilderTests.cs index 1df63a03..314cf2c9 100644 --- a/DiagnosableExceptions.UnitTests/ErrorDocumentationBuilderTests.cs +++ b/DiagnosableExceptions.UnitTests/ErrorDocumentationBuilderTests.cs @@ -206,7 +206,7 @@ public void AnErrorDocumentationBuilderRejectsANullExample() { // Exercise & verify Check.ThatCode(() => builder.WithExamples(factory)) .Throws() - .WithMessage("Example factory at index 0 returned null. Factories must return a valid exception instance."); + .WithMessage("Example factory at index 0 returned null. Factories must return a valid error instance."); } [Fact(DisplayName = "An error documentation builder rejects inconsistent error codes across examples.")] @@ -223,7 +223,7 @@ public void AnErrorDocumentationBuilderRejectsInconsistentErrorCodesAcrossExampl // Exercise & verify Check.ThatCode(() => builder.WithExamples(first, second)) .Throws() - .WithMessage("All example factories must produce exceptions with the same ErrorCode. Example at index 1 produced a different ErrorCode. Expected 'CODE_A', but received 'CODE_B'."); + .WithMessage("All example factories must produce errors with the same ErrorCode. Example at index 1 produced a different ErrorCode. Expected 'CODE_A', but received 'CODE_B'."); } [Fact(DisplayName = "An error documentation builder uses the examples error code as documentation code.")] diff --git a/DiagnosableExceptions.UnitTests/DiagnosableExceptionTests.cs b/DiagnosableExceptions.UnitTests/ErrorTests.cs similarity index 89% rename from DiagnosableExceptions.UnitTests/DiagnosableExceptionTests.cs rename to DiagnosableExceptions.UnitTests/ErrorTests.cs index 420d9588..465de772 100644 --- a/DiagnosableExceptions.UnitTests/DiagnosableExceptionTests.cs +++ b/DiagnosableExceptions.UnitTests/ErrorTests.cs @@ -12,11 +12,11 @@ namespace DiagnosableExceptions.UnitTests; [Collection("SmartEnumSideEffects")] [TestSubject(typeof(Error))] -public sealed class DiagnosableExceptionTests : IDisposable { +public sealed class ErrorTests : IDisposable { #region Constructors declarations - public DiagnosableExceptionTests() { + public ErrorTests() { ErrorContextKey.ResetForTests(); ErrorCode.ResetForTests(); } @@ -29,7 +29,7 @@ public void Dispose() { ErrorCode.ResetForTests(); } - [Fact(DisplayName = "A diagnosable exception has a unique instance identifier.")] + [Fact(DisplayName = "An error has a unique instance identifier.")] public void ADiagnosableExceptionHasAUniqueInstanceIdentifier() { // Setup ErrorCode anyErrorCode = ErrorCodeFactory.CreateAny(); @@ -45,7 +45,7 @@ public void ADiagnosableExceptionHasAUniqueInstanceIdentifier() { Check.That(firstError.InstanceId).IsNotEqualTo(secondError.InstanceId); } - [Fact(DisplayName = "A diagnosable exception captures its occurrence time in UTC.")] + [Fact(DisplayName = "An error captures its occurrence time in UTC.")] public void ADiagnosableExceptionCapturesItsOccurrenceTimeInUtc() { // Setup ErrorCode anyErrorCode = ErrorCodeFactory.CreateAny(); @@ -67,7 +67,7 @@ public void ADiagnosableExceptionCapturesItsOccurrenceTimeInUtc() { Check.That(error.OccurredAt <= after).IsTrue(); } - [Fact(DisplayName = "A diagnosable exception preserves the provided error code.")] + [Fact(DisplayName = "An error preserves the provided error code.")] public void ADiagnosableExceptionPreservesTheProvidedErrorCode() { // Setup string anyErrorMessage = ErrorMessageFactory.CreateAnyMessage(); @@ -80,7 +80,7 @@ public void ADiagnosableExceptionPreservesTheProvidedErrorCode() { Check.That(error.Code).IsEqualTo(temperatureBelowAbsoluteZero); } - [Fact(DisplayName = "A diagnosable exception preserves the provided short message.")] + [Fact(DisplayName = "An error preserves the provided short message.")] public void ADiagnosableExceptionPreservesTheProvidedShortMessage() { // Exercise string anyErrorMessage = ErrorMessageFactory.CreateAnyMessage(); @@ -91,7 +91,7 @@ public void ADiagnosableExceptionPreservesTheProvidedShortMessage() { Check.That(error.ShortMessage).IsEqualTo("short"); } - [Fact(DisplayName = "A diagnosable exception has an empty context when no context is provided.")] + [Fact(DisplayName = "An error has an empty context when no context is provided.")] public void ADiagnosableExceptionHasAnEmptyContextWhenNoContextIsProvided() { // Exercise string anyErrorMessage = ErrorMessageFactory.CreateAnyMessage(); @@ -104,7 +104,7 @@ public void ADiagnosableExceptionHasAnEmptyContextWhenNoContextIsProvided() { Check.That(error.Context.Values).CountIs(0); } - [Fact(DisplayName = "A diagnosable exception includes the provided context entries.")] + [Fact(DisplayName = "An error includes the provided context entries.")] public void ADiagnosableExceptionIncludesTheProvidedContextEntries() { // Setup string anyErrorMessage = ErrorMessageFactory.CreateAnyMessage(); @@ -123,7 +123,7 @@ public void ADiagnosableExceptionIncludesTheProvidedContextEntries() { Check.That(value).IsEqualTo("u-123"); } - [Fact(DisplayName = "A diagnosable exception has no inner exceptions by default.")] + [Fact(DisplayName = "An error has no inner errors by default.")] public void ADiagnosableExceptionHasNoInnerExceptionsByDefault() { // Exercise string anyErrorMessage = ErrorMessageFactory.CreateAnyMessage(); @@ -135,7 +135,7 @@ public void ADiagnosableExceptionHasNoInnerExceptionsByDefault() { Check.That(error.InnerErrors).CountIs(0); } - [Fact(DisplayName = "A diagnosable exception preserves a single inner exception.")] + [Fact(DisplayName = "An error preserves a single inner error.")] public void ADiagnosableExceptionPreservesASingleInnerException() { // Setup string anyErrorMessage = ErrorMessageFactory.CreateAnyMessage(); @@ -150,7 +150,7 @@ public void ADiagnosableExceptionPreservesASingleInnerException() { Check.That(rootError.InnerErrors[0]).IsSameReferenceAs(innerError); } - [Fact(DisplayName = "A diagnosable exception preserves multiple inner exceptions.")] + [Fact(DisplayName = "An error preserves multiple inner errors.")] public void ADiagnosableExceptionPreservesMultipleInnerExceptions() { // Setup string anyErrorMessage = ErrorMessageFactory.CreateAnyMessage(); @@ -170,7 +170,7 @@ public void ADiagnosableExceptionPreservesMultipleInnerExceptions() { Check.That(rootError.InnerErrors[1]).IsSameReferenceAs(secondInnerError); } - [Fact(DisplayName = "A diagnosable exception can be created without inner exceptions even when a null collection is provided.")] + [Fact(DisplayName = "An error can be created without inner errors even when a null collection is provided.")] public void ADiagnosableExceptionCanBeCreatedWithoutInnerExceptionsEvenWhenANullCollectionIsProvided() { // Exercise ErrorCode anyErrorCode = ErrorCodeFactory.CreateAny(); @@ -181,7 +181,7 @@ public void ADiagnosableExceptionCanBeCreatedWithoutInnerExceptionsEvenWhenANull Check.That(error.InnerErrors).CountIs(0); } - [Fact(DisplayName = "A diagnosable exception created with a null inner exception has no inner exceptions.")] + [Fact(DisplayName = "An error created with a null inner error has no inner errors.")] public void ADiagnosableExceptionCreatedWithANullInnerExceptionHasNoInnerExceptions() { // Exercise ErrorCode anyErrorCode = ErrorCodeFactory.CreateAny(); diff --git a/DiagnosableExceptions.UnitTests/InfrastructureExceptionTests.cs b/DiagnosableExceptions.UnitTests/InfrastructureExceptionTests.cs deleted file mode 100644 index a328a68d..00000000 --- a/DiagnosableExceptions.UnitTests/InfrastructureExceptionTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -//#region Usings declarations - -//using System.Diagnostics.CodeAnalysis; - -//using JetBrains.Annotations; - -//using NFluent; - -//#endregion - -//namespace DiagnosableExceptions.UnitTests; - -//[Collection("SmartEnumSideEffects")] -//[TestSubject(typeof(InfrastructureException))] -//public sealed class InfrastructureExceptionTests : IDisposable { - -// #region Constructors declarations - -// public InfrastructureExceptionTests() { -// ErrorContextKey.ResetForTests(); -// ErrorCode.ResetForTests(); -// } - -// #endregion - -// [SuppressMessage("Usage", "CA1816", Justification = "IDisposable is used as an xUnit teardown hook. The class has no finalizer and does not own unmanaged resources.")] -// public void Dispose() { -// ErrorContextKey.ResetForTests(); -// ErrorCode.ResetForTests(); -// } - -// [Theory(DisplayName = "An infrastructure exception preserves its transient classification.")] -// [InlineData(Transience.Transient)] -// [InlineData(Transience.NonTransient)] -// [InlineData(Transience.Unknown)] -// public void AnInfrastructureExceptionPreservesItsTransientClassification(Transience isTransient) { -// // Setup -// ErrorCode anyErrorCode = ErrorCodeFactory.CreateAny(); -// string anyMessage = ExceptionMessageFactory.CreateAnyMessage(); - -// // Exercise -// TestInfrastructureException exception = new(anyErrorCode, anyMessage, isTransient); - -// // Verify -// Check.That(exception.Transience).IsEqualTo(isTransient); -// } - -// [Theory(DisplayName = "An infrastructure exception with an inner exception preserves its transient classification.")] -// [InlineData(Transience.Transient)] -// [InlineData(Transience.NonTransient)] -// [InlineData(Transience.Unknown)] -// public void AnInfrastructureExceptionWithAnInnerExceptionPreservesItsTransientClassification(Transience isTransient) { -// // Setup -// ErrorCode anyErrorCode = ErrorCodeFactory.CreateAny(); -// string anyMessage = ExceptionMessageFactory.CreateAnyMessage(); -// Exception inner = new InvalidOperationException("inner"); - -// // Exercise -// TestInfrastructureException exception = new(anyErrorCode, anyMessage, isTransient, inner); - -// // Verify -// Check.That(exception.Transience).IsEqualTo(isTransient); -// } - -// [Theory(DisplayName = "An infrastructure exception with multiple inner exceptions preserves its transient classification.")] -// [InlineData(Transience.Transient)] -// [InlineData(Transience.NonTransient)] -// [InlineData(Transience.Unknown)] -// public void AnInfrastructureExceptionWithMultipleInnerExceptionsPreservesItsTransientClassification(Transience isTransient) { -// // Setup -// ErrorCode anyErrorCode = ErrorCodeFactory.CreateAny(); -// string anyMessage = ExceptionMessageFactory.CreateAnyMessage(); - -// Exception first = new InvalidOperationException("first"); -// Exception second = new ArgumentException("second"); - -// IEnumerable innerExceptions = new[] { first, second }; - -// // Exercise -// TestInfrastructureException exception = new(anyErrorCode, anyMessage, isTransient, innerExceptions); - -// // Verify -// Check.That(exception.Transience).IsEqualTo(isTransient); -// } - -// #region Nested types declarations - -// //private static class aMessageFactory { - -// #region Statics members declarations - -// public static string CreateAnyMessage() { -// return "boom"; -// } - -// #endregion - -// } - -// #endregion - -//} - diff --git a/DiagnosableExceptions.UnitTests/OutcomeTests.cs b/DiagnosableExceptions.UnitTests/OutcomeTests.cs new file mode 100644 index 00000000..8f77f86d --- /dev/null +++ b/DiagnosableExceptions.UnitTests/OutcomeTests.cs @@ -0,0 +1,287 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace DiagnosableExceptions.UnitTests; + +[TestSubject(typeof(Outcome<>))] +public sealed class OutcomeTests { + + [Fact(DisplayName = "A successful outcome is marked as success.")] + public void SuccessfulOutcomeIsMarkedAsSuccess() { + // Exercise + Outcome outcome = Outcome.Success("ok"); + + // Verify + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.IsFailure).IsFalse(); + Check.That(outcome.Error).IsNull(); + } + + [Fact(DisplayName = "A successful outcome exposes its value.")] + public void SuccessfulOutcomeExposesItsValue() { + // Exercise + Outcome outcome = Outcome.Success("ok"); + + // Verify + Check.That(outcome.GetResultOrThrow()).IsEqualTo("ok"); + } + + [Fact(DisplayName = "A successful outcome can be escalated to a value.")] + public void ASuccessfulOutcomeCanBeEscalatedToAValue() { + // Exercise + Outcome outcome = Outcome.Success("ok"); + + // Verify + Check.That(outcome.GetResultOrThrow()).IsEqualTo("ok"); + } + + [Fact(DisplayName = "A successful outcome cannot be created from a null value.")] + public void SuccessfulOutcomeCannotBeCreatedFromANullValue() { + // Exercise & verify + Check.ThatCode(() => Outcome.Success(null!)) + .Throws(); + } + + [Fact(DisplayName = "A failed outcome is marked as failure.")] + public void FailedOutcomeIsMarkedAsFailure() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + + // Exercise + Outcome outcome = Outcome.Failure(error); + + // Verify + Check.That(outcome.IsSuccess).IsFalse(); + Check.That(outcome.IsFailure).IsTrue(); + } + + [Fact(DisplayName = "A failed outcome exposes its error.")] + public void AFailedOutcomeExposesItsError() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + + // Exercise + Outcome outcome = Outcome.Failure(error); + + // Verify + Check.That(outcome.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "A failed outcome cannot be created from a null error.")] + public void AFailedOutcomeCannotBeCreatedFromANullError() { + // Exercise & verify + Check.ThatCode(() => Outcome.Failure(null!)) + .ThrowsAny(); + } + + [Fact(DisplayName = "Accessing the value of a failed outcome throws the associated exception.")] + public void AccessingTheValueOfAFailedOutcomeThrowsTheAssociatedException() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + Outcome outcome = Outcome.Failure(error); + + // Exercise & verify + Check.ThatCode(() => _ = outcome.GetResultOrThrow()) + .Throws() + .WithMessage("boom"); + } + + [Fact(DisplayName = "Escalating a failed outcome throws the associated exception.")] + public void EscalatingAFailedOutcomeThrowsTheAssociatedException() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + Outcome outcome = Outcome.Failure(error); + + // Exercise & verify + Check.ThatCode(() => outcome.GetResultOrThrow()) + .Throws() + .WithMessage("boom"); + } + + [Fact(DisplayName = "A failed outcome preserves the original error instance.")] + public void FailedOutcomePreservesTheOriginalErrorInstance() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + Outcome outcome = Outcome.Failure(error); + + // Exercise & verify + DomainException thrownException = Check.ThatCode(() => outcome.GetResultOrThrow()).Throws().Value; + Check.That(thrownException.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "ThrowIfFailure throws the associated exception when the outcome is a failure.")] + public void ThrowIfFailureThrowsTheAssociatedExceptionWhenTheOutcomeIsAFailure() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + Outcome outcome = Outcome.Failure(error); + + // Exercise & verify + Check.ThatCode(() => outcome.ThrowIfFailure()) + .Throws() + .WithMessage("boom"); + } + + [Fact(DisplayName = "ThrowIfFailure does nothing when the outcome is a success.")] + public void ThrowIfFailureDoesNothingWhenTheOutcomeIsASuccess() { + // Setup + Outcome outcome = Outcome.Success("ok"); + + // Exercise & verify + Check.ThatCode(() => outcome.ThrowIfFailure()).DoesNotThrow(); + } + + [Fact(DisplayName = "Then chains the next step when the outcome is a success.")] + public void ThenChainsTheNextStepWhenTheOutcomeIsASuccess() { + // Setup + Outcome outcome = Outcome.Success(2); + + // Exercise + Outcome result = outcome.Then(value => Outcome.Success(value * 10)); + + // Verify + Check.That(result.IsSuccess).IsTrue(); + Check.That(result.GetResultOrThrow()).IsEqualTo(20); + } + + [Fact(DisplayName = "Then short-circuits and propagates the error when the outcome is a failure.")] + public void ThenShortCircuitsAndPropagatesTheErrorWhenTheOutcomeIsAFailure() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + Outcome outcome = Outcome.Failure(error); + bool called = false; + + // Exercise + Outcome result = outcome.Then(value => { + called = true; + + return Outcome.Success(value); + }); + + // Verify + Check.That(called).IsFalse(); + Check.That(result.IsFailure).IsTrue(); + Check.That(result.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "To maps the value when the outcome is a success.")] + public void ToMapsTheValueWhenTheOutcomeIsASuccess() { + // Setup + Outcome outcome = Outcome.Success(3); + + // Exercise + Outcome result = outcome.To(value => $"v={value}"); + + // Verify + Check.That(result.GetResultOrThrow()).IsEqualTo("v=3"); + } + + [Fact(DisplayName = "To propagates the error without invoking the converter on a failure.")] + public void ToPropagatesTheErrorWithoutInvokingTheConverterOnAFailure() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + Outcome outcome = Outcome.Failure(error); + + // Exercise + Outcome result = outcome.To(value => value.ToString()); + + // Verify + Check.That(result.IsFailure).IsTrue(); + Check.That(result.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "Recover replaces a failure with a guaranteed fallback value.")] + public void RecoverReplacesAFailureWithAGuaranteedFallbackValue() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + Outcome outcome = Outcome.Failure(error); + + // Exercise + Outcome result = outcome.Recover(_ => 42); + + // Verify + Check.That(result.IsSuccess).IsTrue(); + Check.That(result.GetResultOrThrow()).IsEqualTo(42); + } + + [Fact(DisplayName = "Recover leaves a successful outcome unchanged.")] + public void RecoverLeavesASuccessfulOutcomeUnchanged() { + // Setup + Outcome outcome = Outcome.Success(7); + + // Exercise + Outcome result = outcome.Recover(_ => 42); + + // Verify + Check.That(result.GetResultOrThrow()).IsEqualTo(7); + } + + [Fact(DisplayName = "Finally resolves the success branch when the outcome is a success.")] + public void FinallyResolvesTheSuccessBranchWhenTheOutcomeIsASuccess() { + // Setup + Outcome outcome = Outcome.Success(5); + + // Exercise + string result = outcome.Finally(value => $"ok:{value}", _ => "ko"); + + // Verify + Check.That(result).IsEqualTo("ok:5"); + } + + [Fact(DisplayName = "Finally resolves the failure branch when the outcome is a failure.")] + public void FinallyResolvesTheFailureBranchWhenTheOutcomeIsAFailure() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + Outcome outcome = Outcome.Failure(error); + + // Exercise + string result = outcome.Finally(value => $"ok:{value}", failure => $"ko:{failure.DetailedMessage}"); + + // Verify + Check.That(result).IsEqualTo("ko:boom"); + } + + [Fact(DisplayName = "The non-generic Outcome.Then chains when successful and propagates on failure.")] + public void NonGenericOutcomeThenChainsWhenSuccessfulAndPropagatesOnFailure() { + // Setup + DomainError error = new(ErrorCode.Unspecified, "boom"); + + // Exercise + Outcome chainedFromSuccess = Outcome.Success.Then(() => Outcome.Success); + Outcome chainedFromFailure = Outcome.Failure(error).Then(() => Outcome.Success); + + // Verify + Check.That(chainedFromSuccess.IsSuccess).IsTrue(); + Check.That(chainedFromFailure.IsFailure).IsTrue(); + Check.That(chainedFromFailure.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "Awaiting Then over a Task> chains the next step.")] + public async Task AwaitingThenOverATaskOutcomeChainsTheNextStep() { + // Setup + Task> task = Task.FromResult(Outcome.Success(4)); + + // Exercise + Outcome result = await task.Then(value => Outcome.Success(value + 1)); + + // Verify + Check.That(result.GetResultOrThrow()).IsEqualTo(5); + } + + [Fact(DisplayName = "Awaiting To over a Task> maps the value.")] + public async Task AwaitingToOverATaskOutcomeMapsTheValue() { + // Setup + Task> task = Task.FromResult(Outcome.Success(6)); + + // Exercise + Outcome result = await task.To(value => $"n={value}"); + + // Verify + Check.That(result.GetResultOrThrow()).IsEqualTo("n=6"); + } + +} diff --git a/DiagnosableExceptions.UnitTests/TryOutcomeTests.cs b/DiagnosableExceptions.UnitTests/TryOutcomeTests.cs deleted file mode 100644 index a56cea37..00000000 --- a/DiagnosableExceptions.UnitTests/TryOutcomeTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -#region Usings declarations - -using JetBrains.Annotations; - -using NFluent; - -#endregion - -namespace DiagnosableExceptions.UnitTests; - -[TestSubject(typeof(Outcome<>))] -public sealed class TryOutcomeTests { - - [Fact(DisplayName = "A successful outcome is marked as success.")] - public void SuccessfulOutcomeIsMarkedAsSuccess() { - // Exercise - Outcome outcome = Outcome.Success("ok"); - - // Verify - Check.That(outcome.IsSuccess).IsTrue(); - Check.That(outcome.IsFailure).IsFalse(); - Check.That(outcome.Error).IsNull(); - } - - [Fact(DisplayName = "A successful outcome exposes its value.")] - public void SuccessfulOutcomeExposesItsValue() { - // Exercise - Outcome outcome = Outcome.Success("ok"); - - // Verify - Check.That(outcome.GetResultOrThrow()).IsEqualTo("ok"); - } - - [Fact(DisplayName = "A successful outcome can be escalated to a value.")] - public void ASuccessfulOutcomeCanBeEscalatedToAValue() { - // Exercise - Outcome outcome = Outcome.Success("ok"); - - // Verify - Check.That(outcome.GetResultOrThrow()).IsEqualTo("ok"); - } - - [Fact(DisplayName = "A successful outcome cannot be created from a null value.")] - public void SuccessfulOutcomeCannotBeCreatedFromANullValue() { - // Exercise & verify - Check.ThatCode(() => Outcome.Success(null!)) - .Throws(); - } - - [Fact(DisplayName = "A failed outcome is marked as failure.")] - public void FailedOutcomeIsMarkedAsFailure() { - // Setup - DomainError error = new(ErrorCode.Unspecified, "boom"); - - // Exercise - Outcome outcome = Outcome.Failure(error); - - // Verify - Check.That(outcome.IsSuccess).IsFalse(); - Check.That(outcome.IsFailure).IsTrue(); - } - - [Fact(DisplayName = "A failed outcome exposes its exception.")] - public void AFailedOutcomeExposesItsException() { - // Setup - DomainError error = new(ErrorCode.Unspecified, "boom"); - - // Exercise - Outcome outcome = Outcome.Failure(error); - - // Verify - Check.That(outcome.Error).IsSameReferenceAs(error); - } - - [Fact(DisplayName = "A failed outcome cannot be created from a null exception.")] - public void AFailedOutcomeCannotBeCreatedFromANullException() { - // Exercise & verify - Check.ThatCode(() => Outcome.Failure(null!)) - .ThrowsAny(); - } - - [Fact(DisplayName = "Accessing the value of a failed outcome throws the associated exception.")] - public void AccessingTheValueOfAFailedOutcomeThrowsTheAssociatedException() { - // Setup - DomainError error = new(ErrorCode.Unspecified, "boom"); - Outcome outcome = Outcome.Failure(error); - - // Exercise & verify - Check.ThatCode(() => _ = outcome.GetResultOrThrow()) - .Throws() - .WithMessage("boom"); - } - - [Fact(DisplayName = "Escalating a failed outcome throws the associated exception.")] - public void EscalatingAFailedOutcomeThrowsTheAssociatedException() { - // Setup - DomainError error = new(ErrorCode.Unspecified, "boom"); - Outcome outcome = Outcome.Failure(error); - - // Exercise & verify - Check.ThatCode(() => outcome.GetResultOrThrow()) - .Throws() - .WithMessage("boom"); - } - - [Fact(DisplayName = "A failed outcome preserves the original exception instance.")] - public void FailedOutcomePreservesTheOriginalExceptionInstance() { - // Setup - DomainError error = new(ErrorCode.Unspecified, "boom"); - Outcome outcome = Outcome.Failure(error); - - // Exercise & verify - DomainException thrownException = Check.ThatCode(() => outcome.GetResultOrThrow()).Throws().Value; - Check.That(thrownException.Error).IsSameReferenceAs(error); - } - -} \ No newline at end of file diff --git a/DiagnosableExceptions.Usage/Model/Temperature.cs b/DiagnosableExceptions.Usage/Model/Temperature.cs index ad6dcf1b..4e61a039 100644 --- a/DiagnosableExceptions.Usage/Model/Temperature.cs +++ b/DiagnosableExceptions.Usage/Model/Temperature.cs @@ -44,7 +44,7 @@ public static Temperature FromKelvin(decimal kelvin) { /// /// Temperature in kelvin. /// - /// A TryOutcome<Temperature> that is successful when + /// A Outcome<Temperature> that is successful when /// is not below absolute zero; otherwise a failure containing the corresponding /// . /// @@ -59,7 +59,7 @@ public static Outcome TryFromKelvin(decimal kelvin) { /// /// Temperature in degrees Celsius. /// A new representing the specified Celsius value. - /// + /// /// Thrown when the converted kelvin value is lower than absolute zero. /// public static Temperature FromCelsius(decimal celsius) { @@ -71,7 +71,7 @@ public static Temperature FromCelsius(decimal celsius) { /// /// Temperature in degrees Celsius. /// - /// A TryOutcome<Temperature> that is successful when the Celsius value + /// A Outcome<Temperature> that is successful when the Celsius value /// is not below absolute zero; otherwise a failure containing the corresponding /// . /// diff --git a/DiagnosableExceptions/DiagnosableException.cs b/DiagnosableExceptions/DiagnosableException.cs index 48a81506..441157e7 100644 --- a/DiagnosableExceptions/DiagnosableException.cs +++ b/DiagnosableExceptions/DiagnosableException.cs @@ -6,37 +6,6 @@ /// public abstract class DiagnosableException : Exception { - #region Statics members declarations - - private static IReadOnlyList CreateInnerExceptionList(Exception? innerException) { - if (innerException is null) { return CreateInnerExceptionList(); } - - return Array.AsReadOnly([innerException]); - } - - private static IReadOnlyList CreateInnerExceptionList() { - return Array.AsReadOnly(Array.Empty()); - } - - private static IReadOnlyList CreateInnerExceptionList(IEnumerable? innerExceptions) { - if (innerExceptions is null) { return CreateInnerExceptionList(); } - - Exception[] array = innerExceptions as Exception[] ?? innerExceptions.ToArray(); - - return Array.AsReadOnly(array); - } - - private static ErrorContext BuildContext(Action? configureContext) { - if (configureContext is null) { return ErrorContext.Empty; } - - ErrorContextBuilder builder = new(); - configureContext(builder); - - return builder.Build(); - } - - #endregion - #region Constructors declarations /// @@ -54,11 +23,17 @@ protected DiagnosableException(Error error) #endregion /// - /// Gets or sets the instance associated with this exception. + /// Gets the instance associated with this exception. /// /// /// The instance that provides detailed information about the exception. /// - public Error Error { get; set; } + /// + /// The full diagnostic payload — error code, messages, context and inner errors — is carried by this + /// . Inner errors are surfaced through + /// and are intentionally not mirrored onto ; traverse + /// exception.Error.InnerErrors to walk the diagnostic chain. + /// + public Error Error { get; } } \ No newline at end of file diff --git a/DiagnosableExceptions/DocumentedByAttribute.cs b/DiagnosableExceptions/DocumentedByAttribute.cs index fd19b5a3..054ab013 100644 --- a/DiagnosableExceptions/DocumentedByAttribute.cs +++ b/DiagnosableExceptions/DocumentedByAttribute.cs @@ -1,8 +1,9 @@ namespace DiagnosableExceptions; /// -/// Specifies the method that documents the exception produced by the annotated exception factory method. +/// Specifies the method that documents the error produced by the annotated error factory method. /// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public sealed class DocumentedByAttribute : Attribute { #region Constructors & Destructor diff --git a/DiagnosableExceptions/Error.cs b/DiagnosableExceptions/Error.cs index 7e613dc9..ee2ef966 100644 --- a/DiagnosableExceptions/Error.cs +++ b/DiagnosableExceptions/Error.cs @@ -69,7 +69,7 @@ private static IReadOnlyList CreateSafeInnerErrors(Error? innerError) { /// optional short message, and an optional configuration for the error context. /// /// - /// The unique code representing the error. This value cannot be null or whitespace. + /// The identifying the error. If null, is used. /// /// /// A detailed description of the error. This value cannot be null or whitespace. @@ -101,7 +101,7 @@ protected Error(ErrorCode code, /// optional short message, and an optional configuration for the error context. /// /// - /// The unique code representing the error. This value cannot be null or whitespace. + /// The identifying the error. If null, is used. /// /// /// A detailed description of the error. This value cannot be null or whitespace. @@ -110,8 +110,7 @@ protected Error(ErrorCode code, /// An optional short description of the error. This value can be null. /// /// - /// An inner instances that provide additional context for the error. - /// If null, an empty collection is used. + /// The inner that provides additional context for the error. /// /// /// An optional action to configure the using an . @@ -217,7 +216,9 @@ protected Error(ErrorCode code, /// /// /// This property is intended to provide a simplified error message that can be displayed - /// in user interfaces where a full error message might be too verbose. + /// in user interfaces where a full error message might be too verbose. Unlike + /// , the value is stored verbatim: it is neither trimmed nor substituted + /// when null or whitespace. /// public string? ShortMessage { get; } /// diff --git a/DiagnosableExceptions/ErrorCode.cs b/DiagnosableExceptions/ErrorCode.cs index fdc1a394..b1493d72 100644 --- a/DiagnosableExceptions/ErrorCode.cs +++ b/DiagnosableExceptions/ErrorCode.cs @@ -36,11 +36,11 @@ public static ErrorCode Create(string code) { } /// - /// Resets the internal state of registered instances. + /// Resets the internal state of registered values. /// /// - /// This method is intended for use in testing scenarios only. It clears all registered keys, - /// allowing a clean slate for subsequent tests that rely on registration. + /// This method is intended for use in testing scenarios only. It clears all registered error codes, + /// allowing a clean slate for subsequent tests that rely on registration. /// internal static void ResetForTests() { lock (Lock) { @@ -79,7 +79,10 @@ internal static void ResetForTests() { /// /// The instance to convert. /// The string representation of the specified . + /// Thrown when is null. public static implicit operator string(ErrorCode errorCode) { + if (errorCode is null) { throw new ArgumentNullException(nameof(errorCode)); } + return errorCode._code; } diff --git a/DiagnosableExceptions/ErrorContext.cs b/DiagnosableExceptions/ErrorContext.cs index b0d3b062..91a94c10 100644 --- a/DiagnosableExceptions/ErrorContext.cs +++ b/DiagnosableExceptions/ErrorContext.cs @@ -1,4 +1,10 @@ -namespace DiagnosableExceptions; +#region Usings declarations + +using System.Collections.ObjectModel; + +#endregion + +namespace DiagnosableExceptions; /// /// Represents a context that provides additional information about an error. @@ -21,7 +27,8 @@ public sealed class ErrorContext { #region Fields - private readonly Dictionary _values; + private readonly Dictionary _values; + private readonly ReadOnlyDictionary _readOnlyValues; #endregion @@ -30,7 +37,8 @@ public sealed class ErrorContext { internal ErrorContext(Dictionary values) { if (values is null) { throw new ArgumentNullException(nameof(values)); } - _values = new Dictionary(values); + _values = new Dictionary(values); + _readOnlyValues = new ReadOnlyDictionary(_values); } #endregion @@ -46,7 +54,7 @@ internal ErrorContext(Dictionary values) { /// The property allows access to the supplementary information stored in the error context. This /// information can be used for diagnostics, logging, or troubleshooting purposes. /// - public IReadOnlyDictionary Values => _values; + public IReadOnlyDictionary Values => _readOnlyValues; /// /// Gets a value indicating whether this context contains no entries. @@ -71,7 +79,9 @@ internal ErrorContext(Dictionary values) { /// /// This method is useful for safely retrieving strongly-typed values from the without the /// need for explicit casting. If the key does not exist or the value is not of the expected type, the method returns - /// false, and the parameter is set to its default value. + /// false, and the parameter is set to its default value. Note that an entry whose + /// stored value is null is also reported as false (indistinguishable from an absent key), even though it + /// still appears in and counts toward . /// public bool TryGet(ErrorContextKey key, out T? value) { if (_values.TryGetValue(key, out object? raw) && raw is T typed) { diff --git a/DiagnosableExceptions/ErrorContextKey.cs b/DiagnosableExceptions/ErrorContextKey.cs index f245f21a..9e1ae5f2 100644 --- a/DiagnosableExceptions/ErrorContextKey.cs +++ b/DiagnosableExceptions/ErrorContextKey.cs @@ -191,7 +191,6 @@ private protected ErrorContextKey(string name, string? description, Type valueTy public Type ValueType { get; } /// - /// > public bool Equals(ErrorContextKey? other) { if (other is null) { return false; } if (ReferenceEquals(this, other)) { return true; } @@ -200,19 +199,16 @@ public bool Equals(ErrorContextKey? other) { } /// - /// > public override bool Equals(object? obj) { return obj is ErrorContextKey other && Equals(other); } /// - /// > public override int GetHashCode() { return StringComparer.Ordinal.GetHashCode(Name); } /// - /// > public override string ToString() { return Name; } diff --git a/DiagnosableExceptions/ErrorDocumentation.cs b/DiagnosableExceptions/ErrorDocumentation.cs index b85166e3..2ec95307 100644 --- a/DiagnosableExceptions/ErrorDocumentation.cs +++ b/DiagnosableExceptions/ErrorDocumentation.cs @@ -23,7 +23,7 @@ public sealed class ErrorDocumentation { /// This code serves as a key reference for production support teams, enabling them to quickly /// identify and categorize the error type for efficient troubleshooting and resolution. /// - public string? Code { get; set; } + public string? Code { get; internal set; } /// /// Gets the title of the error documentation. @@ -33,7 +33,7 @@ public sealed class ErrorDocumentation { /// It is intended to be a human-readable identifier for the error, aiding in quick recognition /// and understanding of the issue. /// - public string? Title { get; set; } + public string? Title { get; internal set; } /// /// Gets a detailed explanation of the error. @@ -43,7 +43,7 @@ public sealed class ErrorDocumentation { /// offering additional context and insights to help understand the nature /// and circumstances of the issue. /// - public string? Explanation { get; set; } + public string? Explanation { get; internal set; } /// /// Gets the business rule associated with the error. @@ -53,7 +53,7 @@ public sealed class ErrorDocumentation { /// to the error. It helps in understanding the context of the error in relation to the /// application's business logic. /// - public string? BusinessRule { get; set; } + public string? BusinessRule { get; internal set; } /// /// Gets the collection of diagnostics associated with the error. @@ -66,7 +66,7 @@ public sealed class ErrorDocumentation { /// This property is used to document the potential causes of an error and the recommended solutions, aiding in the /// diagnosis and resolution of the issue. /// - public IReadOnlyList Diagnostics { get; set; } = []; + public IReadOnlyList Diagnostics { get; internal set; } = []; /// /// Gets a collection of examples that illustrate specific instances of the error. @@ -75,24 +75,24 @@ public sealed class ErrorDocumentation { /// Each example provides a detailed and a short description of an error scenario, helping to clarify the nature of the /// error and its potential occurrences. /// - public IReadOnlyList Examples { get; set; } = []; + public IReadOnlyList Examples { get; internal set; } = []; /// - /// Gets or sets the collection of context entries that provide additional details about the error. + /// Gets the collection of context entries that provide additional details about the error. /// /// /// This collection is used to enhance the understanding and diagnostics of errors by providing structured metadata /// about the error context. /// - public IReadOnlyCollection Context { get; set; } = []; + public IReadOnlyCollection Context { get; internal set; } = []; /// - /// Gets or sets the source associated with the error documentation. + /// Gets the source associated with the error documentation. /// /// /// This property typically represents the origin or context of the error (e.g. value object, ...). /// - public string? Source { get; set; } + public string? Source { get; internal set; } /// public override string ToString() { diff --git a/DiagnosableExceptions/ErrorDocumentationException.cs b/DiagnosableExceptions/ErrorDocumentationException.cs index 7873f670..bfa9de82 100644 --- a/DiagnosableExceptions/ErrorDocumentationException.cs +++ b/DiagnosableExceptions/ErrorDocumentationException.cs @@ -14,7 +14,7 @@ public sealed class ErrorDocumentationException : InvalidOperationException { #region Static members internal static ErrorDocumentationException InconsistentErrorCode(int exampleIndex, string expectedErrorCode, ErrorCode receivedErrorCode) { - return new ErrorDocumentationException($"All example factories must produce exceptions with the same ErrorCode. Example at index {exampleIndex} produced a different ErrorCode. Expected '{expectedErrorCode}', but received '{receivedErrorCode}'."); + return new ErrorDocumentationException($"All example factories must produce errors with the same ErrorCode. Example at index {exampleIndex} produced a different ErrorCode. Expected '{expectedErrorCode}', but received '{receivedErrorCode}'."); } internal static ErrorDocumentationException AtLeastOneExampleMustBeProvided() { @@ -30,7 +30,7 @@ internal static ErrorDocumentationException ExampleFactoryThrewAnException(int f } internal static ErrorDocumentationException NullExample(int factoryIndex) { - return new ErrorDocumentationException($"Example factory at index {factoryIndex} returned null. Factories must return a valid exception instance."); + return new ErrorDocumentationException($"Example factory at index {factoryIndex} returned null. Factories must return a valid error instance."); } #endregion diff --git a/DiagnosableExceptions/GenDoc/AssemblyErrorDocumentationReader.cs b/DiagnosableExceptions/GenDoc/AssemblyErrorDocumentationReader.cs index 71d670d4..291bcf44 100644 --- a/DiagnosableExceptions/GenDoc/AssemblyErrorDocumentationReader.cs +++ b/DiagnosableExceptions/GenDoc/AssemblyErrorDocumentationReader.cs @@ -22,12 +22,27 @@ public static class AssemblyErrorDocumentationReader { public static IEnumerable GetErrorDocumentationFrom(Assembly assembly) { if (assembly is null) { throw new ArgumentNullException(nameof(assembly)); } - return assembly.GetTypes() - .Where(type => type is { IsClass: true }) - .SelectMany(BuildFromExceptionType) - .GroupBy(x => x.Code, StringComparer.OrdinalIgnoreCase) - .Select(g => g.First()) - .OrderBy(x => x.Code, StringComparer.OrdinalIgnoreCase); + return GetLoadableTypes(assembly) + .Where(type => type is { IsClass: true }) + .SelectMany(BuildFromExceptionType) + // Order before grouping so that, when several factories share the same Code, the surviving + // documentation is chosen deterministically (reflection ordering is not guaranteed). + .OrderBy(x => x.Code, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Source, StringComparer.Ordinal) + .GroupBy(x => x.Code, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .OrderBy(x => x.Code, StringComparer.OrdinalIgnoreCase); + } + + private static IEnumerable GetLoadableTypes(Assembly assembly) { + // A documented assembly may reference types that cannot be loaded (e.g. a missing or version-mismatched + // dependency). GetTypes() then throws ReflectionTypeLoadException; we still want the loadable, attributed + // types rather than aborting extraction for the whole assembly. + try { + return assembly.GetTypes(); + } catch (ReflectionTypeLoadException ex) { + return ex.Types.Where(type => type is not null).Select(type => type!); + } } private static IEnumerable BuildFromExceptionType(Type exceptionType) { diff --git a/DiagnosableExceptions/IErrorExamplesStage.cs b/DiagnosableExceptions/IErrorExamplesStage.cs index 25645616..80735a2a 100644 --- a/DiagnosableExceptions/IErrorExamplesStage.cs +++ b/DiagnosableExceptions/IErrorExamplesStage.cs @@ -6,25 +6,25 @@ public interface IErrorExamplesStage { /// - /// Adds one or more example exception instances illustrating how this error may appear at runtime. + /// Adds one or more example error instances illustrating how this error may appear at runtime. /// /// - /// The type of produced by the example factories. + /// The type of produced by the example factories. /// /// - /// Factories that create representative exception instances using realistic example values. + /// Factories that create representative error instances using realistic example values. /// /// /// - /// Examples are used to expose realistic error messages generated by the exception, helping readers + /// Examples are used to expose realistic error messages generated by the error, helping readers /// recognize the error in logs, monitoring tools, or user interfaces. /// /// /// Instead of hard-coded message strings, factories are used so that the examples always reflect the actual - /// message format produced by the exception implementation. This prevents message drift and duplication. + /// message format produced by the error implementation. This prevents message drift and duplication. /// /// - /// Each factory should construct an exception using plausible domain values (e.g., dates, identifiers, amounts, + /// Each factory should construct an error using plausible domain values (e.g., dates, identifiers, amounts, /// units) that illustrate a typical failure case. These examples are descriptive, not executable scenarios or /// tests. /// diff --git a/DiagnosableExceptions/InfrastructureError.cs b/DiagnosableExceptions/InfrastructureError.cs index 9391d21e..4c126b24 100644 --- a/DiagnosableExceptions/InfrastructureError.cs +++ b/DiagnosableExceptions/InfrastructureError.cs @@ -56,7 +56,9 @@ public InfrastructureError(ErrorCode code, string detailedMessage, InteractionDi #endregion /// - /// Gets a value indicating whether the error is transient and can potentially be retried. + /// Gets the transience classification of the error: , + /// , or + /// when it cannot be determined. /// /// /// A transient error is typically a temporary issue, such as a network glitch or a service unavailability, diff --git a/DiagnosableExceptions/Outcome.cs b/DiagnosableExceptions/Outcome.cs index eab0a302..9ab186c2 100644 --- a/DiagnosableExceptions/Outcome.cs +++ b/DiagnosableExceptions/Outcome.cs @@ -656,19 +656,21 @@ public Outcome Recover(Func> fallback) { /// Attempts to recover from a failure by providing a guaranteed fallback value. /// /// - /// A function that receives the current and returns a value of type - /// . This function is guaranteed to produce a value — it cannot itself fail. + /// A function that receives the current and returns a non-null value of type + /// used to recover from the failure. /// /// /// The current unchanged if the operation was successful; otherwise, a successful /// containing the value returned by . /// /// - /// Thrown if is null. + /// Thrown if is null, or if returns null + /// (the returned value flows through , which rejects null). /// /// - /// Unlike , this overload always produces a success. - /// Use it when a default or cached value can always be substituted for the failed result. + /// Unlike , this overload produces a success whenever the + /// fallback returns a non-null value. Use it when a default or cached value can always be substituted for the + /// failed result. /// public Outcome Recover(Func fallback) { if (fallback is null) { throw new ArgumentNullException(nameof(fallback)); } @@ -705,7 +707,7 @@ public Task> Recover(Func>> /// /// /// An asynchronous function that receives the current and returns a - /// containing a guaranteed fallback value. + /// containing a non-null fallback value. /// /// /// A token to observe for cancellation requests. @@ -716,7 +718,8 @@ public Task> Recover(Func>> /// containing the fallback value. /// /// - /// Thrown if is null. + /// Thrown if is null, or if the awaited result + /// is null (it flows through , which rejects null). /// public async Task> Recover(Func> fallback, CancellationToken cancellationToken = default) { diff --git a/DiagnosableExceptions/OutcomeTaskExtensions.cs b/DiagnosableExceptions/OutcomeTaskExtensions.cs index 2a43fe66..620213ae 100644 --- a/DiagnosableExceptions/OutcomeTaskExtensions.cs +++ b/DiagnosableExceptions/OutcomeTaskExtensions.cs @@ -109,7 +109,7 @@ public static async Task> Then(this Task /// returned by the function. /// /// - /// Thrown if or is null. + /// Thrown if is null. /// public static async Task Then(this Task task, Func> next, diff --git a/DiagnosableExceptions/PrimaryPortInnerErrors.cs b/DiagnosableExceptions/PrimaryPortInnerErrors.cs index e4c28e9b..de0e4b16 100644 --- a/DiagnosableExceptions/PrimaryPortInnerErrors.cs +++ b/DiagnosableExceptions/PrimaryPortInnerErrors.cs @@ -1,4 +1,10 @@ -namespace DiagnosableExceptions; +#region Usings declarations + +using System.Diagnostics; + +#endregion + +namespace DiagnosableExceptions; /// /// Represents a collection of inner errors specific to primary port operations. @@ -7,6 +13,7 @@ /// This class provides methods to add domain-specific and primary port-specific errors to the collection. /// It ensures that the added errors are not null and supports method chaining for convenience. /// +[DebuggerDisplay("{ToString()}")] public sealed class PrimaryPortInnerErrors { #region Fields declarations @@ -102,4 +109,9 @@ internal IReadOnlyList ToList() { return _errors; } + /// + public override string ToString() { + return _errors.Count.ToString(); + } + } \ No newline at end of file diff --git a/DiagnosableExceptions/ProvidesErrorsForAttribute.cs b/DiagnosableExceptions/ProvidesErrorsForAttribute.cs index c0fe3dea..785394bd 100644 --- a/DiagnosableExceptions/ProvidesErrorsForAttribute.cs +++ b/DiagnosableExceptions/ProvidesErrorsForAttribute.cs @@ -19,11 +19,11 @@ public sealed class ProvidesErrorsForAttribute : Attribute { /// The name of the source (e.g., a domain model or component) for which the attributed class provides error /// definitions. /// - /// - /// Thrown when the is null. + /// + /// Thrown when is null, empty, or consists only of white-space characters. /// public ProvidesErrorsForAttribute(string source) { - if (source is null) { throw new ArgumentNullException(nameof(source)); } + if (string.IsNullOrWhiteSpace(source)) { throw new ArgumentException("Value cannot be null or whitespace.", nameof(source)); } Source = source; } diff --git a/DiagnosableExceptions/README.nuget.md b/DiagnosableExceptions/README.nuget.md index 6ceb8424..deec5ab2 100644 --- a/DiagnosableExceptions/README.nuget.md +++ b/DiagnosableExceptions/README.nuget.md @@ -27,7 +27,7 @@ Errors become **documented knowledge about your system**, not just runtime failu () => BelowAbsoluteZero(-1, TemperatureUnit.Kelvin), () => BelowAbsoluteZero(-280, TemperatureUnit.Celsius)); -This produces **structured documentation tied directly to the exception definition**. +This produces **structured documentation tied directly to the error definition**. ## When to use DiagnosableExceptions diff --git a/DiagnosableExceptions/SecondaryPortInnerErrors.cs b/DiagnosableExceptions/SecondaryPortInnerErrors.cs index 3f48c78f..6c50dd3b 100644 --- a/DiagnosableExceptions/SecondaryPortInnerErrors.cs +++ b/DiagnosableExceptions/SecondaryPortInnerErrors.cs @@ -76,7 +76,7 @@ public SecondaryPortInnerErrors Add(SecondaryPortError error) { /// This method provides access to the internal collection of errors in a read-only format, /// ensuring that the original collection remains unmodifiable. /// - public IReadOnlyList ToList() { + internal IReadOnlyList ToList() { return _errors; } diff --git a/README.md b/README.md index 0c0c044e..94bef12e 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ This enables: The library supports both: * **throwing errors** (traditional exception flow) -* **transporting errors without throwing** via `TryOutcome` +* **transporting errors without throwing** via `Outcome` and `Outcome` This allows you to use exceptions as: @@ -131,14 +131,14 @@ depending on the context (domain logic, validation, pipelines, etc.). From the `DiagnosableExceptions.Usage` project: ```csharp -[ProvidesErrorsFor(typeof(Temperature))] -public sealed class InvalidTemperatureException : DomainException { +[ProvidesErrorsFor(nameof(Temperature))] +public static class InvalidTemperatureError { [DocumentedBy(nameof(BelowAbsoluteZeroDocumentation))] - internal static InvalidTemperatureException BelowAbsoluteZero(decimal invalidValue, TemperatureUnit invalidValueUnit) { - return new InvalidTemperatureException( - "TEMPERATURE_BELOW_ABSOLUTE_ZERO", - $"Failed to instantiate temperature: the value {invalidValue}{invalidValueUnit} is below absolute zero.", + internal static DomainError BelowAbsoluteZero(decimal invalidValue, TemperatureUnit invalidValueUnit) { + return new DomainError( + Code.TemperatureBelowAbsoluteZero, + $"Failed to instantiate temperature: the value {invalidValue} {invalidValueUnit} is below absolute zero.", "Temperature is below absolute zero."); } @@ -151,10 +151,20 @@ public sealed class InvalidTemperatureException : DomainException { () => BelowAbsoluteZero(-1, TemperatureUnit.Kelvin), () => BelowAbsoluteZero(-280, TemperatureUnit.Celsius)); } + + private static class Code { + public static readonly ErrorCode TemperatureBelowAbsoluteZero = ErrorCode.Create("TEMPERATURE_BELOW_ABSOLUTE_ZERO"); + } } ``` -Here, the exception, its meaning, its rule, its diagnostics, and example messages are all defined together — in code. +The factory returns a structured `Error`. When you need to throw it, you turn it into an exception with `.ToException()`: + +```csharp +throw InvalidTemperatureError.BelowAbsoluteZero(-1, TemperatureUnit.Kelvin).ToException(); +``` + +Here, the error, its meaning, its rule, its diagnostics, and example messages are all defined together — in code. ## 🎯 Who is this for? diff --git a/doc/ArchitectureOfTheDocumentationPipeline.en.md b/doc/ArchitectureOfTheDocumentationPipeline.en.md index c3b9ee69..b2860077 100644 --- a/doc/ArchitectureOfTheDocumentationPipeline.en.md +++ b/doc/ArchitectureOfTheDocumentationPipeline.en.md @@ -9,18 +9,28 @@ The pipeline separates **knowledge definition**, **extraction**, and **rendering Error knowledge is written where errors are defined: -* Exception types represent categories of errors +* A static class annotated with `[ProvidesErrorsFor(...)]` groups the errors that belong to a given model +* `Error` subtypes (`DomainError`, `PrimaryPortError`, `SecondaryPortError`, ...) represent categories of errors * Factory methods represent specific error situations * The `DescribeError` DSL describes meaning, rules, diagnostics, and examples At this stage, documentation is **structured data**, not text files. -## 🔗 2️. Factories are linked to documentation +## 🔗 2️. Errors are anchored and linked to documentation -Each factory method is linked to its documentation using: +A static class declares that it owns the errors of a given model: ```csharp -[DocumentedBy(nameof(CurrencyMismatchDocumentation))] +[ProvidesErrorsFor(nameof(Temperature))] +public static class InvalidTemperatureError { ... } +``` + +This attribute is the primary anchor of the documentation model: it marks the class as a source of errors and supplies `ErrorDocumentation.Source` (the model name passed via `nameof(...)`). + +Inside that class, each factory method is linked to its documentation method using: + +```csharp +[DocumentedBy(nameof(BelowAbsoluteZeroDocumentation))] ``` This creates an explicit connection between: @@ -28,49 +38,47 @@ This creates an explicit connection between: * how an error is created * how it is described -Factories become the anchor points of the documentation model. - ## 🔎 3. Assembly scanning -The `AssemblyErrorDocumentationReader` scans assemblies and: +`AssemblyErrorDocumentationReader.GetErrorDocumentationFrom(assembly)` scans an assembly and: -* finds exception types deriving from `DiagnosableException` +* finds any class annotated with `[ProvidesErrorsFor(...)]` (these are plain static classes, not exception types) * finds factory methods marked with `[DocumentedBy]` -* invokes documentation methods -* builds a collection of `ErrorDocumentation` objects +* invokes the linked documentation methods +* builds a collection of `ErrorDocumentation` objects (deduped by `Code`, ordered by `Code`) At this stage, documentation becomes a structured in-memory model. ## 🧩 4️. Aggregation at solution level -A higher-level tool can: +`SolutionErrorDocumentationGenerator.GetErrorDocumentationFrom(solutionPath[, options])` works at a higher level and: -* build a solution -* load all assemblies -* aggregate all extracted `ErrorDocumentation` +* builds a solution +* loads all assemblies +* aggregates all extracted `ErrorDocumentation` (deduped by `Code`, ordered by `Code`) This produces a **global error catalog** for the application or system. -## 🖨️ 5️. Transformation to output formats +## 🖨️ 5️. Transformation to output formats (planned) -The structured model can be transformed into: +The library ships the structured in-memory model only; **no exporter is shipped today**. Because the model is plain data, an exporter can be built on top of this library to transform it into: * Markdown * HTML * JSON * or any other format -The transformation layer is independent of the core model. +Such a transformation layer would be independent of the core model. -## 🧰 6️. CLI orchestration +## 🧰 6️. CLI orchestration (planned) -A CLI tool can orchestrate the full process: +There is **no shipped CLI today** (the CLI project is currently a Hello-World stub). A CLI could be built on top of this library to orchestrate the full process, for example: ``` errdocgen --solution ./MyApp.sln --export html ``` -It handles: +Such a CLI would handle: * solution build * assembly loading @@ -82,13 +90,13 @@ It handles: This separation ensures: -| Layer | Responsibility | -| -------- | -------------------------------- | -| Code | Define error knowledge | -| Reader | Extract structured documentation | -| Builder | Aggregate across assemblies | -| Exporter | Render documentation | -| CLI | Orchestrate the process | +| Layer | Responsibility | +| ------------------ | -------------------------------- | +| Code | Define error knowledge | +| Reader | Extract structured documentation | +| Builder | Aggregate across assemblies | +| Exporter (planned) | Render documentation | +| CLI (planned) | Orchestrate the process | Documentation remains: diff --git a/doc/ArchitectureOfTheDocumentationPipeline.fr.md b/doc/ArchitectureOfTheDocumentationPipeline.fr.md index b88de819..30d18a69 100644 --- a/doc/ArchitectureOfTheDocumentationPipeline.fr.md +++ b/doc/ArchitectureOfTheDocumentationPipeline.fr.md @@ -9,18 +9,28 @@ Le pipeline sépare la **définition de la connaissance**, **l’extraction** et La connaissance liée aux erreurs est écrite à l’endroit où les erreurs sont définies : -* Les types d’exception représentent des catégories d’erreurs +* Une classe statique annotée avec `[ProvidesErrorsFor(...)]` regroupe les erreurs liées à un modèle donné +* Les sous-types d’`Error` (`DomainError`, `PrimaryPortError`, `SecondaryPortError`, ...) représentent des catégories d’erreurs * Les méthodes factory représentent des situations d’erreur spécifiques * Le DSL `DescribeError` décrit le sens, les règles, les diagnostics et les exemples À ce stade, la documentation est une **donnée structurée**, pas des fichiers texte. -## 🔗 2. Les factories sont liées à la documentation +## 🔗 2. Les erreurs sont ancrées et liées à la documentation -Chaque méthode factory est liée à sa documentation via : +Une classe statique déclare qu’elle possède les erreurs d’un modèle donné : ```csharp -[DocumentedBy(nameof(CurrencyMismatchDocumentation))] +[ProvidesErrorsFor(nameof(Temperature))] +public static class InvalidTemperatureError { ... } +``` + +Cet attribut est le point d’ancrage principal du modèle de documentation : il marque la classe comme source d’erreurs et fournit `ErrorDocumentation.Source` (le nom du modèle passé via `nameof(...)`). + +À l’intérieur de cette classe, chaque méthode factory est liée à sa méthode de documentation via : + +```csharp +[DocumentedBy(nameof(BelowAbsoluteZeroDocumentation))] ``` Cela crée une connexion explicite entre : @@ -28,49 +38,47 @@ Cela crée une connexion explicite entre : * la manière dont une erreur est créée * la manière dont elle est décrite -Les factories deviennent les points d’ancrage du modèle de documentation. - ## 🔎 3. Analyse des assemblies -`AssemblyErrorDocumentationReader` analyse les assemblies et : +`AssemblyErrorDocumentationReader.GetErrorDocumentationFrom(assembly)` analyse un assembly et : -* trouve les types d’exception dérivant de `DiagnosableException` +* trouve toute classe annotée avec `[ProvidesErrorsFor(...)]` (ce sont de simples classes statiques, pas des types d’exception) * trouve les méthodes factory marquées avec `[DocumentedBy]` -* invoque les méthodes de documentation -* construit une collection d’objets `ErrorDocumentation` +* invoque les méthodes de documentation liées +* construit une collection d’objets `ErrorDocumentation` (dédupliquée par `Code`, ordonnée par `Code`) À ce stade, la documentation devient un modèle structuré en mémoire. ## 🧩 4. Agrégation au niveau de la solution -Un outil de plus haut niveau peut : +`SolutionErrorDocumentationGenerator.GetErrorDocumentationFrom(solutionPath[, options])` travaille à un niveau plus élevé et : -* compiler une solution -* charger tous les assemblies -* agréger tous les `ErrorDocumentation` extraits +* compile une solution +* charge tous les assemblies +* agrège tous les `ErrorDocumentation` extraits (dédupliqués par `Code`, ordonnés par `Code`) Cela produit un **catalogue global des erreurs** pour l’application ou le système. -## 🖨️ 5. Transformation vers des formats de sortie +## 🖨️ 5. Transformation vers des formats de sortie (prévu) -Le modèle structuré peut être transformé en : +La bibliothèque ne fournit que le modèle structuré en mémoire ; **aucun exporteur n’est livré aujourd’hui**. Le modèle étant une simple donnée, un exporteur peut être construit par-dessus cette bibliothèque pour le transformer en : * Markdown * HTML * JSON * ou tout autre format -La couche de transformation est indépendante du modèle central. +Une telle couche de transformation serait indépendante du modèle central. -## 🧰 6. Orchestration via CLI +## 🧰 6. Orchestration via CLI (prévu) -Un outil en ligne de commande peut orchestrer l’ensemble du processus : +Il n’existe **aucune CLI livrée aujourd’hui** (le projet CLI est actuellement un stub Hello-World). Une CLI pourrait être construite par-dessus cette bibliothèque pour orchestrer l’ensemble du processus, par exemple : ```bash errdocgen --solution ./MyApp.sln --export html ``` -Il gère : +Une telle CLI gérerait : * la compilation de la solution * le chargement des assemblies @@ -82,13 +90,13 @@ Il gère : Cette séparation garantit : -| Couche | Responsabilité | -| -------- | ------------------------------------ | -| Code | Définir la connaissance des erreurs | -| Reader | Extraire la documentation structurée | -| Builder | Agréger à travers les assemblies | -| Exporter | Générer la documentation | -| CLI | Orchestrer le processus | +| Couche | Responsabilité | +| ------------------ | ------------------------------------ | +| Code | Définir la connaissance des erreurs | +| Reader | Extraire la documentation structurée | +| Builder | Agréger à travers les assemblies | +| Exporter (prévu) | Générer la documentation | +| CLI (prévu) | Orchestrer le processus | La documentation reste : diff --git a/doc/BestPractices.en.md b/doc/BestPractices.en.md index 2e7c0e74..6934a860 100644 --- a/doc/BestPractices.en.md +++ b/doc/BestPractices.en.md @@ -32,18 +32,18 @@ Error codes are used in logs, documentation, and support workflows. Stability pr ## ✂️ 3. Keep the happy path clean -Exception factories should keep error construction out of domain logic. +Error factories should keep error construction out of domain logic. Prefer: ```csharp -throw InvalidAmountOperationException.CurrencyMismatch(a1, a2); +throw InvalidAmountOperationError.CurrencyMismatch(a1, a2).ToException(); ``` Over: ```csharp -throw new InvalidAmountOperationException(...); +throw new DomainException(/* manually assembled Error */); ``` **Why:** @@ -92,14 +92,14 @@ Focus on investigation direction, not workflow. **Why:** Operational processes depend on organizational context, not on the application itself. Encoding them in error documentation couples your code to external procedures and makes documentation brittle when processes change. -## 🔁 7. Use TryOutcome where failure is expected +## 🔁 7. Use Outcome where failure is expected Use exceptions for: * invariant violations * unexpected states -Use `TryOutcome` when: +Use `Outcome` when: * validating input * processing batches @@ -135,7 +135,7 @@ Avoid edge cases or pathological data. ## 🧱 10. Keep documentation close to the factory -Documentation methods should live in the same exception class as the factory. +Documentation methods should live in the same error factory class as the factory. This keeps: @@ -148,34 +148,46 @@ in the same conceptual place. **Why:** Keeping documentation next to the factory ensures it evolves with the code. This prevents drift and preserves the core idea of living documentation: knowledge stays where the behavior is defined. -## 🧩 11. Seal application exception types +## 🧩 11. Group errors in a dedicated factory class -Application-specific exceptions should be declared as `sealed`. +Application-specific errors should be grouped in a `static` factory class annotated with `[ProvidesErrorsFor(...)]`, with one `internal static` factory method per error situation. ```csharp -public sealed class InvalidAmountOperationException : DomainException +[ProvidesErrorsFor(nameof(Amount))] +public static class InvalidAmountOperationError { + + [DocumentedBy(nameof(CurrencyMismatchDocumentation))] + internal static DomainError CurrencyMismatch(Amount left, Amount right) { + return new DomainError( + Code.CurrencyMismatch, + $"Cannot operate on amounts with different currencies: {left.Currency} and {right.Currency}.", + "Amounts use different currencies."); + } + + // ... documentation method and error codes ... +} ``` **Why:** -Each exception type represents a well-defined error category. Allowing inheritance tends to blur semantics, create unclear hierarchies, and make diagnostics harder to reason about. Sealing the type ensures that the meaning of the exception remains stable and explicit. +Each factory method represents a well-defined error category. Grouping them in a dedicated class keeps related error situations, their codes, and their documentation in one place. Note that the core types (`DomainError`, `DomainException`, …) are **not** sealed — inheritance is intentionally allowed so you can model your own error hierarchies — but in practice you author error situations through these factory classes rather than by subclassing. -## 🏭 12. Use private constructors and factory methods +## 🏭 12. Build errors through factories, throw via `ToException()` -Exception constructors should be `private` and only the required ones should be implemented as necessary. +You never `new` a `DiagnosableException` in user code, and there is no string-pair constructor: an exception's only constructor takes an `Error`. Build the error through a factory method and turn it into an exception with `ToException()`. ```csharp -private InvalidAmountOperationException(string errorCode, string errorMessage) - : base(errorCode, errorMessage) { } +// Build an Error through the factory, then throw it as an exception: +throw InvalidAmountOperationError.CurrencyMismatch(a1, a2).ToException(); ``` -Instances should always be created through factory methods: +When failure is expected rather than exceptional, return the same factory's `Error` inside an `Outcome`: ```csharp -throw InvalidAmountOperationException.CurrencyMismatch(a1, a2); +return Outcome.Failure(InvalidAmountOperationError.NegativeAmount(value)); ``` **Why:** -By restricting constructors, you ensure that all exceptions of this type are created in a controlled, documented, and semantically consistent way. +Routing every error through a factory ensures that all errors of a given category are created in a controlled, documented, and semantically consistent way, whether they are thrown as exceptions or carried as `Outcome` failures. ## 🎯 Final thought diff --git a/doc/BestPractices.fr.md b/doc/BestPractices.fr.md index ff745907..09b26566 100644 --- a/doc/BestPractices.fr.md +++ b/doc/BestPractices.fr.md @@ -32,18 +32,18 @@ Les codes d’erreur sont utilisés dans les logs, la documentation et les proce ## ✂️ 3. Garder le happy path propre -Les factories d’exception doivent éviter d’introduire la construction d’erreur directement dans la logique métier. +Les factories d’erreur doivent éviter d’introduire la construction d’erreur directement dans la logique métier. Préférez : ```csharp -throw InvalidAmountOperationException.CurrencyMismatch(a1, a2); +throw InvalidAmountOperationError.CurrencyMismatch(a1, a2).ToException(); ```` Plutôt que : ```csharp -throw new InvalidAmountOperationException(...); +throw new DomainException(/* Error assemblée manuellement */); ``` **Pourquoi :** @@ -92,14 +92,14 @@ Concentrez-vous sur la direction de l’investigation, pas sur le workflow. **Pourquoi :** Les processus opérationnels dépendent du contexte organisationnel, pas de l’application elle-même. Les encoder dans la documentation des erreurs couple votre code à des procédures externes et rend la documentation fragile lorsque ces processus changent. -## 🔁 7. Utiliser TryOutcome quand l’échec est attendu +## 🔁 7. Utiliser Outcome quand l’échec est attendu Utilisez des exceptions pour : * les violations d’invariants * les états inattendus -Utilisez `TryOutcome` lorsque : +Utilisez `Outcome` lorsque : * vous validez des entrées * vous traitez des lots @@ -135,7 +135,7 @@ Utilisez des valeurs : ## 🧱 10. Garder la documentation proche de la factory -Les méthodes de documentation doivent vivre dans la même classe d’exception que la factory. +Les méthodes de documentation doivent vivre dans la même classe factory d’erreur que la factory. Cela garde : @@ -148,34 +148,46 @@ au même endroit conceptuel. **Pourquoi :** Garder la documentation à côté de la factory garantit qu’elle évolue avec le code. Cela évite les dérives et préserve l’idée centrale de documentation vivante : la connaissance reste là où le comportement est défini. -## 🧩 11. Sceller les exceptions applicatives +## 🧩 11. Regrouper les erreurs dans une classe factory dédiée -Les exceptions spécifiques à l’application devraient être déclarées `sealed`. +Les erreurs spécifiques à l’application devraient être regroupées dans une classe `static` annotée avec `[ProvidesErrorsFor(...)]`, avec une méthode factory `internal static` par situation d’erreur. ```csharp -public sealed class InvalidAmountOperationException : DomainException +[ProvidesErrorsFor(nameof(Amount))] +public static class InvalidAmountOperationError { + + [DocumentedBy(nameof(CurrencyMismatchDocumentation))] + internal static DomainError CurrencyMismatch(Amount left, Amount right) { + return new DomainError( + Code.CurrencyMismatch, + $"Impossible d’opérer sur des montants de devises différentes : {left.Currency} et {right.Currency}.", + "Les montants utilisent des devises différentes."); + } + + // ... méthode de documentation et codes d’erreur ... +} ``` **Pourquoi :** -Chaque type d’exception représente une catégorie d’erreur bien définie. Autoriser l’héritage tend à brouiller la sémantique, créer des hiérarchies floues et rendre les diagnostics plus difficiles à raisonner. Sceller le type garantit que le sens de l’exception reste stable et explicite. +Chaque méthode factory représente une catégorie d’erreur bien définie. Les regrouper dans une classe dédiée garde au même endroit les situations d’erreur liées, leurs codes et leur documentation. Notez que les types du cœur (`DomainError`, `DomainException`, …) ne sont **pas** `sealed` — l’héritage est intentionnellement autorisé afin de pouvoir modéliser vos propres hiérarchies d’erreur — mais en pratique vous décrivez les situations d’erreur via ces classes factory plutôt qu’en dérivant des sous-classes. -## 🏭 12. Utiliser des constructeurs privés et des méthodes factory +## 🏭 12. Construire les erreurs via des factories, lever avec `ToException()` -Les constructeurs d’exception devraient être `private` et seuls ceux strictement nécessaires devraient être implémentés. +Vous ne faites jamais `new` sur une `DiagnosableException` dans votre code, et il n’existe pas de constructeur à deux chaînes : le seul constructeur d’une exception prend une `Error`. Construisez l’erreur via une méthode factory puis transformez-la en exception avec `ToException()`. ```csharp -private InvalidAmountOperationException(string errorCode, string errorMessage) - : base(errorCode, errorMessage) { } +// Construit une Error via la factory, puis la lève en tant qu’exception : +throw InvalidAmountOperationError.CurrencyMismatch(a1, a2).ToException(); ``` -Les instances doivent toujours être créées via des méthodes factory : +Lorsque l’échec est attendu plutôt qu’exceptionnel, retournez l’`Error` de la même factory dans un `Outcome` : ```csharp -throw InvalidAmountOperationException.CurrencyMismatch(a1, a2); +return Outcome.Failure(InvalidAmountOperationError.NegativeAmount(value)); ``` **Pourquoi :** -En restreignant les constructeurs, vous vous assurez que toutes les exceptions de ce type sont créées de manière contrôlée, documentée et sémantiquement cohérente. +Faire passer chaque erreur par une factory garantit que toutes les erreurs d’une catégorie donnée sont créées de manière contrôlée, documentée et sémantiquement cohérente, qu’elles soient levées en tant qu’exceptions ou portées comme échecs d’`Outcome`. ## 🎯 Pensée finale diff --git a/doc/CoreConcepts.en.md b/doc/CoreConcepts.en.md index 4f271251..4c3e2816 100644 --- a/doc/CoreConcepts.en.md +++ b/doc/CoreConcepts.en.md @@ -73,13 +73,26 @@ Diagnostics are: They do not encode operational processes. They provide **direction**, not procedures. +## 🧭 Error taxonomy + +Errors are modeled as a hierarchy rooted in the abstract `Error` type: + +* **`DomainError`** — a violation of a domain rule (the domain layer). +* **`InfrastructureError`** — a failure at a technical boundary. It carries a `Transience` (`Unknown` / `NonTransient` / `Transient`) and an `InteractionDirection`. + * **`PrimaryPortError`** — incoming boundary (`Direction` fixed to `Incoming`). + * **`SecondaryPortError`** — outgoing boundary (`Direction` fixed to `Outgoing`). + +The Port errors replace the old Adapter exceptions. When a port failure wraps several causes, `PrimaryPortInnerErrors` / `SecondaryPortInnerErrors` aggregate the inner errors and compute the overall transience. + +Each error has a paired exception reached via `error.ToException()`: `DomainException`, `InfrastructureException`, `PrimaryPortException`, `SecondaryPortException`. You never `new` these directly; the exception exposes its `Error` (and through it the context and inner errors). + ## 🔁 Exception or data? Both are supported Traditionally, exceptions are always thrown. DiagnosableExceptions supports two complementary models: * **Exception as control flow** (classic throw) -* **Exception as data** (`TryOutcome`) +* **Exception as data** (`Outcome`, or non-generic `Outcome` when there is no value) This allows errors to be: @@ -87,7 +100,9 @@ This allows errors to be: * transported through validation pipelines * escalated later -The same exception type can serve both roles. +The same error situation can serve both roles. + +The non-throwing model is `Outcome` / `Outcome`: the `Error` is carried as data (`IsSuccess` / `IsFailure` / `Error`) and can be converted into an exception on demand via `error.ToException()`. ## 🎯 From failures to knowledge diff --git a/doc/CoreConcepts.fr.md b/doc/CoreConcepts.fr.md index 9e3d91f7..3d8eed17 100644 --- a/doc/CoreConcepts.fr.md +++ b/doc/CoreConcepts.fr.md @@ -72,13 +72,26 @@ Les diagnostics sont : Ils n’encodent pas de processus opérationnels. Ils donnent une **direction**, pas des procédures. +## 🧭 Taxonomie des erreurs + +Les erreurs sont modélisées sous forme de hiérarchie ayant pour racine le type abstrait `Error` : + +* **`DomainError`** — une violation d’une règle métier (la couche domaine). +* **`InfrastructureError`** — une défaillance à une frontière technique. Elle porte une `Transience` (`Unknown` / `NonTransient` / `Transient`) et une `InteractionDirection`. + * **`PrimaryPortError`** — frontière entrante (`Direction` fixée à `Incoming`). + * **`SecondaryPortError`** — frontière sortante (`Direction` fixée à `Outgoing`). + +Les erreurs de Port remplacent les anciennes exceptions d’Adapter. Lorsqu’une défaillance de port enveloppe plusieurs causes, `PrimaryPortInnerErrors` / `SecondaryPortInnerErrors` agrègent les erreurs internes et calculent la transience globale. + +Chaque erreur possède une exception associée, obtenue via `error.ToException()` : `DomainException`, `InfrastructureException`, `PrimaryPortException`, `SecondaryPortException`. On ne les instancie jamais directement avec `new` ; l’exception expose son `Error` (et, à travers lui, le contexte et les erreurs internes). + ## 🔁 Exception ou donnée ? Les deux sont possibles Traditionnellement, les exceptions sont toujours levées. DiagnosableExceptions supporte deux modèles complémentaires : * **L’exception comme flux de contrôle** (throw classique) -* **L’exception comme donnée** (`TryOutcome`) +* **L’exception comme donnée** (`Outcome`, ou `Outcome` non générique lorsqu’il n’y a pas de valeur) Cela permet aux erreurs d’être : @@ -86,7 +99,9 @@ Cela permet aux erreurs d’être : * transportées dans des pipelines de validation * escaladées plus tard -Le même type d’exception peut servir ces deux rôles. +La même situation d’erreur peut servir ces deux rôles. + +Le modèle sans levée d’exception est `Outcome` / `Outcome` : l’`Error` est portée comme donnée (`IsSuccess` / `IsFailure` / `Error`) et peut être convertie en exception à la demande via `error.ToException()`. ## 🎯 De l’échec à la connaissance diff --git a/doc/DesignPrinciples.en.md b/doc/DesignPrinciples.en.md index 88d9f79c..8725f307 100644 --- a/doc/DesignPrinciples.en.md +++ b/doc/DesignPrinciples.en.md @@ -10,7 +10,7 @@ Diagnostics are not post-mortem analysis; they are structured hypotheses. The go The library also separates semantics from mechanics. Throwing, catching, logging, or transporting errors are mechanical concerns. The meaning of an error — what rule was violated, what situation occurred, what might explain it — belongs to the domain of knowledge. DiagnosableExceptions focuses on preserving that meaning, regardless of how the error travels through the system. -Finally, the design acknowledges that not every failure should be exceptional in the runtime sense. Some errors are expected parts of normal flow, such as validation failures or parsing issues. By allowing exceptions to be used as structured error information through `TryOutcome`, the model supports both throwing and non-throwing flows without losing semantic richness. +Finally, the design acknowledges that not every failure should be exceptional in the runtime sense. Some errors are expected parts of normal flow, such as validation failures or parsing issues. By allowing exceptions to be used as structured error information through `Outcome`, the model supports both throwing and non-throwing flows without losing semantic richness. In essence, the library encourages teams to treat errors as first-class knowledge artifacts. When errors are explicit, documented, and structured, they improve communication between developers, support teams, and the system itself. diff --git a/doc/DesignPrinciples.fr.md b/doc/DesignPrinciples.fr.md index cc7a9a6d..12b9e4d5 100644 --- a/doc/DesignPrinciples.fr.md +++ b/doc/DesignPrinciples.fr.md @@ -10,7 +10,7 @@ Les diagnostics ne sont pas une analyse post-mortem ; ce sont des hypothèses st La bibliothèque sépare également la sémantique de la mécanique. Lever, intercepter, logger ou transporter des erreurs sont des préoccupations mécaniques. Le sens d’une erreur — quelle règle a été violée, quelle situation s’est produite, ce qui pourrait l’expliquer — appartient au domaine de la connaissance. DiagnosableExceptions se concentre sur la préservation de ce sens, indépendamment de la manière dont l’erreur circule dans le système. -Enfin, la conception reconnaît que tous les échecs ne doivent pas être exceptionnels au sens runtime. Certaines erreurs font partie du flux normal, comme les échecs de validation ou les problèmes de parsing. En permettant d’utiliser les exceptions comme information d’erreur structurée via `TryOutcome`, le modèle supporte à la fois les flux avec et sans levée d’exception, sans perdre la richesse sémantique. +Enfin, la conception reconnaît que tous les échecs ne doivent pas être exceptionnels au sens runtime. Certaines erreurs font partie du flux normal, comme les échecs de validation ou les problèmes de parsing. En permettant d’utiliser les exceptions comme information d’erreur structurée via `Outcome`, le modèle supporte à la fois les flux avec et sans levée d’exception, sans perdre la richesse sémantique. En essence, la bibliothèque encourage les équipes à considérer les erreurs comme des artefacts de connaissance de premier plan. Lorsque les erreurs sont explicites, documentées et structurées, elles améliorent la communication entre les développeurs, les équipes de support et le système lui-même. diff --git a/doc/ErrorContext.en.md b/doc/ErrorContext.en.md index 25e9c579..f04d74a9 100644 --- a/doc/ErrorContext.en.md +++ b/doc/ErrorContext.en.md @@ -1,6 +1,6 @@ # Error Context: When and Why to Use It -`ErrorContext` lets you attach **structured, typed, and stable** metadata to a `DiagnosableException` instance. +`ErrorContext` lets you attach **structured, typed, and stable** metadata to an `Error` (via `Error.Context`), reached from a thrown exception through `exception.Error.Context`. It complements the error code and messages by answering: @@ -61,12 +61,13 @@ internal static class ErrCtxKey { ### 2) Add context at factory level -Attach context where the exception is created, so every occurrence is consistent: +Attach context where the error is created, so every occurrence is consistent: ```csharp -return new NonCompliantBankTransactionFileException( +return new PrimaryPortError( Code.DateOutOfStatementPeriod, - $"Transaction dated {transactionDate} is outside statement period [{periodStart};{periodEnd}].", + $"Transaction dated {transactionDate} is outside the statement period.", + Transience.NonTransient, "Transaction date is outside the statement period.", ctx => ctx.Add(ErrCtxKey.TransactionDate, transactionDate)); ``` diff --git a/doc/ErrorContext.fr.md b/doc/ErrorContext.fr.md index bbeae385..7825c112 100644 --- a/doc/ErrorContext.fr.md +++ b/doc/ErrorContext.fr.md @@ -1,6 +1,6 @@ # Contexte d’erreur : quand et pourquoi l’utiliser -`ErrorContext` permet d’attacher des métadonnées **structurées, typées et stables** à une instance de `DiagnosableException`. +`ErrorContext` permet d’attacher des métadonnées **structurées, typées et stables** à une `Error` (via `Error.Context`), accessibles depuis une exception levée via `exception.Error.Context`. Il complète le code d’erreur et les messages en répondant à : @@ -61,12 +61,13 @@ internal static class ErrCtxKey { ### 2) Ajouter le contexte au niveau des factories -Attachez le contexte là où l’exception est créée, pour garantir la cohérence de chaque occurrence : +Attachez le contexte là où l’erreur est créée, pour garantir la cohérence de chaque occurrence : ```csharp -return new NonCompliantBankTransactionFileException( +return new PrimaryPortError( Code.DateOutOfStatementPeriod, - $"Transaction datée du {transactionDate} hors période [{periodStart};{periodEnd}].", + $"Transaction datée du {transactionDate} hors période du relevé.", + Transience.NonTransient, "La date de transaction est hors période du relevé.", ctx => ctx.Add(ErrCtxKey.TransactionDate, transactionDate)); ``` diff --git a/doc/FAQ.en.md b/doc/FAQ.en.md index 4832c498..0f23e43b 100644 --- a/doc/FAQ.en.md +++ b/doc/FAQ.en.md @@ -24,7 +24,7 @@ DiagnosableExceptions keeps: * diagnostics * context -while still allowing errors to be transported without throwing via `TryOutcome`. +while still allowing errors to be transported without throwing via `Outcome`. You get the advantages of result-based flow without losing the power of exceptions. @@ -95,7 +95,7 @@ Avoid adding: A good rule: if the data helps explain this occurrence in logs, and is safe to expose, add it. -## ❓ When should I use `TryOutcome`? +## ❓ When should I use `Outcome`? Use it when failure is expected and part of normal flow: @@ -108,12 +108,12 @@ Use exceptions directly when: * invariants are violated * the system cannot proceed -## ❓ Does `TryOutcome` lose the stack trace? +## ❓ Does `Outcome` lose the stack trace? Yes — intentionally. -When using `TryOutcome`, the exception is treated as structured error information, not a runtime crash. -If you later call `GetOrThrow()`, the exception is thrown at that point. +When using `Outcome`, the exception is treated as structured error information, not a runtime crash. +If you later call `GetResultOrThrow()`, the exception is thrown at that point. ## ❓ Can I document every exception? diff --git a/doc/FAQ.fr.md b/doc/FAQ.fr.md index 766ab6e4..93138de2 100644 --- a/doc/FAQ.fr.md +++ b/doc/FAQ.fr.md @@ -24,7 +24,7 @@ DiagnosableExceptions conserve : * des diagnostics * du contexte -tout en permettant de transporter les erreurs sans lever d’exception via `TryOutcome`. +tout en permettant de transporter les erreurs sans lever d’exception via `Outcome`. Vous obtenez les avantages d’un flux basé sur les résultats sans perdre la puissance des exceptions. @@ -95,7 +95,7 @@ Bons candidats : Règle simple : si la donnée aide à expliquer cette occurrence dans les logs, et qu’elle est sûre à exposer, ajoutez-la. -## ❓ Quand dois-je utiliser `TryOutcome` ? +## ❓ Quand dois-je utiliser `Outcome` ? Utilisez-le lorsque l’échec est attendu et fait partie du flux normal : @@ -108,12 +108,12 @@ Utilisez directement des exceptions lorsque : * des invariants sont violés * le système ne peut pas continuer -## ❓ `TryOutcome` fait-il perdre la stack trace ? +## ❓ `Outcome` fait-il perdre la stack trace ? Oui — volontairement. -Avec `TryOutcome`, l’exception est traitée comme une information d’erreur structurée, pas comme un crash runtime. -Si vous appelez ensuite `GetOrThrow()`, l’exception est levée à ce moment-là. +Avec `Outcome`, l’exception est traitée comme une information d’erreur structurée, pas comme un crash runtime. +Si vous appelez ensuite `GetResultOrThrow()`, l’exception est levée à ce moment-là. ## ❓ Puis-je documenter toutes les exceptions ? diff --git a/doc/GettingStarted.en.md b/doc/GettingStarted.en.md index 03aa415c..b7d78f7b 100644 --- a/doc/GettingStarted.en.md +++ b/doc/GettingStarted.en.md @@ -22,36 +22,38 @@ This pattern is essential because: Note: -*Using factory methods to create exceptions is a well-established .NET pattern for centralizing and standardizing exception creation. DiagnosableExceptions builds on this idea and makes exception factories the anchor point for structured, living error documentation. Beyond documentation, factories significantly improve code readability: they keep error construction (error codes, messages, formatting, and wording) out of the “happy path,” allowing domain logic to remain focused on business rules rather than technical details. A call such as `throw InvalidAmountOperationException.CurrencyMismatch(a1, a2);` expresses intent far more clearly than inlined exception construction. This approach aligns with clean code principles by separating concerns, reducing duplication, and giving each error situation a named, explicit representation in the codebase — while also providing a single, consistent place to attach diagnostics and documentation.* +*Using factory methods to create exceptions is a well-established .NET pattern for centralizing and standardizing exception creation. DiagnosableExceptions builds on this idea and makes exception factories the anchor point for structured, living error documentation. Beyond documentation, factories significantly improve code readability: they keep error construction (error codes, messages, formatting, and wording) out of the “happy path,” allowing domain logic to remain focused on business rules rather than technical details. A call such as `throw InvalidAmountOperationError.CurrencyMismatch(a1, a2).ToException();` expresses intent far more clearly than inlined exception construction. This approach aligns with clean code principles by separating concerns, reducing duplication, and giving each error situation a named, explicit representation in the codebase — while also providing a single, consistent place to attach diagnostics and documentation.* Example: ```csharp -[ProvidesErrorsFor(typeof(Amount))] -public sealed class InvalidAmountOperationException : DomainException { +[ProvidesErrorsFor(nameof(Amount))] +public static class InvalidAmountOperationError { [DocumentedBy(nameof(CurrencyMismatchDocumentation))] - public static InvalidAmountOperationException CurrencyMismatch(Amount amount1, Amount amount2) { - return new InvalidAmountOperationException( - "AMOUNT_CURRENCY_MISMATCH", + internal static DomainError CurrencyMismatch(Amount amount1, Amount amount2) { + return new DomainError( + Code.CurrencyMismatch, $"Failed to perform the monetary operation because the involved amounts are expressed in different currencies: {amount1} and {amount2}.", - "Currency mismatch" - ); + "Currency mismatch"); } - private InvalidAmountOperationException(string errorCode, string errorMessage, string shortMessage) - : base(errorCode, errorMessage, shortMessage) { } + private static class Code { + public static readonly ErrorCode CurrencyMismatch = ErrorCode.Create("AMOUNT_CURRENCY_MISMATCH"); + } } ``` Here: -* The **exception type** represents a category of domain errors. +* The **error type** represents a category of domain errors. * The **factory method** represents a precise error case. * The **error code** is stable and machine-readable. * The factory method is what will be documented. +You never `new` the exception yourself: when you need to throw, you call `error.ToException()` (see section 4). + ## 2️. Link the factory to structured documentation Each factory method is linked to documentation using `[DocumentedBy]`. @@ -63,18 +65,20 @@ private static ErrorDocumentation CurrencyMismatchDocumentation() { .WithRule("All monetary operations must involve amounts expressed in the same currency.") .WithDiagnostic( "Amounts were used in a monetary operation without having been converted to the same currency.", - ErrorCauseType.System, + ErrorOrigin.Internal, "Verify whether all amounts involved in the operation were converted to a common currency before being used together." ) .AndDiagnostic( "Amounts expected to be expressed in the same currency were provided with different currencies.", - ErrorCauseType.SystemOrInput, + ErrorOrigin.InternalOrExternal, "Check the currencies associated with each amount and confirm whether a common currency was expected for this operation." ) .WithExamples(() => CurrencyMismatch(new Amount(127.33m, Currency.EUR), new Amount(57689.00m, Currency.USD))); } ``` +Each diagnostic declares an **origin** via `ErrorOrigin`, whose values are `Internal`, `External`, and `InternalOrExternal` — indicating whether the cause lies inside the system, outside it (input), or could be either. + This documentation: * explains what the error means @@ -89,13 +93,15 @@ This is structured knowledge, not a comment. When information helps diagnose **a specific occurrence**, attach it as context. ```csharp -return new NonCompliantBankTransactionFileException( +return new SecondaryPortError( Code.DateOutOfStatementPeriod, $"Transaction dated {transactionDate} is outside statement period [{periodStart};{periodEnd}].", "Transaction date is outside the statement period.", ctx => ctx.Add(ErrCtxKey.TransactionDate, transactionDate)); ``` +The context lives on the `Error`; when an exception is later produced with `error.ToException()`, it is reached through `exception.Error.Context`. + Best practices: * use named, stable keys (`ErrorContextKey`) @@ -106,7 +112,7 @@ Best practices: ```csharp public Amount Add(Amount other) { - if (Currency != other.Currency) { throw InvalidAmountOperationException.CurrencyMismatch(this, other); } + if (Currency != other.Currency) { throw InvalidAmountOperationError.CurrencyMismatch(this, other).ToException(); } return new Amount(Value + other.Value, Currency); } @@ -114,32 +120,34 @@ public Amount Add(Amount other) { Domain logic remains clean and expressive. -## 5️. Or use it without throwing (`TryOutcome`) +## 5️. Or use it without throwing (`Outcome`) For validation or batch scenarios: ```csharp -public static TryOutcome TryAdd(Amount a1, Amount a2) { +public static Outcome TryAdd(Amount a1, Amount a2) { if (a1.Currency != a2.Currency) { - return TryOutcome.Failure(InvalidAmountOperationException.CurrencyMismatch(a1, a2)); + return Outcome.Failure(InvalidAmountOperationError.CurrencyMismatch(a1, a2)); } - return TryOutcome.Success(new Amount(a1.Value + a2.Value, a1.Currency)); + return Outcome.Success(new Amount(a1.Value + a2.Value, a1.Currency)); } ``` +Note: `Failure(...)` takes an **`Error`** — the factory returns one directly, so no exception is involved. + You can inspect: ```csharp if (result.IsFailure) { - Log(result.Exception); + Log(result.Error); } ``` Or escalate: ```csharp -var amount = result.GetOrThrow(); +var amount = result.GetResultOrThrow(); ``` ## 6️. Generate documentation diff --git a/doc/GettingStarted.fr.md b/doc/GettingStarted.fr.md index ef13af86..1362f210 100644 --- a/doc/GettingStarted.fr.md +++ b/doc/GettingStarted.fr.md @@ -22,36 +22,38 @@ Ce pattern est essentiel car : Remarque : -*L’utilisation de méthodes factory pour créer des exceptions est un pattern .NET bien établi pour centraliser et standardiser la création d’exceptions. DiagnosableExceptions s’appuie sur cette idée et fait des factories le point d’ancrage de la documentation d’erreurs structurée et vivante. Au-delà de la documentation, les factories améliorent fortement la lisibilité du code : elles sortent la construction de l’erreur (codes, messages, formatage, formulation) du “happy path”, ce qui permet à la logique métier de rester centrée sur les règles métier plutôt que sur des détails techniques. Un appel comme `throw InvalidAmountOperationException.CurrencyMismatch(a1, a2);` exprime l’intention bien plus clairement qu’une construction d’exception inline. Cette approche s’aligne avec les principes du clean code en séparant les responsabilités, en réduisant la duplication et en donnant à chaque situation d’erreur une représentation explicite et nommée dans le code — tout en fournissant un point unique et cohérent pour attacher diagnostics et documentation.* +*L’utilisation de méthodes factory pour créer des exceptions est un pattern .NET bien établi pour centraliser et standardiser la création d’exceptions. DiagnosableExceptions s’appuie sur cette idée et fait des factories le point d’ancrage de la documentation d’erreurs structurée et vivante. Au-delà de la documentation, les factories améliorent fortement la lisibilité du code : elles sortent la construction de l’erreur (codes, messages, formatage, formulation) du “happy path”, ce qui permet à la logique métier de rester centrée sur les règles métier plutôt que sur des détails techniques. Un appel comme `throw InvalidAmountOperationError.CurrencyMismatch(a1, a2).ToException();` exprime l’intention bien plus clairement qu’une construction d’exception inline. Cette approche s’aligne avec les principes du clean code en séparant les responsabilités, en réduisant la duplication et en donnant à chaque situation d’erreur une représentation explicite et nommée dans le code — tout en fournissant un point unique et cohérent pour attacher diagnostics et documentation.* Exemple : ```csharp -[ProvidesErrorsFor(typeof(Amount))] -public sealed class InvalidAmountOperationException : DomainException { +[ProvidesErrorsFor(nameof(Amount))] +public static class InvalidAmountOperationError { [DocumentedBy(nameof(CurrencyMismatchDocumentation))] - public static InvalidAmountOperationException CurrencyMismatch(Amount amount1, Amount amount2) { - return new InvalidAmountOperationException( - "AMOUNT_CURRENCY_MISMATCH", + internal static DomainError CurrencyMismatch(Amount amount1, Amount amount2) { + return new DomainError( + Code.CurrencyMismatch, $"Impossible d’effectuer l’opération monétaire car les montants impliqués sont exprimés dans des devises différentes : {amount1} et {amount2}.", - "Devise différente" - ); + "Devise différente"); } - private InvalidAmountOperationException(string errorCode, string errorMessage, string shortMessage) - : base(errorCode, errorMessage, shortMessage) { } + private static class Code { + public static readonly ErrorCode CurrencyMismatch = ErrorCode.Create("AMOUNT_CURRENCY_MISMATCH"); + } } ```` Ici : -* Le **type d’exception** représente une catégorie d’erreurs métier. +* Le **type d’erreur** représente une catégorie d’erreurs métier. * La **méthode factory** représente un cas d’erreur précis. * Le **code d’erreur** est stable et lisible par machine. * C’est la méthode factory qui sera documentée. +Vous ne faites jamais `new` sur l’exception vous-même : pour lever, vous appelez `error.ToException()` (voir section 4). + ## 2. Lier la factory à une documentation structurée Chaque méthode factory est liée à sa documentation via `[DocumentedBy]`. @@ -63,18 +65,20 @@ private static ErrorDocumentation CurrencyMismatchDocumentation() { .WithRule("Toutes les opérations monétaires doivent impliquer des montants exprimés dans la même devise.") .WithDiagnostic( "Des montants ont été utilisés dans une opération monétaire sans avoir été convertis dans une devise commune.", - ErrorCauseType.System, + ErrorOrigin.Internal, "Vérifiez si tous les montants impliqués ont été convertis dans une devise commune avant d’être utilisés ensemble." ) .AndDiagnostic( "Des montants censés être exprimés dans la même devise ont été fournis avec des devises différentes.", - ErrorCauseType.SystemOrInput, + ErrorOrigin.InternalOrExternal, "Vérifiez les devises associées à chaque montant et confirmez si une devise commune était attendue pour cette opération." ) .WithExamples(() => CurrencyMismatch(new Amount(127.33m, Currency.EUR), new Amount(57689.00m, Currency.USD))); } ``` +Chaque diagnostic déclare une **origine** via `ErrorOrigin`, dont les valeurs sont `Internal`, `External` et `InternalOrExternal` — indiquant si la cause se situe à l’intérieur du système, à l’extérieur (entrée) ou peut être l’une ou l’autre. + Cette documentation : * explique la signification de l’erreur @@ -89,13 +93,15 @@ Il s’agit de connaissance structurée, pas d’un commentaire. Quand une information est utile pour diagnostiquer **une occurrence précise**, ajoutez-la dans le contexte. ```csharp -return new NonCompliantBankTransactionFileException( +return new SecondaryPortError( Code.DateOutOfStatementPeriod, $"Transaction datée du {transactionDate} hors période [{periodStart};{periodEnd}].", "Date de transaction hors période.", ctx => ctx.Add(ErrCtxKey.TransactionDate, transactionDate)); ``` +Le contexte est porté par l’`Error` ; lorsqu’une exception est ensuite produite avec `error.ToException()`, on y accède via `exception.Error.Context`. + Bonnes pratiques : * utilisez des clés nommées et stables (`ErrorContextKey`) @@ -106,7 +112,7 @@ Bonnes pratiques : ```csharp public Amount Add(Amount other) { - if (Currency != other.Currency) { throw InvalidAmountOperationException.CurrencyMismatch(this, other); } + if (Currency != other.Currency) { throw InvalidAmountOperationError.CurrencyMismatch(this, other).ToException(); } return new Amount(Value + other.Value, Currency); } @@ -114,32 +120,34 @@ public Amount Add(Amount other) { La logique métier reste propre et expressive. -## 5. Ou l’utiliser sans lever d’exception (`TryOutcome`) +## 5. Ou l’utiliser sans lever d’exception (`Outcome`) Pour les scénarios de validation ou de traitement par lots : ```csharp -public static TryOutcome TryAdd(Amount a1, Amount a2) { +public static Outcome TryAdd(Amount a1, Amount a2) { if (a1.Currency != a2.Currency) { - return TryOutcome.Failure(InvalidAmountOperationException.CurrencyMismatch(a1, a2)); + return Outcome.Failure(InvalidAmountOperationError.CurrencyMismatch(a1, a2)); } - return TryOutcome.Success(new Amount(a1.Value + a2.Value, a1.Currency)); + return Outcome.Success(new Amount(a1.Value + a2.Value, a1.Currency)); } ``` +Remarque : `Failure(...)` prend une **`Error`** — la factory en renvoie une directement, donc aucune exception n’est impliquée. + Vous pouvez inspecter : ```csharp if (result.IsFailure) { - Log(result.Exception); + Log(result.Error); } ``` Ou escalader : ```csharp -var amount = result.GetOrThrow(); +var amount = result.GetResultOrThrow(); ``` ## 6. Générer la documentation diff --git a/doc/OperationalIntegration.en.md b/doc/OperationalIntegration.en.md index 5118cffb..b0efa6f8 100644 --- a/doc/OperationalIntegration.en.md +++ b/doc/OperationalIntegration.en.md @@ -40,17 +40,17 @@ Logs can include: This makes logs not only readable but also correlatable across systems. -## 🔍 Logging inner exceptions +## 🔍 Logging inner errors -By default, most logging setups treat exceptions as flat messages or stack traces. They do not automatically traverse and structure multiple inner exceptions in a meaningful way for analysis. +By default, most logging setups treat exceptions as flat messages or stack traces. They do not automatically traverse and structure the diagnostic information carried by a `DiagnosableException` in a meaningful way for analysis. -Since `DiagnosableException` can aggregate several inner exceptions, a logging filter or middleware should explicitly extract and log them. Without this step, part of the diagnostic information carried by the model may remain unused in logs. +A `DiagnosableException` does not set `Exception.InnerException`; instead, the diagnostic chain lives on its `Error`. Through `exception.Error.InnerErrors` (a list of `Error`), a logging filter or middleware should explicitly traverse and log the chain. Without this step, part of the diagnostic information carried by the model may remain unused in logs. This filter can: * detect `DiagnosableException` -* extract its inner exceptions -* log the full chain in a structured form +* read its `.Error` +* traverse `Error.InnerErrors` and log the full chain in a structured form This preserves diagnostic depth and ensures that the richness of the error model is actually visible in operational logs. diff --git a/doc/OperationalIntegration.fr.md b/doc/OperationalIntegration.fr.md index 9008a944..d0c97038 100644 --- a/doc/OperationalIntegration.fr.md +++ b/doc/OperationalIntegration.fr.md @@ -40,17 +40,17 @@ Les logs peuvent inclure : Cela rend les logs non seulement lisibles, mais aussi corrélables entre systèmes. -## 🔍 Logging des inner exceptions +## 🔍 Logging des inner errors -Par défaut, la plupart des configurations de logging traitent les exceptions comme de simples messages ou des stack traces. Elles ne parcourent pas automatiquement plusieurs inner exceptions de manière structurée et exploitable pour l’analyse. +Par défaut, la plupart des configurations de logging traitent les exceptions comme de simples messages ou des stack traces. Elles ne parcourent pas automatiquement l’information de diagnostic portée par une `DiagnosableException` de manière structurée et exploitable pour l’analyse. -Comme `DiagnosableException` peut agréger plusieurs inner exceptions, un filtre de logging ou un middleware devrait explicitement les extraire et les logger. Sans cela, une partie de l’information de diagnostic portée par le modèle peut rester inutilisée dans les logs. +Une `DiagnosableException` ne renseigne pas `Exception.InnerException` ; la chaîne de diagnostic vit plutôt sur son `Error`. Via `exception.Error.InnerErrors` (une liste d’`Error`), un filtre de logging ou un middleware devrait explicitement parcourir et logger cette chaîne. Sans cela, une partie de l’information de diagnostic portée par le modèle peut rester inutilisée dans les logs. Ce filtre peut : * détecter les `DiagnosableException` -* extraire leurs inner exceptions -* logger toute la chaîne de manière structurée +* lire son `.Error` +* parcourir `Error.InnerErrors` et logger toute la chaîne de manière structurée Cela préserve la profondeur diagnostique et garantit que la richesse du modèle d’erreur est réellement visible dans les logs opérationnels. @@ -60,8 +60,8 @@ Un pattern puissant consiste à enrichir les exceptions diagnostiquables avec un Lors de la génération de la documentation, chaque erreur peut être associée à une page ou une ancre. Un filtre de logging peut alors renseigner : -```csharp -exception.HelpLink = "[https://docs.mycompany/errors/AMOUNT_CURRENCY_MISMATCH](https://docs.mycompany/errors/AMOUNT_CURRENCY_MISMATCH)" +``` +exception.HelpLink = "https://docs.mycompany/errors/AMOUNT_CURRENCY_MISMATCH" ``` Les logs de production deviennent ainsi navigables : le support peut passer directement d’une entrée de log à la documentation correspondante de l’erreur. diff --git a/doc/README.fr.md b/doc/README.fr.md index c7951f44..44e3d9c8 100644 --- a/doc/README.fr.md +++ b/doc/README.fr.md @@ -117,7 +117,7 @@ Cela permet de générer : La bibliothèque supporte à la fois : * **les erreurs levées** (flux classique par exceptions) -* **les erreurs transportées sans être levées** via `TryOutcome` +* **les erreurs transportées sans être levées** via `Outcome` et `Outcome` Cela vous permet d’utiliser les exceptions : @@ -131,14 +131,14 @@ selon le contexte (domaine, validation, pipelines, etc.). Extrait du projet `DiagnosableExceptions.Usage` : ```csharp -[ProvidesErrorsFor(typeof(Temperature))] -public sealed class InvalidTemperatureException : DomainException { +[ProvidesErrorsFor(nameof(Temperature))] +public static class InvalidTemperatureError { [DocumentedBy(nameof(BelowAbsoluteZeroDocumentation))] - internal static InvalidTemperatureException BelowAbsoluteZero(decimal invalidValue, TemperatureUnit invalidValueUnit) { - return new InvalidTemperatureException( - "TEMPERATURE_BELOW_ABSOLUTE_ZERO", - $"Failed to instantiate temperature: the value {invalidValue}{invalidValueUnit} is below absolute zero.", + internal static DomainError BelowAbsoluteZero(decimal invalidValue, TemperatureUnit invalidValueUnit) { + return new DomainError( + Code.TemperatureBelowAbsoluteZero, + $"Failed to instantiate temperature: the value {invalidValue} {invalidValueUnit} is below absolute zero.", "Temperature is below absolute zero."); } @@ -151,10 +151,20 @@ public sealed class InvalidTemperatureException : DomainException { () => BelowAbsoluteZero(-1, TemperatureUnit.Kelvin), () => BelowAbsoluteZero(-280, TemperatureUnit.Celsius)); } + + private static class Code { + public static readonly ErrorCode TemperatureBelowAbsoluteZero = ErrorCode.Create("TEMPERATURE_BELOW_ABSOLUTE_ZERO"); + } } ``` -Ici, l’exception, sa signification, sa règle, ses diagnostics et des exemples de messages sont définis ensemble — dans le code. +La factory retourne une `Error` structurée. Lorsque vous devez la lever, vous la transformez en exception avec `.ToException()` : + +```csharp +throw InvalidTemperatureError.BelowAbsoluteZero(-1, TemperatureUnit.Kelvin).ToException(); +``` + +Ici, l’erreur, sa signification, sa règle, ses diagnostics et des exemples de messages sont définis ensemble — dans le code. ## 🎯 Pour qui ? diff --git a/doc/UsagePatterns.en.md b/doc/UsagePatterns.en.md index ed60108f..6c879ad5 100644 --- a/doc/UsagePatterns.en.md +++ b/doc/UsagePatterns.en.md @@ -12,7 +12,7 @@ public static Amount From(decimal value, Currency currency) { if (value < 0) { - throw InvalidAmountException.NegativeValue(value, currency); + throw InvalidAmountOperationError.NegativeAmount(value).ToException(); } return new Amount(value, currency); @@ -32,15 +32,15 @@ This keeps domain code expressive and self-explanatory. User or external inputs may be invalid, but not exceptional in the technical sense. ```csharp -public TryOutcome TryCreateAmount(decimal value, string currencyCode) +public Outcome TryCreateAmount(decimal value, string currencyCode) { if (!Currency.TryParse(currencyCode, out var currency)) { - return TryOutcome.Failure( - InvalidAmountException.UnknownCurrency(currencyCode)); + return Outcome.Failure( + InvalidAmountOperationError.UnknownCurrency(currencyCode)); } - return TryOutcome.Success(new Amount(value, currency)); + return Outcome.Success(new Amount(value, currency)); } ``` @@ -61,7 +61,7 @@ public Amount Add(Amount other) { if (Currency != other.Currency) { - throw InvalidAmountOperationException.CurrencyMismatch(this, other); + throw InvalidAmountOperationError.CurrencyMismatch(this, other).ToException(); } return new Amount(Value + other.Value, Currency); @@ -81,7 +81,7 @@ foreach (var line in file) if (result.IsFailure) { - Log(result.Exception); + Log(result.Error); continue; } @@ -117,8 +117,8 @@ Complex validations often involve multiple checks. ```csharp var result = ValidateAmount(amount) - .Bind(CheckCurrency) - .Bind(CheckLimits); + .Then(CheckCurrency) + .Then(CheckLimits); ``` Each failure can carry a diagnosable exception, keeping the model consistent while avoiding uncontrolled throwing. @@ -133,6 +133,61 @@ Because exceptions carry structured diagnostics, logs become more useful: Support teams can relate runtime events to documented error cases. +## 🛠️ 8. Composing with the `Outcome` pipeline + +`Outcome` and `Outcome` let you compose success and failure paths without throwing. +A failure carries an `Error` (never an `Exception`), so the whole chain stays diagnosable. + +* **`Then(...)`** — chain the next step only when the previous one succeeded (short-circuits on failure). +* **`To(...)`** — map the carried value to another value (`Outcome` only), preserving any failure. +* **`Recover(...)`** — provide a fallback when the chain has failed. +* **`Finally(...)`** — run terminal handling for both success and failure. + +```csharp +Outcome outcome = + TryCreateAmount(value, currencyCode) // Outcome + .Then(amount => CheckLimits(amount)) // Outcome, runs only on success + .To(amount => amount.WithVat()) // map the value, failures pass through + .Recover(error => Amount.Zero) // fallback value if the chain failed + .Then(amount => Charge(amount)) // Outcome + .Finally( + onSuccess: receipt => Log($"Charged {receipt}"), + onFailure: error => Log(error)); // error is an Error, fully diagnosable +``` + +### Escape hatches + +When you need to leave the `Outcome` world (e.g. at an application boundary), two escape hatches turn a failure back into a throw: + +* **`ThrowIfFailure()`** — throws the failure's exception (via `error.ToException()`) when the outcome failed; otherwise does nothing. +* **`GetResultOrThrow()`** — returns the carried value on success, or throws the failure's exception (`Outcome` only). + +```csharp +Outcome outcome = TryCreateAmount(value, currencyCode); + +outcome.ThrowIfFailure(); // throws error.ToException() on failure +Amount amount = outcome.GetResultOrThrow(); // value on success, otherwise throws +``` + +### Async composition + +For asynchronous flows, `OutcomeTaskExtensions` provides `Then` / `To` / `Recover` / `Finally` +overloads over `Task` and `Task>`. Each overload accepts an optional +`CancellationToken`, so you can await the whole pipeline: + +```csharp +Outcome outcome = + await TryLoadAmountAsync(orderId, cancellationToken) // Task> + .Then(amount => CheckLimitsAsync(amount), cancellationToken) + .To(amount => amount.WithVat()) + .Recover(error => Amount.Zero) + .Then(amount => ChargeAsync(amount), cancellationToken) + .Finally( + onSuccess: receipt => LogAsync(receipt), + onFailure: error => LogAsync(error), + cancellationToken); +``` + ## 🎯 Summary DiagnosableExceptions shines when: diff --git a/doc/UsagePatterns.fr.md b/doc/UsagePatterns.fr.md index 53a7cefa..32f85c1c 100644 --- a/doc/UsagePatterns.fr.md +++ b/doc/UsagePatterns.fr.md @@ -9,7 +9,7 @@ Lors de la création d’un value object, les états invalides doivent être rej ```csharp public static Amount From(decimal value, Currency currency) { - if (value < 0) { throw InvalidAmountException.NegativeValue(value, currency); } + if (value < 0) { throw InvalidAmountOperationError.NegativeAmount(value).ToException(); } return new Amount(value, currency); } @@ -28,11 +28,11 @@ Le code métier reste expressif et auto-explicatif. Les entrées utilisateur ou externes peuvent être invalides, sans être exceptionnelles au sens technique. ```csharp -public TryOutcome TryCreateAmount(decimal value, string currencyCode){ +public Outcome TryCreateAmount(decimal value, string currencyCode){ if (!Currency.TryParse(currencyCode, out var currency)) { - return TryOutcome.Failure(InvalidAmountException.UnknownCurrency(currencyCode)); } + return Outcome.Failure(InvalidAmountOperationError.UnknownCurrency(currencyCode)); } - return TryOutcome.Success(new Amount(value, currency)); + return Outcome.Success(new Amount(value, currency)); } ``` @@ -50,7 +50,7 @@ Les opérations entre objets métier comportent souvent des contraintes sémanti ```csharp public Amount Add(Amount other) { - if (Currency != other.Currency) { throw InvalidAmountOperationException.CurrencyMismatch(this, other); } + if (Currency != other.Currency) { throw InvalidAmountOperationError.CurrencyMismatch(this, other).ToException(); } return new Amount(Value + other.Value, Currency); } @@ -67,7 +67,7 @@ foreach (var line in file) { var result = TryParseAmount(line); if (result.IsFailure) { - Log(result.Exception); + Log(result.Error); continue; } @@ -104,8 +104,8 @@ Les validations complexes impliquent souvent plusieurs contrôles. ```csharp var result = ValidateAmount(amount) - .Bind(CheckCurrency) - .Bind(CheckLimits); + .Then(CheckCurrency) + .Then(CheckLimits); ``` Chaque échec peut porter une exception diagnostiquable, ce qui garde un modèle cohérent tout en évitant des levées d’exception incontrôlées. @@ -120,6 +120,61 @@ Comme les exceptions portent des diagnostics structurés, les logs deviennent pl Les équipes support peuvent relier les événements runtime à des cas d’erreur documentés. +## 🛠️ 8. Composer avec le pipeline `Outcome` + +`Outcome` et `Outcome` permettent de composer les chemins de succès et d’échec sans lever d’exception. +Un échec porte une `Error` (jamais une `Exception`), si bien que toute la chaîne reste diagnostiquable. + +* **`Then(...)`** — enchaîne l’étape suivante uniquement si la précédente a réussi (court-circuite en cas d’échec). +* **`To(...)`** — transforme la valeur portée en une autre valeur (`Outcome` uniquement), en préservant un éventuel échec. +* **`Recover(...)`** — fournit une valeur de repli lorsque la chaîne a échoué. +* **`Finally(...)`** — exécute un traitement terminal pour le succès comme pour l’échec. + +```csharp +Outcome outcome = + TryCreateAmount(value, currencyCode) // Outcome + .Then(amount => CheckLimits(amount)) // Outcome, exécuté seulement en cas de succès + .To(amount => amount.WithVat()) // transforme la valeur, les échecs passent au travers + .Recover(error => Amount.Zero) // valeur de repli si la chaîne a échoué + .Then(amount => Charge(amount)) // Outcome + .Finally( + onSuccess: receipt => Log($"Charged {receipt}"), + onFailure: error => Log(error)); // error est une Error, pleinement diagnostiquable +``` + +### Échappatoires + +Lorsqu’il faut sortir du monde `Outcome` (par exemple à une frontière applicative), deux échappatoires retransforment un échec en levée d’exception : + +* **`ThrowIfFailure()`** — lève l’exception de l’échec (via `error.ToException()`) lorsque l’outcome a échoué ; sinon ne fait rien. +* **`GetResultOrThrow()`** — retourne la valeur portée en cas de succès, ou lève l’exception de l’échec (`Outcome` uniquement). + +```csharp +Outcome outcome = TryCreateAmount(value, currencyCode); + +outcome.ThrowIfFailure(); // lève error.ToException() en cas d’échec +Amount amount = outcome.GetResultOrThrow(); // valeur en cas de succès, sinon lève +``` + +### Composition asynchrone + +Pour les flux asynchrones, `OutcomeTaskExtensions` fournit des surcharges `Then` / `To` / `Recover` / `Finally` +sur `Task` et `Task>`. Chaque surcharge accepte un `CancellationToken` optionnel, +ce qui permet d’attendre l’ensemble du pipeline : + +```csharp +Outcome outcome = + await TryLoadAmountAsync(orderId, cancellationToken) // Task> + .Then(amount => CheckLimitsAsync(amount), cancellationToken) + .To(amount => amount.WithVat()) + .Recover(error => Amount.Zero) + .Then(amount => ChargeAsync(amount), cancellationToken) + .Finally( + onSuccess: receipt => LogAsync(receipt), + onFailure: error => LogAsync(error), + cancellationToken); +``` + ## 🎯 Résumé DiagnosableExceptions brille lorsque : diff --git a/doc/WritingErrorsGuide.en.md b/doc/WritingErrorsGuide.en.md index 256cab21..a2e220ff 100644 --- a/doc/WritingErrorsGuide.en.md +++ b/doc/WritingErrorsGuide.en.md @@ -56,7 +56,7 @@ Good: Avoid: -* “InvalidAmountOperationException” +* “InvalidAmountOperationError” * “Operation failed” ## 📝 4. Writing the **Description** diff --git a/doc/WritingErrorsGuide.fr.md b/doc/WritingErrorsGuide.fr.md index ac6945b3..a145f936 100644 --- a/doc/WritingErrorsGuide.fr.md +++ b/doc/WritingErrorsGuide.fr.md @@ -54,7 +54,7 @@ Bon : À éviter : -* « InvalidAmountOperationException » +* « InvalidAmountOperationError » * « L’opération a échoué » ## 📝 4. Écrire la **Description** @@ -63,17 +63,21 @@ La description explique la signification de l’erreur. Un bon schéma est : +> « Cette erreur survient en essayant de… » + +ou + > « Cette erreur survient lorsque… » +Vous pouvez choisir la formulation qui convient le mieux, mais restez cohérent au sein du projet. La cohérence dans la formulation améliore la lisibilité et rend la documentation plus homogène. + La description doit : * décrire la situation en langage simple * être compréhensible par quelqu’un qui ne connaît pas le code * expliquer *ce qui s’est passé*, pas *comment le système a réagi* -La cohérence dans la formulation améliore la lisibilité globale de la documentation. - -## 📏 5. Écrire la **régle** +## 📏 5. Écrire la **règle** La règle exprime l’invariant ou la contrainte métier.