From fa887e9f36106609303d4faf85c7ff5479d3f7b8 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Thu, 22 Oct 2026 12:29:49 +0200 Subject: [PATCH 1/9] feat(core): allow custom subtypes of built-in errors --- .../ExtensibleErrorTypesTests.cs | 157 ++++++++++++++++++ FirstClassErrors/DomainError.cs | 15 +- FirstClassErrors/InfrastructureError.cs | 15 +- FirstClassErrors/PrimaryPortError.cs | 10 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 11 ++ FirstClassErrors/PublicMessageStage.cs | 10 +- FirstClassErrors/SecondaryPortError.cs | 10 +- .../0072-allow-custom-error-subtypes.fr.md | 63 +++++++ .../adr/0072-allow-custom-error-subtypes.md | 63 +++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 10 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 FirstClassErrors.UnitTests/ExtensibleErrorTypesTests.cs create mode 100644 doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.md diff --git a/FirstClassErrors.UnitTests/ExtensibleErrorTypesTests.cs b/FirstClassErrors.UnitTests/ExtensibleErrorTypesTests.cs new file mode 100644 index 00000000..d0156651 --- /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 new 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 new 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 new 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 new static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, Transience transience) { + return new PublicMessageStage((shortMessage, detailedMessage) => + new PersistenceError(code, diagnosticMessage, shortMessage, detailedMessage, transience)); + } + + public new 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..5cb98b74 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.fr.md @@ -0,0 +1,63 @@ +# ADR-0072 | Autoriser les sous-types d'erreur personnalisés + +🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](0072-allow-custom-error-subtypes.md) + +**Status:** Proposed +**Proposed:** 2026-10-22 +**Decision Makers:** 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 factories à étapes, mais leurs constructeurs ne sont pas accessibles aux sous-classes d'autres assemblies. +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 factory à é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 la direction architecturale, la classification de transience et les diagnostics. +Les factories à é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 factories et codes 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 factories. + +### Risques + +* Les sous-classes peuvent s'écarter des conventions de construction ; les exemples et les tests doivent promouvoir les factories à étapes. +* Une conversion en exception plus spécialisée nécessite une adaptation explicite. + +## Actions de suivi + +* Documenter le modèle d'extension et exécuter les tests d'héritage côté consommateur sur les frameworks pris en charge. + +## 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..c9d5abe8 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.md @@ -0,0 +1,63 @@ +# ADR-0072 | Allow custom error subtypes + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0072-allow-custom-error-subtypes.fr.md) + +**Status:** Proposed +**Proposed:** 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 architectural direction, transience classification, and diagnosable metadata. +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 + +* Document the extension pattern and run the consumer-style inheritance tests across supported target frameworks. + +## 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..aa6db604 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 | Proposed | From 262c6087b7b034a78f8e2c3f92c4fcf35530cb22 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:53:14 +0000 Subject: [PATCH 2/9] test(core): drop invalid new modifier on custom error factories CS0109: the Create factories of the test-local error types hide no accessible base member, so the new modifier fails the build. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DGavnQsfXNak5Xj3UWNbAd --- .../ExtensibleErrorTypesTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/FirstClassErrors.UnitTests/ExtensibleErrorTypesTests.cs b/FirstClassErrors.UnitTests/ExtensibleErrorTypesTests.cs index d0156651..f62a73d7 100644 --- a/FirstClassErrors.UnitTests/ExtensibleErrorTypesTests.cs +++ b/FirstClassErrors.UnitTests/ExtensibleErrorTypesTests.cs @@ -92,7 +92,7 @@ private sealed class CustomDomainError : DomainError { private CustomDomainError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage) : base(code, diagnosticMessage, shortMessage, detailedMessage) { } - public new static PublicMessageStage Create(ErrorCode code, string diagnosticMessage) { + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage) { return new PublicMessageStage((shortMessage, detailedMessage) => new CustomDomainError(code, diagnosticMessage, shortMessage, detailedMessage)); } @@ -104,7 +104,7 @@ 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 new static PublicMessageStage Create(ErrorCode code, string diagnosticMessage) { + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage) { return new PublicMessageStage((shortMessage, detailedMessage) => new CustomInfrastructureError(code, diagnosticMessage, shortMessage, detailedMessage)); } @@ -116,7 +116,7 @@ private sealed class CustomPrimaryPortError : PrimaryPortError { private CustomPrimaryPortError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, PrimaryPortInnerErrors innerErrors) : base(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors) { } - public new static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, PrimaryPortInnerErrors innerErrors) { + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, PrimaryPortInnerErrors innerErrors) { return new PublicMessageStage((shortMessage, detailedMessage) => new CustomPrimaryPortError(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors)); } @@ -131,12 +131,12 @@ private PersistenceError(ErrorCode code, string diagnosticMessage, string shortM private PersistenceError(ErrorCode code, string diagnosticMessage, string shortMessage, string? detailedMessage, SecondaryPortInnerErrors innerErrors) : base(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors) { } - public new static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, Transience transience) { + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, Transience transience) { return new PublicMessageStage((shortMessage, detailedMessage) => new PersistenceError(code, diagnosticMessage, shortMessage, detailedMessage, transience)); } - public new static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, SecondaryPortInnerErrors innerErrors) { + public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, SecondaryPortInnerErrors innerErrors) { return new PublicMessageStage((shortMessage, detailedMessage) => new PersistenceError(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors)); } From e3c1f3082eafa21497caaef6412ec84e2b339610 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 12:13:37 +0000 Subject: [PATCH 3/9] test(core): cover custom error subtypes from a consumer assembly FirstClassErrors.GenDoc.UnitTests has no access to the library's internals, so deriving a SecondaryPortError there proves the protected constructors and the public PublicMessageStage work for a real consumer. It also checks that GenDoc extracts a documented factory returning such a type and runs its examples. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DGavnQsfXNak5Xj3UWNbAd --- .../CustomErrorTypesTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs diff --git a/FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs b/FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs new file mode 100644 index 00000000..206c237f --- /dev/null +++ b/FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs @@ -0,0 +1,97 @@ +#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 from a built-in error 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)); + } + + } + + [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); + } + + } + +} From 266760c0fec8edca544455268976b28feac90c04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 12:13:37 +0000 Subject: [PATCH 4/9] docs: describe specializing the built-in error types Add a section to the error taxonomy guide, in English and French, showing a derived SecondaryPortError with its own staged Create factory. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DGavnQsfXNak5Xj3UWNbAd --- doc/handwritten/for-users/ErrorTaxonomy.en.md | 25 +++++++++++++++++++ doc/handwritten/for-users/ErrorTaxonomy.fr.md | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/doc/handwritten/for-users/ErrorTaxonomy.en.md b/doc/handwritten/for-users/ErrorTaxonomy.en.md index 8ac451fb..046ac448 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 derived from, so a shared platform library can define a reusable category (a persistence failure, for example) without a class per error code. A specialized error keeps its direction, its transience and its place in the hierarchy. + +```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: + +* `ToException()` still returns the exception of the built-in parent (`SecondaryPortException` here), never an exception specific to your type. +* 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..e3bfa59f 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é + +`DomainError`, `PrimaryPortError`, `SecondaryPortError` et `InfrastructureError` peuvent être dérivés, 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. Une erreur spécialisée conserve sa direction, sa transience et sa place dans la hiérarchie. + +```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 : + +* `ToException()` renvoie toujours l’exception du parent intégré (`SecondaryPortException` ici), jamais une exception propre à votre type. +* Un type spécialisé est une classification, pas une identité : c’est toujours le code d’erreur qui identifie l’échec. + ---
From 7821b51d3d474a22d2d73e29c528df63a440d0da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 12:15:08 +0000 Subject: [PATCH 5/9] docs: accept ADR-0072 and close its follow-up actions Accepted by the maintainer. The extension pattern is documented and the consumer-side tests exist, so the follow-up list is empty. The French header now uses the translated labels like the rest of the ADR base. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DGavnQsfXNak5Xj3UWNbAd --- .../adr/0072-allow-custom-error-subtypes.fr.md | 9 +++++---- .../adr/0072-allow-custom-error-subtypes.md | 5 +++-- doc/handwritten/for-maintainers/adr/README.md | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) 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 index 5cb98b74..66bf16f6 100644 --- 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 @@ -2,9 +2,10 @@ 🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](0072-allow-custom-error-subtypes.md) -**Status:** Proposed -**Proposed:** 2026-10-22 -**Decision Makers:** Reefact +**Statut :** Accepté +**Proposé :** 2026-10-22 +**Accepté :** 2026-10-22 +**Décideurs :** Reefact ## Contexte @@ -55,7 +56,7 @@ Les codes identifient bien les situations particulières, mais ne permettent pas ## Actions de suivi -* Documenter le modèle d'extension et exécuter les tests d'héritage côté consommateur sur les frameworks pris en charge. +* 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 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 index c9d5abe8..ffde3a29 100644 --- a/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.md +++ b/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.md @@ -2,8 +2,9 @@ 🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0072-allow-custom-error-subtypes.fr.md) -**Status:** Proposed +**Status:** Accepted **Proposed:** 2026-10-22 +**Accepted:** 2026-10-22 **Decision Makers:** Reefact ## Context @@ -55,7 +56,7 @@ Codes identify individual failure cases well, but they do not provide reusable t ## Follow-up Actions -* Document the extension pattern and run the consumer-style inheritance tests across supported target frameworks. +* 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 diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index aa6db604..31109292 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -239,4 +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 | Proposed | +| [ADR-0072](0072-allow-custom-error-subtypes.md) | Allow custom error subtypes | Accepted | From e4fda6f4cacd51d07ffde196d9904ce7312dc8aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 12:16:11 +0000 Subject: [PATCH 6/9] docs: fix the French wording of ADR-0072 Replace literal anglicisms (factories, sous-classes, transience) with the vocabulary the French documentation already uses. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DGavnQsfXNak5Xj3UWNbAd --- .../adr/0072-allow-custom-error-subtypes.fr.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 index 66bf16f6..3d10b66c 100644 --- 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 @@ -12,9 +12,9 @@ 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 factories à étapes, mais leurs constructeurs ne sont pas accessibles aux sous-classes d'autres assemblies. +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 factory à étapes renvoyant un type spécialisé. +Les consommateurs ne peuvent pas non plus créer une méthode de fabrique en deux étapes renvoyant un type spécialisé. ## Décision @@ -23,8 +23,8 @@ FirstClassErrors permettra aux applications de dériver des catégories d'erreur ## 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 la direction architecturale, la classification de transience et les diagnostics. -Les factories à étapes conservent une façon uniforme de fournir les messages publics. +Le maintien dans la hiérarchie existante préserve la direction architecturale, le caractère transitoire ou non de l’erreur et les diagnostics. +Les méthodes de fabrique en deux étapes conservent une façon uniforme de fournir les messages publics. ## Alternatives étudiées @@ -41,17 +41,17 @@ Les codes identifient bien les situations particulières, mais ne permettent pas ### Positives * Les consommateurs peuvent ajouter des catégories sémantiques sans modifier FirstClassErrors. -* Les factories et codes existants restent utilisables. +* 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 factories. +* Les types dérivés doivent fournir leurs propres méthodes de fabrique. ### Risques -* Les sous-classes peuvent s'écarter des conventions de construction ; les exemples et les tests doivent promouvoir les factories à étapes. +* 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 From f2a346b12bbea3dbdf512a5146c27ad5c0d3f18e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 12:19:33 +0000 Subject: [PATCH 7/9] test(core): cover a specialized exception on a custom error A derived error overrides ToException to return an exception derived from SecondaryPortException, from an assembly without access to the internals. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DGavnQsfXNak5Xj3UWNbAd --- .../CustomErrorTypesTests.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs b/FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs index 206c237f..48ae40e4 100644 --- a/FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs +++ b/FirstClassErrors.GenDoc.UnitTests/CustomErrorTypesTests.cs @@ -25,7 +25,7 @@ private static ErrorDocumentationExtractionResult Extract() { #endregion - [Fact(DisplayName = "A consumer assembly can derive a typed error from a built-in error and build it through its own staged factory.")] + [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(); @@ -35,7 +35,7 @@ public void AConsumerAssemblyCanDeriveATypedError() { 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(); + Check.That(error.ToException()).IsInstanceOf(); } [Fact(DisplayName = "GenDoc extracts a documented factory that returns a custom error type, running its examples.")] @@ -71,6 +71,16 @@ public static PublicMessageStage Create(ErrorCode code, 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.")] From 078f2a5876422d2c158600d970a4f34b4c91f499 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 12:19:34 +0000 Subject: [PATCH 8/9] docs: correct the extension guide on metadata and ToException Direction and transience belong to the infrastructure family only, and a derived type may override ToException to return a specialized exception. Also replace the literal anglicisms in the French guide. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DGavnQsfXNak5Xj3UWNbAd --- doc/handwritten/for-users/ErrorTaxonomy.en.md | 4 ++-- doc/handwritten/for-users/ErrorTaxonomy.fr.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/handwritten/for-users/ErrorTaxonomy.en.md b/doc/handwritten/for-users/ErrorTaxonomy.en.md index 046ac448..f61c40d2 100644 --- a/doc/handwritten/for-users/ErrorTaxonomy.en.md +++ b/doc/handwritten/for-users/ErrorTaxonomy.en.md @@ -158,7 +158,7 @@ Do not choose a type from the current class or folder. Choose it from the meanin ## Specializing a built-in type -`DomainError`, `PrimaryPortError`, `SecondaryPortError` and `InfrastructureError` can be derived from, so a shared platform library can define a reusable category (a persistence failure, for example) without a class per error code. A specialized error keeps its direction, its transience and its place in the hierarchy. +`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 { @@ -178,7 +178,7 @@ The type exposes its own `Create` factory built on `PublicMessageStage`, Two things to keep in mind: -* `ToException()` still returns the exception of the built-in parent (`SecondaryPortException` here), never an exception specific to your type. +* 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 e3bfa59f..aecdfb95 100644 --- a/doc/handwritten/for-users/ErrorTaxonomy.fr.md +++ b/doc/handwritten/for-users/ErrorTaxonomy.fr.md @@ -158,7 +158,7 @@ Ne choisissez pas un type selon la classe ou le dossier actuel. Choisissez-le se ## Spécialiser un type intégré -`DomainError`, `PrimaryPortError`, `SecondaryPortError` et `InfrastructureError` peuvent être dérivés, 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. Une erreur spécialisée conserve sa direction, sa transience et sa place dans la hiérarchie. +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 { @@ -178,7 +178,7 @@ Le type expose sa propre fabrique `Create`, construite sur `PublicMessageStage Date: Thu, 22 Oct 2026 12:23:26 +0000 Subject: [PATCH 9/9] docs: scope the ADR-0072 rationale to infrastructure-derived types DomainError has neither a direction nor a transience, so the rationale no longer claims them for every specialized error. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DGavnQsfXNak5Xj3UWNbAd --- .../for-maintainers/adr/0072-allow-custom-error-subtypes.fr.md | 2 +- .../for-maintainers/adr/0072-allow-custom-error-subtypes.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 3d10b66c..94a506a6 100644 --- 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 @@ -23,7 +23,7 @@ FirstClassErrors permettra aux applications de dériver des catégories d'erreur ## 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 la direction architecturale, le caractère transitoire ou non de l’erreur et les diagnostics. +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 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 index ffde3a29..8ef35e9d 100644 --- a/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.md +++ b/doc/handwritten/for-maintainers/adr/0072-allow-custom-error-subtypes.md @@ -23,7 +23,7 @@ FirstClassErrors will allow applications to derive specialized error categories ## 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 architectural direction, transience classification, and diagnosable metadata. +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