diff --git a/FirstClassErrors.UnitTests/ErrorDefensiveTests.cs b/FirstClassErrors.UnitTests/ErrorDefensiveTests.cs index 31aee78..51d7a3d 100644 --- a/FirstClassErrors.UnitTests/ErrorDefensiveTests.cs +++ b/FirstClassErrors.UnitTests/ErrorDefensiveTests.cs @@ -1,5 +1,7 @@ #region Usings declarations +using System.Diagnostics.CodeAnalysis; + using JetBrains.Annotations; using NFluent; @@ -8,8 +10,22 @@ namespace FirstClassErrors.UnitTests; +[Collection("SmartEnumSideEffects")] [TestSubject(typeof(Error))] -public sealed class ErrorDefensiveTests { +public sealed class ErrorDefensiveTests : IDisposable { + + #region Constructors & Destructor + + public ErrorDefensiveTests() { + ErrorContextKey.ResetForTests(); + } + + #endregion + + [SuppressMessage("Usage", "CA1816", Justification = "IDisposable is used as an xUnit teardown hook. The class has no finalizer and does not own unmanaged resources.")] + public void Dispose() { + ErrorContextKey.ResetForTests(); + } [Fact(DisplayName = "A null error code is replaced by the unspecified error code.")] public void ANullErrorCodeIsReplacedByTheUnspecifiedErrorCode() { @@ -20,21 +36,25 @@ public void ANullErrorCodeIsReplacedByTheUnspecifiedErrorCode() { Check.That(error.Code).IsSameReferenceAs(ErrorCode.Unspecified); } - [Fact(DisplayName = "A null diagnostic message is rejected.")] - public void ANullDiagnosticMessageIsRejected() { - // Exercise & verify - Check.ThatCode(() => DomainError.Create(ErrorCode.Unspecified, null!)) - .Throws(); + [Fact(DisplayName = "A null diagnostic message is replaced by a fallback sentinel.")] + public void ANullDiagnosticMessageIsReplacedByAFallbackSentinel() { + // Exercise + DomainError error = DomainError.Create(ErrorCode.Unspecified, null!).WithPublicMessage("short"); + + // Verify + Check.That(error.DiagnosticMessage).IsEqualTo(Error.MissingDiagnosticMessage); } - [Theory(DisplayName = "An empty or whitespace diagnostic message is rejected.")] + [Theory(DisplayName = "An empty or whitespace diagnostic message is replaced by a fallback sentinel.")] [InlineData("")] [InlineData(" ")] [InlineData(" ")] - public void AnEmptyOrWhitespaceDiagnosticMessageIsRejected(string value) { - // Exercise & verify - Check.ThatCode(() => DomainError.Create(ErrorCode.Unspecified, value)) - .Throws(); + public void AnEmptyOrWhitespaceDiagnosticMessageIsReplacedByAFallbackSentinel(string value) { + // Exercise + DomainError error = DomainError.Create(ErrorCode.Unspecified, value).WithPublicMessage("short"); + + // Verify + Check.That(error.DiagnosticMessage).IsEqualTo(Error.MissingDiagnosticMessage); } [Fact(DisplayName = "The diagnostic message is trimmed.")] @@ -46,21 +66,56 @@ public void TheDiagnosticMessageIsTrimmed() { Check.That(error.DiagnosticMessage).IsEqualTo("hello"); } - [Fact(DisplayName = "A null short message is rejected.")] - public void ANullShortMessageIsRejected() { - // Exercise & verify - Check.ThatCode(() => DomainError.Create(ErrorCode.Unspecified, "diagnostic").WithPublicMessage(null!)) - .Throws(); + [Fact(DisplayName = "A null short message is replaced by a fallback sentinel.")] + public void ANullShortMessageIsReplacedByAFallbackSentinel() { + // Exercise + DomainError error = DomainError.Create(ErrorCode.Unspecified, "diagnostic").WithPublicMessage(null!); + + // Verify + Check.That(error.ShortMessage).IsEqualTo(Error.MissingShortMessage); } - [Theory(DisplayName = "An empty or whitespace short message is rejected.")] + [Theory(DisplayName = "An empty or whitespace short message is replaced by a fallback sentinel.")] [InlineData("")] [InlineData(" ")] [InlineData(" ")] - public void AnEmptyOrWhitespaceShortMessageIsRejected(string value) { - // Exercise & verify - Check.ThatCode(() => DomainError.Create(ErrorCode.Unspecified, "diagnostic").WithPublicMessage(value)) - .Throws(); + public void AnEmptyOrWhitespaceShortMessageIsReplacedByAFallbackSentinel(string value) { + // Exercise + DomainError error = DomainError.Create(ErrorCode.Unspecified, "diagnostic").WithPublicMessage(value); + + // Verify + Check.That(error.ShortMessage).IsEqualTo(Error.MissingShortMessage); + } + + [Fact(DisplayName = "A missing diagnostic message is recorded in the context under #MISSING_REQUIRED_MESSAGE.")] + public void AMissingDiagnosticMessageIsRecordedInTheContext() { + // Exercise + DomainError error = DomainError.Create(ErrorCode.Unspecified, null!).WithPublicMessage("short"); + + // Verify + Check.That(error.Context.Values.ContainsKey(ErrorContextKey.MissingRequiredMessages)).IsTrue(); + + IReadOnlyList missing = (IReadOnlyList)error.Context.Values[ErrorContextKey.MissingRequiredMessages]!; + Check.That(missing).ContainsExactly("diagnosticMessage"); + } + + [Fact(DisplayName = "Missing diagnostic and short messages are both recorded in the context.")] + public void MissingDiagnosticAndShortMessagesAreBothRecordedInTheContext() { + // Exercise + DomainError error = DomainError.Create(ErrorCode.Unspecified, null!).WithPublicMessage(null!); + + // Verify + IReadOnlyList missing = (IReadOnlyList)error.Context.Values[ErrorContextKey.MissingRequiredMessages]!; + Check.That(missing).ContainsExactly("diagnosticMessage", "shortMessage"); + } + + [Fact(DisplayName = "Present mandatory messages leave no #MISSING_REQUIRED_MESSAGE entry in the context.")] + public void PresentMandatoryMessagesLeaveNoMissingEntryInTheContext() { + // Exercise + DomainError error = DomainError.Create(ErrorCode.Unspecified, "diagnostic").WithPublicMessage("short"); + + // Verify + Check.That(error.Context.Values.ContainsKey(ErrorContextKey.MissingRequiredMessages)).IsFalse(); } [Fact(DisplayName = "The short message is trimmed.")] @@ -117,6 +172,26 @@ public void AConfigureContextDelegateThatThrowsIsCapturedIntoTheContext() { Check.That(((Exception)captured!).Message).IsEqualTo("boom"); } + [Fact(DisplayName = "A configure-context delegate that throws preserves the entries added before the failure.")] + public void AConfigureContextDelegateThatThrowsPreservesTheEntriesAddedBeforeTheFailure() { + // Setup + ErrorContextKey beforeKey = ErrorContextKey.Create("BeforeFailure"); + + // Exercise + DomainError error = DomainError.Create(ErrorCode.Unspecified, "diagnostic", builder => { + builder.Add(beforeKey, "kept"); + + throw new InvalidOperationException("boom"); + }) + .WithPublicMessage("short"); + + // Verify — the entry added before the failure survives alongside the captured exception. + bool found = error.Context.TryGet(beforeKey, out string? kept); + Check.That(found).IsTrue(); + Check.That(kept).IsEqualTo("kept"); + Check.That(error.Context.Values.ContainsKey(ErrorContextKey.CannotBuildErrorContext)).IsTrue(); + } + [Fact(DisplayName = "Inner errors are stored as a defensive copy of the provided collection.")] public void InnerErrorsAreStoredAsADefensiveCopyOfTheProvidedCollection() { // Setup diff --git a/FirstClassErrors/DomainError.cs b/FirstClassErrors/DomainError.cs index 22e70db..e958b50 100644 --- a/FirstClassErrors/DomainError.cs +++ b/FirstClassErrors/DomainError.cs @@ -35,10 +35,8 @@ internal DomainError(ErrorCode code, string diagnosticMessage, string shortMessa /// An optional action to configure additional error context. /// A to supply the public messages and finalize the error. public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, Action? configureContext = null) { - string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - return new PublicMessageStage((shortMessage, detailedMessage) => - new DomainError(code, safeDiagnosticMessage, shortMessage, detailedMessage, configureContext)); + new DomainError(code, diagnosticMessage, shortMessage, detailedMessage, configureContext)); } /// @@ -50,10 +48,8 @@ public static PublicMessageStage Create(ErrorCode code, string diag /// An optional action to configure additional error context. /// A to supply the public messages and finalize the error. public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, DomainError innerError, Action? configureContext = null) { - string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - return new PublicMessageStage((shortMessage, detailedMessage) => - new DomainError(code, safeDiagnosticMessage, shortMessage, detailedMessage, innerError, configureContext)); + new DomainError(code, diagnosticMessage, shortMessage, detailedMessage, innerError, configureContext)); } /// @@ -65,10 +61,8 @@ public static PublicMessageStage Create(ErrorCode code, string diag /// An optional action to configure additional error context. /// A to supply the public messages and finalize the error. public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, IEnumerable innerErrors, Action? configureContext = null) { - string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - return new PublicMessageStage((shortMessage, detailedMessage) => - new DomainError(code, safeDiagnosticMessage, shortMessage, detailedMessage, innerErrors, configureContext)); + new DomainError(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors, configureContext)); } #endregion diff --git a/FirstClassErrors/Error.cs b/FirstClassErrors/Error.cs index 18e2105..a279186 100644 --- a/FirstClassErrors/Error.cs +++ b/FirstClassErrors/Error.cs @@ -33,10 +33,52 @@ namespace FirstClassErrors; /// ) and returns a ; the final error is /// produced by . /// +/// +/// Doctrine — manufacturing an error never throws. Because an error is frequently built on a failure path +/// (inside a catch, while logging, or on top of another failure), the construction is designed to always +/// succeed rather than raise a secondary exception that would mask the original problem. Invalid or missing inputs +/// degrade to documented, visible fallbacks instead of being rejected: +/// +/// +/// a null becomes (#UNSPECIFIED); +/// +/// a missing (null or whitespace) mandatory message becomes a sentinel +/// ( / ), and the omission is recorded +/// in under the #MISSING_REQUIRED_MESSAGE key so it stays diagnosable; +/// +/// null inner errors are dropped; +/// +/// a configureContext delegate that throws is captured as data under the +/// #CANNOT_BUILD_ERROR_CONTEXT key, and the context entries it added before failing are preserved. +/// +/// /// [DebuggerDisplay("{ToString()}")] public abstract class Error { + #region Constants declarations + + /// + /// The value substituted for a missing (null or whitespace) . + /// + /// + /// Under the library's doctrine, manufacturing an error never throws: a missing mandatory diagnostic message is not + /// rejected but replaced by this visible, self-documenting sentinel, and the omission is recorded in + /// under the #MISSING_REQUIRED_MESSAGE key. + /// + public const string MissingDiagnosticMessage = "#MISSING_DIAGNOSTIC_MESSAGE"; + + /// + /// The value substituted for a missing (null or whitespace) . + /// + /// + /// See for the rationale. Because is public-facing, + /// this sentinel is intentionally non-sensitive and clearly signals a construction defect if it ever surfaces. + /// + public const string MissingShortMessage = "#MISSING_SHORT_MESSAGE"; + + #endregion + #region Statics members declarations private static ErrorCode CreateSafeCode(ErrorCode? errorCode) { @@ -44,15 +86,23 @@ private static ErrorCode CreateSafeCode(ErrorCode? errorCode) { } /// - /// Validates a mandatory message and returns its trimmed value. + /// Normalizes a mandatory message under the "manufacturing an error never throws" doctrine: a null or + /// whitespace value is replaced by ; otherwise the value is trimmed. /// - /// Thrown when is null. - /// Thrown when is empty or whitespace. - internal static string RequireMessage(string value, string paramName) { - if (value is null) { throw new ArgumentNullException(paramName); } - if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException("Value cannot be empty or whitespace.", paramName); } + private static string CoalesceRequiredMessage(string? value, string fallback) { + return string.IsNullOrWhiteSpace(value) ? fallback : value!.Trim(); + } + + /// + /// Returns the parameter names of the mandatory messages that are missing (null or whitespace) and were + /// therefore replaced by a fallback sentinel. The list is empty when both messages are present. + /// + private static IReadOnlyList CollectMissingMessageNames(string? diagnosticMessage, string? shortMessage) { + List missing = new(2); + if (string.IsNullOrWhiteSpace(diagnosticMessage)) { missing.Add(nameof(diagnosticMessage)); } + if (string.IsNullOrWhiteSpace(shortMessage)) { missing.Add(nameof(shortMessage)); } - return value.Trim(); + return missing; } /// @@ -62,15 +112,20 @@ internal static string RequireMessage(string value, string paramName) { return string.IsNullOrWhiteSpace(value) ? null : value!.Trim(); } - private static ErrorContext BuildContext(Action? configure) { + private static ErrorContext BuildContext(Action? configure, IReadOnlyList missingRequiredMessages) { ErrorContextBuilder builder = new(); try { configure?.Invoke(builder); } catch (Exception ex) { - Dictionary dictionary = new() { { ErrorContextKey.CannotBuildErrorContext, ex } }; + // Doctrine: manufacturing an error never throws. A failing context configuration is captured as data rather + // than propagated. The entries the delegate added before failing are kept (they are often the most useful + // part of the diagnostic) instead of being discarded along with the exception. + builder.SetInternalValue(ErrorContextKey.CannotBuildErrorContext, ex); + } - return new ErrorContext(dictionary); + if (missingRequiredMessages.Count > 0) { + builder.SetInternalValue(ErrorContextKey.MissingRequiredMessages, missingRequiredMessages); } return builder.Build(); @@ -98,10 +153,12 @@ private static IReadOnlyList CreateSafeInnerErrors(Error? innerError) { /// The identifying the error. If null, is used. /// /// - /// The mandatory internal diagnostic message. This value cannot be null or whitespace. + /// The mandatory internal diagnostic message. If null or whitespace, it is replaced by + /// and the omission is recorded in the context; the error is never rejected. /// /// - /// The mandatory public short summary of the error. This value cannot be null or whitespace. + /// The mandatory public short summary of the error. If null or whitespace, it is replaced by + /// and the omission is recorded in the context; the error is never rejected. /// /// /// An optional, controlled public detail. This value can be null. @@ -119,11 +176,11 @@ protected Error(ErrorCode code, InstanceId = Guid.NewGuid(); Code = CreateSafeCode(code); OccurredAt = DateTimeOffset.UtcNow; - DiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - ShortMessage = RequireMessage(shortMessage, nameof(shortMessage)); + DiagnosticMessage = CoalesceRequiredMessage(diagnosticMessage, MissingDiagnosticMessage); + ShortMessage = CoalesceRequiredMessage(shortMessage, MissingShortMessage); DetailedMessage = NormalizeOptionalMessage(detailedMessage); InnerErrors = new List(); - Context = BuildContext(configureContext); + Context = BuildContext(configureContext, CollectMissingMessageNames(diagnosticMessage, shortMessage)); } /// @@ -133,8 +190,8 @@ protected Error(ErrorCode code, /// /// The identifying the error. If null, is used. /// - /// The mandatory internal diagnostic message. This value cannot be null or whitespace. - /// The mandatory public short summary of the error. This value cannot be null or whitespace. + /// The mandatory internal diagnostic message. If null or whitespace, it is replaced by and the omission is recorded in the context (the error is never rejected). + /// The mandatory public short summary of the error. If null or whitespace, it is replaced by and the omission is recorded in the context (the error is never rejected). /// An optional, controlled public detail. This value can be null. /// /// The inner that provides additional context for the error. @@ -149,11 +206,11 @@ protected Error(ErrorCode code, InstanceId = Guid.NewGuid(); Code = CreateSafeCode(code); OccurredAt = DateTimeOffset.UtcNow; - DiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - ShortMessage = RequireMessage(shortMessage, nameof(shortMessage)); + DiagnosticMessage = CoalesceRequiredMessage(diagnosticMessage, MissingDiagnosticMessage); + ShortMessage = CoalesceRequiredMessage(shortMessage, MissingShortMessage); DetailedMessage = NormalizeOptionalMessage(detailedMessage); InnerErrors = CreateSafeInnerErrors(innerError); - Context = BuildContext(configureContext); + Context = BuildContext(configureContext, CollectMissingMessageNames(diagnosticMessage, shortMessage)); } /// @@ -164,8 +221,8 @@ protected Error(ErrorCode code, /// /// The error code that uniquely identifies the error. If null, is used. /// - /// The mandatory internal diagnostic message. This value cannot be null or whitespace. - /// The mandatory public short summary of the error. This value cannot be null or whitespace. + /// The mandatory internal diagnostic message. If null or whitespace, it is replaced by and the omission is recorded in the context (the error is never rejected). + /// The mandatory public short summary of the error. If null or whitespace, it is replaced by and the omission is recorded in the context (the error is never rejected). /// An optional, controlled public detail. This value can be null. /// /// A collection of inner instances that provide additional context for the error. @@ -182,11 +239,11 @@ protected Error(ErrorCode code, InstanceId = Guid.NewGuid(); Code = CreateSafeCode(code); OccurredAt = DateTimeOffset.UtcNow; - DiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - ShortMessage = RequireMessage(shortMessage, nameof(shortMessage)); + DiagnosticMessage = CoalesceRequiredMessage(diagnosticMessage, MissingDiagnosticMessage); + ShortMessage = CoalesceRequiredMessage(shortMessage, MissingShortMessage); DetailedMessage = NormalizeOptionalMessage(detailedMessage); InnerErrors = CreateSafeInnerErrors(innerErrors); - Context = BuildContext(configureContext); + Context = BuildContext(configureContext, CollectMissingMessageNames(diagnosticMessage, shortMessage)); } #endregion @@ -233,6 +290,10 @@ protected Error(ErrorCode code, /// It is not safe for external exposure and must never be used by default in an HTTP response or returned /// to an external client. It is intended for diagnostics, support, observability and developers. /// + /// + /// This message is mandatory. When it is omitted at construction, it holds + /// and the #MISSING_REQUIRED_MESSAGE context key lists the omission (see the doctrine on ). + /// /// public string DiagnosticMessage { get; } @@ -244,7 +305,9 @@ protected Error(ErrorCode code, /// /// /// This message is a concise public summary, safe to surface to an end user or an API client (for instance as the - /// title of an RFC 9457 problem detail). It is mandatory. + /// title of an RFC 9457 problem detail). It is mandatory. When it is omitted at construction, it holds + /// and the #MISSING_REQUIRED_MESSAGE context key lists the omission (see the + /// doctrine on ). /// public string ShortMessage { get; } diff --git a/FirstClassErrors/ErrorContextBuilder.cs b/FirstClassErrors/ErrorContextBuilder.cs index 2556481..e0a1e1b 100644 --- a/FirstClassErrors/ErrorContextBuilder.cs +++ b/FirstClassErrors/ErrorContextBuilder.cs @@ -35,6 +35,15 @@ public ErrorContextBuilder Add(ErrorContextKey key, T? value) { return this; } + /// + /// Sets a value for a framework-owned (non-generic) context key, bypassing the strongly-typed public + /// API. Used during error construction to record diagnostics about the construction itself + /// (a failed context configuration or a missing mandatory message) without discarding the entries already added. + /// + internal void SetInternalValue(ErrorContextKey key, object? value) { + _values[key] = value; + } + internal ErrorContext Build() { return new ErrorContext(_values); } diff --git a/FirstClassErrors/ErrorContextKey.cs b/FirstClassErrors/ErrorContextKey.cs index 0c94488..057e301 100644 --- a/FirstClassErrors/ErrorContextKey.cs +++ b/FirstClassErrors/ErrorContextKey.cs @@ -52,6 +52,18 @@ public abstract class ErrorContextKey : IEquatable { /// internal static readonly ErrorContextKey CannotBuildErrorContext = Create("#CANNOT_BUILD_ERROR_CONTEXT", "The exception responsible for the failure to construct the error context."); + /// + /// Represents the key that lists the mandatory messages (by parameter name) that were missing at error construction + /// and were replaced by a fallback sentinel. + /// + /// + /// This key materializes, as queryable diagnostic data, a violation of the invariant that every error carries a + /// diagnostic and a short message. It is added during construction under the library's + /// "manufacturing an error never throws" doctrine. The associated value is an + /// of . + /// + internal static readonly ErrorContextKey MissingRequiredMessages = Create>("#MISSING_REQUIRED_MESSAGE", "The mandatory messages (by parameter name) that were missing and replaced by a fallback sentinel."); + /// /// Creates a new instance of with the specified name and optional description. /// diff --git a/FirstClassErrors/InfrastructureError.cs b/FirstClassErrors/InfrastructureError.cs index 06b1086..cbb21fb 100644 --- a/FirstClassErrors/InfrastructureError.cs +++ b/FirstClassErrors/InfrastructureError.cs @@ -52,10 +52,8 @@ internal InfrastructureError(ErrorCode code, string diagnosticMessage, string sh /// An optional action to configure additional error context. /// A to supply the public messages and finalize the error. public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, InteractionDirection direction, Transience transience, Action? configureContext = null) { - string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - return new PublicMessageStage((shortMessage, detailedMessage) => - new InfrastructureError(code, safeDiagnosticMessage, shortMessage, detailedMessage, direction, transience, configureContext)); + new InfrastructureError(code, diagnosticMessage, shortMessage, detailedMessage, direction, transience, configureContext)); } /// @@ -69,10 +67,8 @@ public static PublicMessageStage Create(ErrorCode code, str /// An optional action to configure additional error context. /// A to supply the public messages and finalize the error. public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, InteractionDirection direction, Transience transience, Error innerError, Action? configureContext = null) { - string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - return new PublicMessageStage((shortMessage, detailedMessage) => - new InfrastructureError(code, safeDiagnosticMessage, shortMessage, detailedMessage, direction, transience, innerError, configureContext)); + new InfrastructureError(code, diagnosticMessage, shortMessage, detailedMessage, direction, transience, innerError, configureContext)); } /// @@ -86,10 +82,8 @@ public static PublicMessageStage Create(ErrorCode code, str /// An optional action to configure additional error context. /// A to supply the public messages and finalize the error. public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, InteractionDirection direction, Transience transience, IEnumerable innerErrors, Action? configureContext = null) { - string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - return new PublicMessageStage((shortMessage, detailedMessage) => - new InfrastructureError(code, safeDiagnosticMessage, shortMessage, detailedMessage, direction, transience, innerErrors, configureContext)); + new InfrastructureError(code, diagnosticMessage, shortMessage, detailedMessage, direction, transience, innerErrors, configureContext)); } #endregion diff --git a/FirstClassErrors/PrimaryPortError.cs b/FirstClassErrors/PrimaryPortError.cs index f042536..72ad76e 100644 --- a/FirstClassErrors/PrimaryPortError.cs +++ b/FirstClassErrors/PrimaryPortError.cs @@ -34,10 +34,8 @@ internal PrimaryPortError(ErrorCode code, string diagnosticMessage, string short /// An optional action to configure additional error context. /// A to supply the public messages and finalize the error. public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, Transience transience, Action? configureContext = null) { - string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - return new PublicMessageStage((shortMessage, detailedMessage) => - new PrimaryPortError(code, safeDiagnosticMessage, shortMessage, detailedMessage, transience, configureContext)); + new PrimaryPortError(code, diagnosticMessage, shortMessage, detailedMessage, transience, configureContext)); } /// @@ -50,10 +48,8 @@ public static PublicMessageStage Create(ErrorCode code, string /// An optional action to configure additional error context. /// A to supply the public messages and finalize the error. public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, PrimaryPortInnerErrors innerErrors, Action? configureContext = null) { - string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - return new PublicMessageStage((shortMessage, detailedMessage) => - new PrimaryPortError(code, safeDiagnosticMessage, shortMessage, detailedMessage, innerErrors, configureContext)); + new PrimaryPortError(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors, configureContext)); } #endregion diff --git a/FirstClassErrors/PrimaryPortInnerErrors.cs b/FirstClassErrors/PrimaryPortInnerErrors.cs index 70efb70..009fcf0 100644 --- a/FirstClassErrors/PrimaryPortInnerErrors.cs +++ b/FirstClassErrors/PrimaryPortInnerErrors.cs @@ -11,7 +11,8 @@ namespace FirstClassErrors; /// /// /// This class provides methods to add domain-specific and primary port-specific errors to the collection. -/// It ensures that the added errors are not null and supports method chaining for convenience. +/// A null error is ignored rather than rejected (manufacturing an error never throws), and the methods support +/// method chaining for convenience. /// [DebuggerDisplay("{ToString()}")] public sealed class PrimaryPortInnerErrors { @@ -26,13 +27,14 @@ public sealed class PrimaryPortInnerErrors { /// Adds a domain-specific error to the collection of inner errors. /// /// - /// The instance representing the domain-specific error to add. + /// The instance representing the domain-specific error to add. If null, the call is + /// ignored. /// /// /// The current instance of , allowing for method chaining. /// /// - /// This method ensures that the provided is not null before adding it to the collection. + /// A null is ignored rather than rejected (manufacturing an error never throws). /// public PrimaryPortInnerErrors Add(DomainError error) { // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract @@ -47,13 +49,14 @@ public PrimaryPortInnerErrors Add(DomainError error) { /// Adds a primary port-specific error to the collection of inner errors. /// /// - /// The instance representing the primary port-specific error to add. + /// The instance representing the primary port-specific error to add. If null, + /// the call is ignored. /// /// /// The current instance of , allowing for method chaining. /// /// - /// This method ensures that the provided is not null before adding it to the collection. + /// A null is ignored rather than rejected (manufacturing an error never throws). /// public PrimaryPortInnerErrors Add(PrimaryPortError error) { // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract diff --git a/FirstClassErrors/PublicMessageStage.cs b/FirstClassErrors/PublicMessageStage.cs index 089ceef..0a7c697 100644 --- a/FirstClassErrors/PublicMessageStage.cs +++ b/FirstClassErrors/PublicMessageStage.cs @@ -38,21 +38,19 @@ internal PublicMessageStage(Func finalize) { /// Supplies the public-facing messages and produces the final error instance. /// /// - /// The mandatory short public summary of the error, safe to surface to an end user or an API client. This value - /// cannot be null or whitespace. + /// The mandatory short public summary of the error, safe to surface to an end user or an API client. Following the + /// library's "manufacturing an error never throws" doctrine, a null or whitespace value is not rejected: it is + /// replaced by and the omission is recorded in the error context. /// /// /// An optional, controlled public detail. It may be exposed to a caller, but only when the application explicitly /// chooses to. Defaults to null. /// /// The finalized instance. - /// Thrown when is null. - /// Thrown when is empty or whitespace. public TError WithPublicMessage(string shortMessage, string? detailedMessage = null) { - string safeShortMessage = Error.RequireMessage(shortMessage, nameof(shortMessage)); - string? safeDetailedMessage = Error.NormalizeOptionalMessage(detailedMessage); - - return _finalize(safeShortMessage, safeDetailedMessage); + // The base Error constructor is the single place that normalizes messages (coalesces missing mandatory ones to a + // documented sentinel and trims the rest), so the raw values are forwarded as-is here. + return _finalize(shortMessage, detailedMessage); } } diff --git a/FirstClassErrors/SecondaryPortError.cs b/FirstClassErrors/SecondaryPortError.cs index 566b7b2..bda85c3 100644 --- a/FirstClassErrors/SecondaryPortError.cs +++ b/FirstClassErrors/SecondaryPortError.cs @@ -34,10 +34,8 @@ internal SecondaryPortError(ErrorCode code, string diagnosticMessage, string sho /// An optional action to configure additional error context. /// A to supply the public messages and finalize the error. public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, Transience transience, Action? configureContext = null) { - string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - return new PublicMessageStage((shortMessage, detailedMessage) => - new SecondaryPortError(code, safeDiagnosticMessage, shortMessage, detailedMessage, transience, configureContext)); + new SecondaryPortError(code, diagnosticMessage, shortMessage, detailedMessage, transience, configureContext)); } /// @@ -50,10 +48,8 @@ public static PublicMessageStage Create(ErrorCode code, stri /// An optional action to configure additional error context. /// A to supply the public messages and finalize the error. public static PublicMessageStage Create(ErrorCode code, string diagnosticMessage, SecondaryPortInnerErrors innerErrors, Action? configureContext = null) { - string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage)); - return new PublicMessageStage((shortMessage, detailedMessage) => - new SecondaryPortError(code, safeDiagnosticMessage, shortMessage, detailedMessage, innerErrors, configureContext)); + new SecondaryPortError(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors, configureContext)); } #endregion diff --git a/FirstClassErrors/SecondaryPortInnerErrors.cs b/FirstClassErrors/SecondaryPortInnerErrors.cs index 5f40087..c86c3b9 100644 --- a/FirstClassErrors/SecondaryPortInnerErrors.cs +++ b/FirstClassErrors/SecondaryPortInnerErrors.cs @@ -26,14 +26,14 @@ public sealed class SecondaryPortInnerErrors { /// Adds a to the collection of errors. /// /// - /// The instance to add. Must not be null. + /// The instance to add. If null, the call is ignored. /// /// /// The current instance, allowing for method chaining. /// /// /// This method appends a domain-specific error to the internal collection of errors. - /// It ensures that the provided error is not null before adding it. + /// A null is ignored rather than rejected (manufacturing an error never throws). /// public SecondaryPortInnerErrors Add(DomainError error) { // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract @@ -48,14 +48,14 @@ public SecondaryPortInnerErrors Add(DomainError error) { /// Adds a to the collection of errors. /// /// - /// The instance to add. Must not be null. + /// The instance to add. If null, the call is ignored. /// /// /// The current instance, allowing for method chaining. /// /// /// This method appends a secondary port error to the internal collection of errors. - /// It ensures that the provided error is not null before adding it. + /// A null is ignored rather than rejected (manufacturing an error never throws). /// public SecondaryPortInnerErrors Add(SecondaryPortError error) { // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract