diff --git a/FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs b/FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs new file mode 100644 index 00000000..48ae40e4 --- /dev/null +++ b/FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs @@ -0,0 +1,107 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.GenDoc.UnitTests; + +/// +/// Proves, from an assembly that has no access to the library's internal members, that the built-in error +/// types can be specialized by a consumer (ADR-0072) and that GenDoc documents such specialized errors like any other. +/// +/// +/// This project is deliberately the vantage point: the library only exposes its internals to +/// FirstClassErrors.UnitTests and FirstClassErrors.Testing, so a fixture that compiles here is a fixture a +/// real consumer can write. +/// +public sealed class CustomErrorTypesTests { + + #region Statics members declarations + + private static ErrorDocumentationExtractionResult Extract() { + return AssemblyErrorDocumentationReader.GetErrorDocumentationFrom(typeof(CustomErrorTypesTests).Assembly); + } + + #endregion + + [Fact(DisplayName = "A consumer assembly can derive a typed error and its exception from the built-in ones, and build it through its own staged factory.")] + public void AConsumerAssemblyCanDeriveATypedError() { + // Exercise + CustomPersistenceError error = CustomRepositoryErrors.Unavailable(); + + // Verify + Check.That(error.Code).IsEqualTo(ErrorCode.Create("CUSTOM_REPOSITORY_UNAVAILABLE")); + Check.That(error.ShortMessage).IsEqualTo("Service temporarily unavailable."); + Check.That(error.Direction).IsEqualTo(InteractionDirection.Outgoing); + Check.That(error.Transience).IsEqualTo(Transience.Transient); + Check.That(error.ToException()).IsInstanceOf(); + } + + [Fact(DisplayName = "GenDoc extracts a documented factory that returns a custom error type, running its examples.")] + public void GenDocExtractsADocumentedFactoryReturningACustomErrorType() { + // Exercise + ErrorDocumentationExtractionResult extraction = Extract(); + + // Verify + ErrorDocumentation? documentation = extraction.Documentation.FirstOrDefault(doc => doc.Code == "CUSTOM_REPOSITORY_UNAVAILABLE"); + + Check.That(documentation).IsNotNull(); + Check.That(documentation!.Title).IsEqualTo("Custom repository unavailable"); + Check.That(documentation.Source).IsEqualTo("CustomRepository"); + Check.That(documentation.Examples).CountIs(1); + } + + [Fact(DisplayName = "Extracting a custom error type records no failure.")] + public void ExtractingACustomErrorTypeRecordsNoFailure() { + // Exercise + ErrorDocumentationExtractionResult extraction = Extract(); + + // Verify + Check.That(extraction.Failures).IsEmpty(); + } + + private sealed class CustomPersistenceError : SecondaryPortError { + + private CustomPersistenceError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, Transience transience) + : base(code, diagnosticMessage, shortMessage, detailedMessage, transience) { } + + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, Transience transience) { + return new PublicMessageStage((shortMessage, detailedMessage) => + new CustomPersistenceError(code, diagnosticMessage, shortMessage, detailedMessage, transience)); + } + + public override DiagnosableException ToException() { + return new CustomPersistenceException(this); + } + + } + + private sealed class CustomPersistenceException : SecondaryPortException { + + public CustomPersistenceException(CustomPersistenceError error) : base(error) { } + + } + + [ProvidesErrorsFor("CustomRepository", Description = "Errors raised by the custom repository fixture.")] + private static class CustomRepositoryErrors { + + [DocumentedBy(nameof(UnavailableDocumentation))] + public static CustomPersistenceError Unavailable() { + return CustomPersistenceError.Create(ErrorCode.Create("CUSTOM_REPOSITORY_UNAVAILABLE"), + "The custom repository is unavailable.", + Transience.Transient) + .WithPublicMessage("Service temporarily unavailable."); + } + + private static ErrorDocumentation UnavailableDocumentation() { + return DescribeError.WithTitle("Custom repository unavailable") + .WithDescription("This error occurs when the custom repository cannot be reached.") + .WithoutRule() + .WithoutDiagnostic() + .WithExamples(Unavailable); + } + + } + +} diff --git a/FirstClassErrors.UnitTests/ExtensibleErrorTypesTests.cs b/FirstClassErrors.UnitTests/ExtensibleErrorTypesTests.cs new file mode 100644 index 00000000..f62a73d7 --- /dev/null +++ b/FirstClassErrors.UnitTests/ExtensibleErrorTypesTests.cs @@ -0,0 +1,157 @@ +#region Usings declarations + +using FirstClassErrors.Testing; + +using NFluent; + +#endregion + +namespace FirstClassErrors.UnitTests; + +/// +/// Exercises custom error types as they would be defined in an application referencing FirstClassErrors. +/// +public sealed class ExtensibleErrorTypesTests { + + [Fact(DisplayName = "Derived domain errors retain their type and diagnostic fields.")] + public void DerivedDomainErrorRetainsItsTypeAndDiagnosticFields() { + // Exercise + ErrorCode code = ErrorCodeFactory.Any(); + CustomDomainError error = CustomDomainError.Create(code, "Internal diagnostic") + .WithPublicMessage("Public summary", "Public details"); + + // Verify + Check.That(error.Code).IsEqualTo(code); + Check.That(error.DiagnosticMessage).IsEqualTo("Internal diagnostic"); + Check.That(error.ShortMessage).IsEqualTo("Public summary"); + Check.That(error.DetailedMessage).IsEqualTo("Public details"); + Check.That(error.ToException() is DomainException).IsTrue(); + } + + [Fact(DisplayName = "Derived infrastructure errors preserve direction and transience.")] + public void DerivedInfrastructureErrorPreservesMetadata() { + // Exercise + CustomInfrastructureError error = CustomInfrastructureError.Create(ErrorCodeFactory.Any(), "Diagnostic") + .WithPublicMessage("Unavailable"); + + // Verify + Check.That(error.Direction).IsEqualTo(InteractionDirection.Outgoing); + Check.That(error.Transience).IsEqualTo(Transience.Transient); + Check.That(error.ToException() is InfrastructureException).IsTrue(); + } + + [Fact(DisplayName = "Derived primary port errors aggregate nested errors without losing direction.")] + public void DerivedPrimaryPortErrorAggregatesInnerErrors() { + // Setup + DomainError inner = ErrorFactory.Domain(ErrorCodeFactory.Any(), "Invalid input"); + PrimaryPortInnerErrors innerErrors = new PrimaryPortInnerErrors().Add(inner); + + // Exercise + CustomPrimaryPortError error = CustomPrimaryPortError.Create(ErrorCodeFactory.Any(), "Diagnostic", innerErrors) + .WithPublicMessage("Invalid request"); + + // Verify + Check.That(error.Direction).IsEqualTo(InteractionDirection.Incoming); + Check.That(error.Transience).IsEqualTo(Transience.Unknown); + Check.That(error.InnerErrors).CountIs(1); + Check.That(error.InnerErrors[0]).IsSameReferenceAs(inner); + Check.That(error.ToException() is PrimaryPortException).IsTrue(); + } + + [Fact(DisplayName = "A repository factory can produce a derived secondary port error.")] + public void RepositoryFactoryProducesTypedPersistenceError() { + // Exercise + PersistenceError error = CommandRepositoryErrors.Unavailable(); + + // Verify + Check.That(error.Code).IsEqualTo(ErrorCode.Create("COMMAND_REPOSITORY_UNAVAILABLE")); + Check.That(error.Direction).IsEqualTo(InteractionDirection.Outgoing); + Check.That(error.Transience).IsEqualTo(Transience.Transient); + Check.That(error.ToException() is SecondaryPortException).IsTrue(); + } + + [Fact(DisplayName = "Derived secondary port errors preserve nested outgoing errors and computed transience.")] + public void DerivedSecondaryPortErrorAggregatesInnerErrors() { + // Setup + SecondaryPortError inner = ErrorFactory.Secondary(ErrorCodeFactory.Any(), "Dependency failed", Transience.NonTransient); + SecondaryPortInnerErrors innerErrors = new SecondaryPortInnerErrors().Add(inner); + + // Exercise + PersistenceError error = PersistenceError.Create(ErrorCodeFactory.Any(), "Repository failed", innerErrors) + .WithPublicMessage("Repository unavailable"); + + // Verify + Check.That(error.Direction).IsEqualTo(InteractionDirection.Outgoing); + Check.That(error.Transience).IsEqualTo(Transience.NonTransient); + Check.That(error.InnerErrors).CountIs(1); + Check.That(error.InnerErrors[0]).IsSameReferenceAs(inner); + } + + private sealed class CustomDomainError : DomainError { + + private CustomDomainError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage) + : base(code, diagnosticMessage, shortMessage, detailedMessage) { } + + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage) { + return new PublicMessageStage((shortMessage, detailedMessage) => + new CustomDomainError(code, diagnosticMessage, shortMessage, detailedMessage)); + } + + } + + private sealed class CustomInfrastructureError : InfrastructureError { + + private CustomInfrastructureError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage) + : base(code, diagnosticMessage, shortMessage, detailedMessage, InteractionDirection.Outgoing, Transience.Transient) { } + + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage) { + return new PublicMessageStage((shortMessage, detailedMessage) => + new CustomInfrastructureError(code, diagnosticMessage, shortMessage, detailedMessage)); + } + + } + + private sealed class CustomPrimaryPortError : PrimaryPortError { + + private CustomPrimaryPortError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, PrimaryPortInnerErrors innerErrors) + : base(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors) { } + + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, PrimaryPortInnerErrors innerErrors) { + return new PublicMessageStage((shortMessage, detailedMessage) => + new CustomPrimaryPortError(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors)); + } + + } + + private sealed class PersistenceError : SecondaryPortError { + + private PersistenceError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, Transience transience) + : base(code, diagnosticMessage, shortMessage, detailedMessage, transience) { } + + private PersistenceError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, SecondaryPortInnerErrors innerErrors) + : base(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors) { } + + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, Transience transience) { + return new PublicMessageStage((shortMessage, detailedMessage) => + new PersistenceError(code, diagnosticMessage, shortMessage, detailedMessage, transience)); + } + + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, SecondaryPortInnerErrors innerErrors) { + return new PublicMessageStage((shortMessage, detailedMessage) => + new PersistenceError(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors)); + } + + } + + private static class CommandRepositoryErrors { + + public static PersistenceError Unavailable() { + return PersistenceError.Create(ErrorCode.Create("COMMAND_REPOSITORY_UNAVAILABLE"), + "The command repository is unavailable.", + Transience.Transient) + .WithPublicMessage("Service temporarily unavailable."); + } + + } + +} diff --git a/FirstClassErrors/DomainError.cs b/FirstClassErrors/DomainError.cs index e958b501..c019efb5 100644 --- a/FirstClassErrors/DomainError.cs +++ b/FirstClassErrors/DomainError.cs @@ -14,13 +14,22 @@ public class DomainError : Error { #region Constructors declarations - internal DomainError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, Action? configureContext = null) + /// + /// Initializes a or a derived error type. + /// + protected internal DomainError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, Action? configureContext = null) : base(code, diagnosticMessage, shortMessage, detailedMessage, configureContext) { } - internal DomainError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, DomainError innerError, Action? configureContext = null) + /// + /// Initializes a or a derived error type. + /// + protected internal DomainError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, DomainError innerError, Action? configureContext = null) : base(code, diagnosticMessage, shortMessage, detailedMessage, innerError, configureContext) { } - internal DomainError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, IEnumerable innerErrors, Action? configureContext = null) + /// + /// Initializes a or a derived error type. + /// + protected internal DomainError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, IEnumerable innerErrors, Action? configureContext = null) : base(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors, configureContext) { } #endregion diff --git a/FirstClassErrors/InfrastructureError.cs b/FirstClassErrors/InfrastructureError.cs index cbb21fb9..9db53610 100644 --- a/FirstClassErrors/InfrastructureError.cs +++ b/FirstClassErrors/InfrastructureError.cs @@ -20,19 +20,28 @@ public class InfrastructureError : Error { #region Constructors declarations - internal InfrastructureError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, InteractionDirection direction, Transience transience, Action? configureContext = null) + /// + /// Initializes a or a derived error type. + /// + protected internal InfrastructureError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, InteractionDirection direction, Transience transience, Action? configureContext = null) : base(code, diagnosticMessage, shortMessage, detailedMessage, configureContext) { Direction = direction; Transience = transience; } - internal InfrastructureError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, InteractionDirection direction, Transience transience, Error innerError, Action? configureContext = null) + /// + /// Initializes a or a derived error type. + /// + protected internal InfrastructureError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, InteractionDirection direction, Transience transience, Error innerError, Action? configureContext = null) : base(code, diagnosticMessage, shortMessage, detailedMessage, innerError, configureContext) { Direction = direction; Transience = transience; } - internal InfrastructureError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, InteractionDirection direction, Transience transience, IEnumerable innerErrors, Action? configureContext = null) + /// + /// Initializes a or a derived error type. + /// + protected internal InfrastructureError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, InteractionDirection direction, Transience transience, IEnumerable innerErrors, Action? configureContext = null) : base(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors, configureContext) { Direction = direction; Transience = transience; diff --git a/FirstClassErrors/PrimaryPortError.cs b/FirstClassErrors/PrimaryPortError.cs index 72ad76ee..78a09307 100644 --- a/FirstClassErrors/PrimaryPortError.cs +++ b/FirstClassErrors/PrimaryPortError.cs @@ -15,10 +15,16 @@ public class PrimaryPortError : InfrastructureError { #region Constructors declarations - internal PrimaryPortError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, Transience transience, Action? configureContext = null) + /// + /// Initializes a or a derived error type. + /// + protected internal PrimaryPortError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, Transience transience, Action? configureContext = null) : base(code, diagnosticMessage, shortMessage, detailedMessage, InteractionDirection.Incoming, transience, configureContext) { } - internal PrimaryPortError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, PrimaryPortInnerErrors innerErrors, Action? configureContext = null) + /// + /// Initializes a or a derived error type. + /// + protected internal PrimaryPortError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, PrimaryPortInnerErrors innerErrors, Action? configureContext = null) : base(code, diagnosticMessage, shortMessage, detailedMessage, InteractionDirection.Incoming, innerErrors.ComputeTransience(), innerErrors.ToList(), configureContext) { } #endregion diff --git a/FirstClassErrors/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/FirstClassErrors/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index b90e5f80..d18900a0 100644 --- a/FirstClassErrors/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/FirstClassErrors/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -13,6 +13,9 @@ FirstClassErrors.DocumentedByAttribute FirstClassErrors.DocumentedByAttribute.DocumentedByAttribute(string! documentationMethodName) -> void FirstClassErrors.DocumentedByAttribute.MethodName.get -> string! FirstClassErrors.DomainError +FirstClassErrors.DomainError.DomainError(FirstClassErrors.ErrorCode! code, string! diagnosticMessage, string! shortMessage, string? detailedMessage, FirstClassErrors.DomainError! innerError, System.Action? configureContext = null) -> void +FirstClassErrors.DomainError.DomainError(FirstClassErrors.ErrorCode! code, string! diagnosticMessage, string! shortMessage, string? detailedMessage, System.Action? configureContext = null) -> void +FirstClassErrors.DomainError.DomainError(FirstClassErrors.ErrorCode! code, string! diagnosticMessage, string! shortMessage, string? detailedMessage, System.Collections.Generic.IEnumerable! innerErrors, System.Action? configureContext = null) -> void FirstClassErrors.DomainException FirstClassErrors.DomainException.DomainException(FirstClassErrors.DomainError! error) -> void FirstClassErrors.Error @@ -141,6 +144,9 @@ FirstClassErrors.IErrorRuleStage.WithRule(string! rule) -> FirstClassErrors.IErr FirstClassErrors.IErrorTitleStage FirstClassErrors.IErrorTitleStage.WithTitle(string! title) -> FirstClassErrors.IErrorDescriptionStage! FirstClassErrors.InfrastructureError +FirstClassErrors.InfrastructureError.InfrastructureError(FirstClassErrors.ErrorCode! code, string! diagnosticMessage, string! shortMessage, string? detailedMessage, FirstClassErrors.InteractionDirection direction, FirstClassErrors.Transience transience, FirstClassErrors.Error! innerError, System.Action? configureContext = null) -> void +FirstClassErrors.InfrastructureError.InfrastructureError(FirstClassErrors.ErrorCode! code, string! diagnosticMessage, string! shortMessage, string? detailedMessage, FirstClassErrors.InteractionDirection direction, FirstClassErrors.Transience transience, System.Action? configureContext = null) -> void +FirstClassErrors.InfrastructureError.InfrastructureError(FirstClassErrors.ErrorCode! code, string! diagnosticMessage, string! shortMessage, string? detailedMessage, FirstClassErrors.InteractionDirection direction, FirstClassErrors.Transience transience, System.Collections.Generic.IEnumerable! innerErrors, System.Action? configureContext = null) -> void FirstClassErrors.InfrastructureError.Direction.get -> FirstClassErrors.InteractionDirection FirstClassErrors.InfrastructureError.Transience.get -> FirstClassErrors.Transience FirstClassErrors.InfrastructureException @@ -186,6 +192,8 @@ FirstClassErrors.Outcome.Then(System.Func! convert) -> F FirstClassErrors.Outcome.ThrowIfFailure() -> void FirstClassErrors.OutcomeTaskExtensions FirstClassErrors.PrimaryPortError +FirstClassErrors.PrimaryPortError.PrimaryPortError(FirstClassErrors.ErrorCode! code, string! diagnosticMessage, string! shortMessage, string? detailedMessage, FirstClassErrors.PrimaryPortInnerErrors! innerErrors, System.Action? configureContext = null) -> void +FirstClassErrors.PrimaryPortError.PrimaryPortError(FirstClassErrors.ErrorCode! code, string! diagnosticMessage, string! shortMessage, string? detailedMessage, FirstClassErrors.Transience transience, System.Action? configureContext = null) -> void FirstClassErrors.PrimaryPortException FirstClassErrors.PrimaryPortException.PrimaryPortException(FirstClassErrors.PrimaryPortError! error) -> void FirstClassErrors.PrimaryPortInnerErrors @@ -201,8 +209,11 @@ FirstClassErrors.ProvidesErrorsForAttribute.DescriptionResourceType.set -> void FirstClassErrors.ProvidesErrorsForAttribute.ProvidesErrorsForAttribute(string! source) -> void FirstClassErrors.ProvidesErrorsForAttribute.Source.get -> string! FirstClassErrors.PublicMessageStage +FirstClassErrors.PublicMessageStage.PublicMessageStage(System.Func! finalize) -> void FirstClassErrors.PublicMessageStage.WithPublicMessage(string! shortMessage, string? detailedMessage = null) -> TError! FirstClassErrors.SecondaryPortError +FirstClassErrors.SecondaryPortError.SecondaryPortError(FirstClassErrors.ErrorCode! code, string! diagnosticMessage, string! shortMessage, string? detailedMessage, FirstClassErrors.SecondaryPortInnerErrors! innerErrors, System.Action? configureContext = null) -> void +FirstClassErrors.SecondaryPortError.SecondaryPortError(FirstClassErrors.ErrorCode! code, string! diagnosticMessage, string! shortMessage, string? detailedMessage, FirstClassErrors.Transience transience, System.Action? configureContext = null) -> void FirstClassErrors.SecondaryPortException FirstClassErrors.SecondaryPortException.SecondaryPortException(FirstClassErrors.SecondaryPortError! error) -> void FirstClassErrors.SecondaryPortInnerErrors diff --git a/FirstClassErrors/PublicMessageStage.cs b/FirstClassErrors/PublicMessageStage.cs index 0a7c6970..5a6b3ae2 100644 --- a/FirstClassErrors/PublicMessageStage.cs +++ b/FirstClassErrors/PublicMessageStage.cs @@ -28,7 +28,15 @@ public sealed class PublicMessageStage #region Constructors declarations - internal PublicMessageStage(Func finalize) { + /// + /// Initializes a staged builder for a custom error type. + /// + /// Creates the specialized error once the public messages are supplied. + /// + /// Derived error types can expose their own Create factory using this constructor, keeping the + /// two-stage public-message contract of the built-in error types. + /// + public PublicMessageStage(Func finalize) { _finalize = finalize; } diff --git a/FirstClassErrors/SecondaryPortError.cs b/FirstClassErrors/SecondaryPortError.cs index bda85c3f..86d3e0aa 100644 --- a/FirstClassErrors/SecondaryPortError.cs +++ b/FirstClassErrors/SecondaryPortError.cs @@ -15,10 +15,16 @@ public class SecondaryPortError : InfrastructureError { #region Constructors declarations - internal SecondaryPortError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, Transience transience, Action? configureContext = null) + /// + /// Initializes a or a derived error type. + /// + protected internal SecondaryPortError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, Transience transience, Action? configureContext = null) : base(code, diagnosticMessage, shortMessage, detailedMessage, InteractionDirection.Outgoing, transience, configureContext) { } - internal SecondaryPortError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, SecondaryPortInnerErrors innerErrors, Action? configureContext = null) + /// + /// Initializes a or a derived error type. + /// + protected internal SecondaryPortError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, SecondaryPortInnerErrors innerErrors, Action? configureContext = null) : base(code, diagnosticMessage, shortMessage, detailedMessage, InteractionDirection.Outgoing, innerErrors.ComputeTransience(), innerErrors.ToList(), configureContext) { } #endregion diff --git a/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.fr.md b/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.fr.md new file mode 100644 index 00000000..94a506a6 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.fr.md @@ -0,0 +1,64 @@ +# ADR-0072 | Autoriser les sous-types d'erreur personnalisés + +🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](0072-allow-custom-error-subtypes.md) + +**Statut :** Accepté +**Proposé :** 2026-10-22 +**Accepté :** 2026-10-22 +**Décideurs :** Reefact + +## Contexte + +FirstClassErrors fournit une hiérarchie typée d'erreurs de domaine et d'infrastructure, avec les ports primaires et secondaires. +Les équipes de socle peuvent définir des catégories d'erreurs réutilisables, par exemple les erreurs de persistance, pour harmoniser le support et le diagnostic. + +Les classes existantes disposent de méthodes de fabrique en deux étapes, mais leurs constructeurs ne sont pas accessibles aux classes dérivées définies dans d’autres projets .NET. +Une catégorie personnalisée ne peut donc pas étendre cette taxonomie sans remplacer ou dupliquer son fonctionnement. +Les consommateurs ne peuvent pas non plus créer une méthode de fabrique en deux étapes renvoyant un type spécialisé. + +## Décision + +FirstClassErrors permettra aux applications de dériver des catégories d'erreurs spécialisées de chaque type concret intégré, tout en conservant le modèle de construction à étapes des messages publics. + +## Justification + +Une hiérarchie extensible permet aux bibliothèques de socle de proposer des catégories utiles au support sans créer un type par code d'erreur. +Le maintien dans la hiérarchie existante préserve les métadonnées héritées et la place des erreurs dans la taxonomie. Les types dérivés de la famille infrastructure conservent aussi leur direction d’interaction et leur caractère transitoire ou non. +Les méthodes de fabrique en deux étapes conservent une façon uniforme de fournir les messages publics. + +## Alternatives étudiées + +### Garder la hiérarchie fermée + +Cela limiterait l'API publique, mais empêcherait les équipes de socle de définir des familles d'erreurs par héritage. + +### Utiliser exclusivement les codes d'erreur + +Les codes identifient bien les situations particulières, mais ne permettent pas de regrouper les erreurs dans des catégories de types transverses. + +## Conséquences + +### Positives + +* Les consommateurs peuvent ajouter des catégories sémantiques sans modifier FirstClassErrors. +* Les méthodes de fabrique et les codes d’erreur existants restent utilisables. +* Les types personnalisés bénéficient du même modèle de messages publics à deux étapes. + +### Négatives + +* L'API publique expose de nouveaux points d'extension et de nouvelles obligations de compatibilité. +* Les types dérivés doivent fournir leurs propres méthodes de fabrique. + +### Risques + +* Les classes dérivées peuvent s'écarter des conventions de construction ; les exemples et les tests doivent promouvoir les méthodes de fabrique en deux étapes. +* Une conversion en exception plus spécialisée nécessite une adaptation explicite. + +## Actions de suivi + +* Aucune en attente. Le modèle d'extension est documenté dans le [guide de la taxonomie des erreurs](../../for-users/ErrorTaxonomy.fr.md), et l'héritage côté consommateur ainsi que l'extraction GenDoc sont couverts par `CustomErrorTypesTests` dans `FirstClassErrors.GenDoc.UnitTests`, un assembly sans accès aux membres internes de la bibliothèque. + +## Références + +* [Hiérarchie des erreurs](../../../../FirstClassErrors/Error.cs) +* [Messages publics à étapes](../../../../FirstClassErrors/PublicMessageStage.cs) diff --git a/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.md b/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.md new file mode 100644 index 00000000..8ef35e9d --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.md @@ -0,0 +1,64 @@ +# ADR-0072 | Allow custom error subtypes + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0072-allow-custom-error-subtypes.fr.md) + +**Status:** Accepted +**Proposed:** 2026-10-22 +**Accepted:** 2026-10-22 +**Decision Makers:** Reefact + +## Context + +FirstClassErrors provides a typed hierarchy for domain and infrastructure errors, including primary and secondary ports. +Application platform teams can define more specific, reusable categories of errors, such as persistence failures, for both operational support and consistent diagnostics. + +The existing categories can be instantiated through staged factories, but their constructors are inaccessible to subclasses in consuming assemblies. +Consequently, a custom category cannot extend the established taxonomy without replacing it or duplicating its behavior. +Consumers also cannot create a staged factory for a specialized error type. + +## Decision + +FirstClassErrors will allow applications to derive specialized error categories from each concrete built-in error type while retaining the staged public-message construction model. + +## Rationale + +An extensible type hierarchy allows shared platform libraries to introduce meaningful operational categories without requiring a concrete class for every individual error code. +Keeping specialized errors within the existing hierarchy preserves their inherited metadata and their place in the taxonomy. Infrastructure-derived types also retain their interaction direction and transience classification. +Preserving staged construction provides a consistent way to supply public messages and minimizes divergence between built-in and user-defined error factories. + +## Alternatives Considered + +### Keep the built-in hierarchy closed to extension + +This preserves a smaller public surface but prevents platform teams from defining common error families with normal type relationships. + +### Model every specialization through an error code only + +Codes identify individual failure cases well, but they do not provide reusable type categories for cross-cutting support and telemetry conventions. + +## Consequences + +### Positive + +* Consumers can add semantic categories without changing FirstClassErrors. +* Existing factories and error codes remain valid. +* Custom types can use the same two-stage message contract. + +### Negative + +* The public API now exposes subclass construction points, increasing compatibility obligations. +* Custom types must maintain their own factory methods. + +### Risks + +* Custom subclasses may violate intended construction conventions; examples and tests should emphasize the two-stage factory pattern. +* Derived types may require explicit exception conversion behavior when a more specific exception is desired. + +## Follow-up Actions + +* None outstanding. The extension pattern is documented in the [Error Taxonomy guide](../../for-users/ErrorTaxonomy.en.md), and consumer-style inheritance and GenDoc extraction are covered by `CustomErrorTypesTests` in `FirstClassErrors.GenDoc.UnitTests`, an assembly without access to the library's internals. + +## References + +* [Built-in error hierarchy](../../../../FirstClassErrors/Error.cs) +* [Staged public messages](../../../../FirstClassErrors/PublicMessageStage.cs) diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 7d0d1138..31109292 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -239,3 +239,4 @@ Optional supporting material: | [ADR-0069](0069-consume-justdummies-from-its-own-repository.md) | Consume JustDummies from its own repository | Accepted | | [ADR-0070](0070-land-pull-requests-by-rebasing-and-keep-main-linear.md) | Land pull requests by rebasing, and keep `main`'s history linear | Accepted | | [ADR-0071](0071-refresh-an-open-branch-only-by-rebasing-it.md) | Refresh an open branch only by rebasing it onto `main` | Accepted | +| [ADR-0072](0072-allow-custom-error-subtypes.md) | Allow custom error subtypes | Accepted | diff --git a/doc/handwritten/for-users/ErrorTaxonomy.en.md b/doc/handwritten/for-users/ErrorTaxonomy.en.md index 8ac451fb..f61c40d2 100644 --- a/doc/handwritten/for-users/ErrorTaxonomy.en.md +++ b/doc/handwritten/for-users/ErrorTaxonomy.en.md @@ -156,6 +156,31 @@ Ask these questions in order: Do not choose a type from the current class or folder. Choose it from the meaning of the failure. +## Specializing a built-in type + +`DomainError`, `PrimaryPortError`, `SecondaryPortError` and `InfrastructureError` can be used as base classes, so a shared platform library can define a reusable category (a persistence failure, for example) without a class per error code. A derived class keeps the properties of its base class and its place in the hierarchy; the interaction direction and the transience belong to the infrastructure family (`InfrastructureError` and its descendants), not to `DomainError`. + +```csharp +public sealed class PersistenceError : SecondaryPortError { + + private PersistenceError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, Transience transience) + : base(code, diagnosticMessage, shortMessage, detailedMessage, transience) { } + + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, Transience transience) { + return new PublicMessageStage((shortMessage, detailedMessage) => + new PersistenceError(code, diagnosticMessage, shortMessage, detailedMessage, transience)); + } + +} +``` + +The type exposes its own `Create` factory built on `PublicMessageStage`, which keeps the two-stage contract of the built-in types: the public message is always supplied before the error exists. Documented factories that return a specialized type are extracted by GenDoc like any other. + +Two things to keep in mind: + +* By default, `ToException()` returns the exception of the built-in parent (`SecondaryPortException` here). A derived type can override it to return a specialized exception, itself derived from that exception. +* A specialized type is a classification, not an identity: the error code still identifies the failure. + ---
diff --git a/doc/handwritten/for-users/ErrorTaxonomy.fr.md b/doc/handwritten/for-users/ErrorTaxonomy.fr.md index 82d1b422..aecdfb95 100644 --- a/doc/handwritten/for-users/ErrorTaxonomy.fr.md +++ b/doc/handwritten/for-users/ErrorTaxonomy.fr.md @@ -156,6 +156,31 @@ Posez ces questions dans l’ordre : Ne choisissez pas un type selon la classe ou le dossier actuel. Choisissez-le selon le sens de l’échec. +## Spécialiser un type intégré + +Les classes `DomainError`, `PrimaryPortError`, `SecondaryPortError` et `InfrastructureError` peuvent servir de classes de base, afin qu’une bibliothèque de socle partagée définisse une catégorie réutilisable (une défaillance de persistance, par exemple) sans créer une classe par code d’erreur. Chaque classe dérivée conserve les propriétés de sa classe de base et sa place dans la hiérarchie ; la direction de l’interaction et le caractère transitoire ou non de l’erreur sont propres à la famille infrastructure (`InfrastructureError` et ses descendants), pas à `DomainError`. + +```csharp +public sealed class PersistenceError : SecondaryPortError { + + private PersistenceError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, Transience transience) + : base(code, diagnosticMessage, shortMessage, detailedMessage, transience) { } + + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, Transience transience) { + return new PublicMessageStage((shortMessage, detailedMessage) => + new PersistenceError(code, diagnosticMessage, shortMessage, detailedMessage, transience)); + } + +} +``` + +Le type expose sa propre fabrique `Create`, construite sur `PublicMessageStage`, ce qui conserve le contrat en deux étapes des types intégrés : le message public est toujours fourni avant que l’erreur n’existe. Les fabriques documentées qui renvoient un type spécialisé sont extraites par GenDoc comme les autres. + +Deux points à garder en tête : + +* Par défaut, `ToException()` renvoie l’exception du parent intégré (`SecondaryPortException` ici). Un type dérivé peut la redéfinir pour renvoyer une exception spécialisée, elle-même dérivée de cette exception. +* Un type spécialisé est une classification, pas une identité : c’est toujours le code d’erreur qui identifie l’échec. + ---