diff --git a/FirstClassErrors.RequestBinder.UnitTests/BinderErrorDefinitionTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BinderErrorDefinitionTests.cs
new file mode 100644
index 00000000..11a84104
--- /dev/null
+++ b/FirstClassErrors.RequestBinder.UnitTests/BinderErrorDefinitionTests.cs
@@ -0,0 +1,91 @@
+#region Usings declarations
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.RequestBinder.UnitTests;
+
+///
+/// Unit tests for the two configuration value types the structural-error definitions introduce:
+/// (a plain public-message carrier) and (a code
+/// bundled with its message builder, immutable, derivable through WithCode / WithMessage).
+///
+public sealed class BinderErrorDefinitionTests {
+
+ private static readonly ErrorCode SampleCode = ErrorCode.Create("SOME_CODE");
+
+ private static BindingMessage Message(string argumentPath) {
+ return new BindingMessage($"short {argumentPath}", $"detailed {argumentPath}");
+ }
+
+ // ── BindingMessage ────────────────────────────────────────────────────────────────────────────────────
+
+ [Fact(DisplayName = "BindingMessage carries the short and detailed messages; the detail defaults to null.")]
+ public void BindingMessageCarriesBothMessages() {
+ var full = new BindingMessage("short", "detailed");
+ Check.That(full.ShortMessage).IsEqualTo("short");
+ Check.That(full.DetailedMessage).IsEqualTo("detailed");
+
+ var shortOnly = new BindingMessage("short");
+ Check.That(shortOnly.DetailedMessage).IsNull();
+ }
+
+ [Fact(DisplayName = "BindingMessage never throws on a null short message — coalescing is deferred to the error factory.")]
+ public void BindingMessageToleratesNullShortMessage() {
+ Check.ThatCode(() => new BindingMessage(null!)).DoesNotThrow();
+ }
+
+ // ── BinderErrorDefinition: construction and accessors ─────────────────────────────────────────────────
+
+ [Fact(DisplayName = "GetMessage invokes the builder with the argument path; Code exposes the code.")]
+ public void GetMessageInvokesTheBuilder() {
+ var definition = new BinderErrorDefinition(SampleCode, Message);
+
+ BindingMessage message = definition.GetMessage("Guests[1].FirstName");
+
+ Check.That(definition.Code).IsEqualTo(SampleCode);
+ Check.That(message.ShortMessage).IsEqualTo("short Guests[1].FirstName");
+ Check.That(message.DetailedMessage).IsEqualTo("detailed Guests[1].FirstName");
+ }
+
+ [Fact(DisplayName = "A null code or a null message builder is rejected at construction.")]
+ public void ConstructorRejectsNulls() {
+ Check.ThatCode(() => new BinderErrorDefinition(null!, Message)).Throws();
+ Check.ThatCode(() => new BinderErrorDefinition(SampleCode, null!)).Throws();
+ }
+
+ // ── Immutable derivation: WithCode / WithMessage ──────────────────────────────────────────────────────
+
+ [Fact(DisplayName = "WithCode swaps the code and keeps the message builder, without mutating the original.")]
+ public void WithCodeSwapsCodeKeepsMessage() {
+ var original = new BinderErrorDefinition(SampleCode, Message);
+ var other = ErrorCode.Create("OTHER_CODE");
+
+ BinderErrorDefinition derived = original.WithCode(other);
+
+ Check.That(derived.Code).IsEqualTo(other);
+ Check.That(derived.GetMessage("X").ShortMessage).IsEqualTo("short X"); // same builder
+ Check.That(original.Code).IsEqualTo(SampleCode); // original untouched
+ }
+
+ [Fact(DisplayName = "WithMessage swaps the message builder and keeps the code, without mutating the original.")]
+ public void WithMessageSwapsMessageKeepsCode() {
+ var original = new BinderErrorDefinition(SampleCode, Message);
+
+ BinderErrorDefinition derived = original.WithMessage(argumentPath => new BindingMessage($"new {argumentPath}"));
+
+ Check.That(derived.Code).IsEqualTo(SampleCode); // same code
+ Check.That(derived.GetMessage("X").ShortMessage).IsEqualTo("new X");
+ Check.That(original.GetMessage("X").ShortMessage).IsEqualTo("short X"); // original untouched
+ }
+
+ [Fact(DisplayName = "WithCode and WithMessage reject nulls.")]
+ public void WithersRejectNulls() {
+ var definition = new BinderErrorDefinition(SampleCode, Message);
+
+ Check.ThatCode(() => definition.WithCode(null!)).Throws();
+ Check.ThatCode(() => definition.WithMessage(null!)).Throws();
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs
index a62f6d96..ab069754 100644
--- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs
+++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs
@@ -325,7 +325,7 @@ public void CrossBinderReadMessageNamesTheCause() {
[Fact(DisplayName = "REQUEST_ARGUMENT_REQUIRED carries its public summary, and its detailed and diagnostic messages name the argument path.")]
public void RequiredArgumentErrorCarriesItsMessages() {
- PrimaryPortError error = RequestBindingError.ArgumentRequired(RequestBindingError.DefaultArgumentRequiredCode, "GuestEmail");
+ PrimaryPortError error = RequestBindingError.ArgumentRequired(RequestBindingError.DefaultArgumentRequired, "GuestEmail");
Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(error.ShortMessage).IsEqualTo("A required argument is missing.");
@@ -336,7 +336,7 @@ public void RequiredArgumentErrorCarriesItsMessages() {
[Fact(DisplayName = "REQUEST_ARGUMENT_INVALID carries its public summary, its detailed message names the path, and it wraps the cause.")]
public void InvalidArgumentErrorCarriesItsMessages() {
- PrimaryPortError error = RequestBindingError.ArgumentInvalid(RequestBindingError.DefaultArgumentInvalidCode, "GuestEmail", BookingDomainError.EmailInvalid("x"));
+ PrimaryPortError error = RequestBindingError.ArgumentInvalid(RequestBindingError.DefaultArgumentInvalid, "GuestEmail", BookingDomainError.EmailInvalid("x"));
Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
Check.That(error.ShortMessage).IsEqualTo("An argument is invalid.");
diff --git a/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs b/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs
index cdf0d666..1163c6ad 100644
--- a/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs
+++ b/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs
@@ -30,8 +30,8 @@ private static Error BindMissingEmail(RequestBinderEnvelopeStage
[Fact(DisplayName = "Bind.PropertiesOf binds with the configured default options — naming and structural codes — without WithOptions.")]
public void BindPropertiesOfUsesTheConfiguredDefault() {
var configured = new RequestBinderOptions(new SnakeCaseNameProvider(),
- ErrorCode.Create("ACME_ARGUMENT_REQUIRED"),
- ErrorCode.Create("ACME_ARGUMENT_INVALID"));
+ RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")),
+ RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("ACME_ARGUMENT_INVALID")));
using (RequestBinderOptions.OverrideDefaultForTests(configured)) {
Error error = BindMissingEmail(Bind.PropertiesOf(MissingEmail()));
@@ -54,11 +54,11 @@ public void OutsideScopeFallsBackToBuiltIn() {
[Fact(DisplayName = "A per-call Bind.WithOptions overrides the configured default.")]
public void WithOptionsWinsOverTheConfiguredDefault() {
var appDefault = new RequestBinderOptions(new SnakeCaseNameProvider(),
- ErrorCode.Create("DEFAULT_REQUIRED"),
- ErrorCode.Create("DEFAULT_INVALID"));
+ RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("DEFAULT_REQUIRED")),
+ RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("DEFAULT_INVALID")));
var perCall = new RequestBinderOptions(new SnakeCaseNameProvider(),
- ErrorCode.Create("PERCALL_REQUIRED"),
- ErrorCode.Create("PERCALL_INVALID"));
+ RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("PERCALL_REQUIRED")),
+ RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("PERCALL_INVALID")));
using (RequestBinderOptions.OverrideDefaultForTests(appDefault)) {
Error error = BindMissingEmail(Bind.WithOptions(perCall).PropertiesOf(MissingEmail()));
@@ -71,8 +71,8 @@ public void WithOptionsWinsOverTheConfiguredDefault() {
[Fact(DisplayName = "Unconfigured, RequestBinderOptions.Default is the built-in default (default structural codes).")]
public void DefaultIsBuiltInWhenUnconfigured() {
- Check.That(RequestBinderOptions.Default.ArgumentRequiredCode == RequestBindingError.DefaultArgumentRequiredCode).IsTrue();
- Check.That(RequestBinderOptions.Default.ArgumentInvalidCode == RequestBindingError.DefaultArgumentInvalidCode).IsTrue();
+ Check.That(RequestBinderOptions.Default.ArgumentRequired.Code == RequestBindingError.DefaultArgumentRequiredCode).IsTrue();
+ Check.That(RequestBinderOptions.Default.ArgumentInvalid.Code == RequestBindingError.DefaultArgumentInvalidCode).IsTrue();
}
// ── Setter contract: null-rejecting and frozen-after-first-use ────────────────────────────────────────
diff --git a/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs b/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs
index 8b9e6440..45a8443d 100644
--- a/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs
+++ b/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs
@@ -12,7 +12,10 @@ public sealed class StructuralCodeOverrideTests {
private static readonly ErrorCode AcmeInvalid = ErrorCode.Create("ACME_ARGUMENT_INVALID");
private static RequestBinderOptions CustomCodes() {
- return new RequestBinderOptions(RequestBinderOptions.Default.ArgumentNameProvider, AcmeRequired, AcmeInvalid);
+ return new RequestBinderOptions(
+ RequestBinderOptions.Default.ArgumentNameProvider,
+ RequestBindingError.DefaultArgumentRequired.WithCode(AcmeRequired),
+ RequestBindingError.DefaultArgumentInvalid.WithCode(AcmeInvalid));
}
private static BookingRequest Request(string? guestEmail = "a@b.c", StayDto? stay = null,
@@ -37,12 +40,12 @@ public void PublicDefaultCodesAreExposed() {
[Fact(DisplayName = "Options without custom codes keep the default structural codes.")]
public void OptionsDefaultToTheStructuralCodes() {
- Check.That(RequestBinderOptions.Default.ArgumentRequiredCode == RequestBindingError.DefaultArgumentRequiredCode).IsTrue();
- Check.That(RequestBinderOptions.Default.ArgumentInvalidCode == RequestBindingError.DefaultArgumentInvalidCode).IsTrue();
+ Check.That(RequestBinderOptions.Default.ArgumentRequired.Code == RequestBindingError.DefaultArgumentRequiredCode).IsTrue();
+ Check.That(RequestBinderOptions.Default.ArgumentInvalid.Code == RequestBindingError.DefaultArgumentInvalidCode).IsTrue();
var namesOnly = new RequestBinderOptions(RequestBinderOptions.Default.ArgumentNameProvider);
- Check.That(namesOnly.ArgumentRequiredCode == RequestBindingError.DefaultArgumentRequiredCode).IsTrue();
- Check.That(namesOnly.ArgumentInvalidCode == RequestBindingError.DefaultArgumentInvalidCode).IsTrue();
+ Check.That(namesOnly.ArgumentRequired.Code == RequestBindingError.DefaultArgumentRequiredCode).IsTrue();
+ Check.That(namesOnly.ArgumentInvalid.Code == RequestBindingError.DefaultArgumentInvalidCode).IsTrue();
}
[Fact(DisplayName = "By default, a missing required argument still records REQUEST_ARGUMENT_REQUIRED.")]
diff --git a/FirstClassErrors.RequestBinder.UnitTests/StructuralMessageOverrideTests.cs b/FirstClassErrors.RequestBinder.UnitTests/StructuralMessageOverrideTests.cs
new file mode 100644
index 00000000..53db91c0
--- /dev/null
+++ b/FirstClassErrors.RequestBinder.UnitTests/StructuralMessageOverrideTests.cs
@@ -0,0 +1,158 @@
+#region Usings declarations
+
+using System.Globalization;
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.RequestBinder.UnitTests;
+
+///
+/// The binder's structural public messages are overridable and localizable through the same options seam as the
+/// codes: a bundles a code with its message builder. These tests lock the
+/// default rendering, the message-only override, the code-and-message override as one unit, the per-emission
+/// localization, and the guarantee that custom options which never touch the definitions keep the shipped defaults.
+///
+public sealed class StructuralMessageOverrideTests {
+
+ private static BookingRequest MissingEmail() {
+ return new BookingRequest(null, "REF-1", null, null, null, null, null);
+ }
+
+ private static Error BindMissingEmail(RequestBinderOptions? options = null) {
+ RequestBinderEnvelopeStage start = options is null
+ ? Bind.PropertiesOf(MissingEmail())
+ : Bind.WithOptions(options).PropertiesOf(MissingEmail());
+
+ var bind = start.FailWith(BookingEnvelopeError.CommandInvalid);
+ bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+
+ return bind.New(_ => "x").Error!.InnerErrors.Single();
+ }
+
+ private static Error BindInvalidEmail(RequestBinderOptions options) {
+ var request = new BookingRequest("not-an-email", "REF-1", null, null, null, null, null);
+ var bind = Bind.WithOptions(options).PropertiesOf(request).FailWith(BookingEnvelopeError.CommandInvalid);
+ bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+
+ return bind.New(_ => "x").Error!.InnerErrors.Single();
+ }
+
+ // ── The default rendering is preserved (the "same output out of the box" guarantee) ───────────────────
+
+ [Fact(DisplayName = "By default, a missing required argument carries the shipped English public messages.")]
+ public void DefaultMessagesArePreserved() {
+ Error error = BindMissingEmail();
+
+ Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ Check.That(error.ShortMessage).IsEqualTo("A required argument is missing.");
+ Check.That(error.DetailedMessage).IsEqualTo("The argument 'GuestEmail' is required.");
+ }
+
+ // ── Overriding the messages, keeping the default code ─────────────────────────────────────────────────
+
+ [Fact(DisplayName = "Overriding only the messages changes the public text while keeping the default code.")]
+ public void MessageOverrideKeepsDefaultCode() {
+ var options = new RequestBinderOptions(
+ RequestBinderOptions.Default.ArgumentNameProvider,
+ RequestBindingError.DefaultArgumentRequired.WithMessage(
+ argumentPath => new BindingMessage("This field is mandatory.", $"Please provide '{argumentPath}'.")));
+
+ Error error = BindMissingEmail(options);
+
+ Check.That(error.Code == RequestBindingError.DefaultArgumentRequiredCode).IsTrue(); // code untouched
+ Check.That(error.ShortMessage).IsEqualTo("This field is mandatory.");
+ Check.That(error.DetailedMessage).IsEqualTo("Please provide 'GuestEmail'.");
+ }
+
+ // ── Overriding code and message together, as one coherent unit ────────────────────────────────────────
+
+ [Fact(DisplayName = "A definition can carry a custom code and custom messages together; both flow to the raised error.")]
+ public void CodeAndMessageOverrideTogether() {
+ var options = new RequestBinderOptions(
+ RequestBinderOptions.Default.ArgumentNameProvider,
+ new BinderErrorDefinition(
+ ErrorCode.Create("ACME_REQUIRED"),
+ argumentPath => new BindingMessage("Champ obligatoire.", $"Le champ '{argumentPath}' est obligatoire.")));
+
+ Error error = BindMissingEmail(options);
+
+ Check.That(error.Code.ToString()).IsEqualTo("ACME_REQUIRED");
+ Check.That(error.ShortMessage).IsEqualTo("Champ obligatoire.");
+ Check.That(error.DetailedMessage).IsEqualTo("Le champ 'GuestEmail' est obligatoire.");
+ }
+
+ // ── Localization is per-emission: one options instance serves several languages ───────────────────────
+
+ [Fact(DisplayName = "The message builder is evaluated per emission, so one options instance localizes by ambient culture.")]
+ public void MessageIsLocalizedPerEmission() {
+ var options = new RequestBinderOptions(
+ RequestBinderOptions.Default.ArgumentNameProvider,
+ RequestBindingError.DefaultArgumentRequired.WithMessage(Localized));
+
+ CultureInfo original = CultureInfo.CurrentUICulture;
+ try {
+ CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr");
+ Check.That(BindMissingEmail(options).ShortMessage).IsEqualTo("Un argument requis est manquant.");
+
+ CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en");
+ Check.That(BindMissingEmail(options).ShortMessage).IsEqualTo("A required argument is missing.");
+ } finally {
+ CultureInfo.CurrentUICulture = original;
+ }
+ }
+
+ private static BindingMessage Localized(string argumentPath) {
+ return CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "fr"
+ ? new BindingMessage("Un argument requis est manquant.", $"L'argument '{argumentPath}' est requis.")
+ : new BindingMessage("A required argument is missing.", $"The argument '{argumentPath}' is required.");
+ }
+
+ // ── The invalid path has its own message wiring — override it too ─────────────────────────────────────
+
+ [Fact(DisplayName = "Overriding only the invalid message changes the public text, keeps the default code, and still wraps the cause.")]
+ public void InvalidMessageOverrideKeepsCodeAndCause() {
+ var options = new RequestBinderOptions(
+ RequestBinderOptions.Default.ArgumentNameProvider,
+ argumentInvalid: RequestBindingError.DefaultArgumentInvalid.WithMessage(
+ argumentPath => new BindingMessage("The value is malformed.", $"'{argumentPath}' could not be parsed.")));
+
+ Error error = BindInvalidEmail(options);
+
+ Check.That(error.Code == RequestBindingError.DefaultArgumentInvalidCode).IsTrue(); // code untouched
+ Check.That(error.ShortMessage).IsEqualTo("The value is malformed.");
+ Check.That(error.DetailedMessage).IsEqualTo("'GuestEmail' could not be parsed.");
+ Check.That(error.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID"); // cause still wrapped
+ }
+
+ [Fact(DisplayName = "An invalid definition can carry a custom code and custom messages together; both flow, and the cause is still wrapped.")]
+ public void InvalidCodeAndMessageOverrideTogether() {
+ var options = new RequestBinderOptions(
+ RequestBinderOptions.Default.ArgumentNameProvider,
+ argumentInvalid: new BinderErrorDefinition(
+ ErrorCode.Create("ACME_INVALID"),
+ argumentPath => new BindingMessage("Valeur invalide.", $"Le champ '{argumentPath}' est invalide.")));
+
+ Error error = BindInvalidEmail(options);
+
+ Check.That(error.Code.ToString()).IsEqualTo("ACME_INVALID");
+ Check.That(error.ShortMessage).IsEqualTo("Valeur invalide.");
+ Check.That(error.DetailedMessage).IsEqualTo("Le champ 'GuestEmail' est invalide.");
+ Check.That(error.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID");
+ }
+
+ // ── Custom options that never touch the definitions keep the shipped defaults ──────────────────────────
+
+ [Fact(DisplayName = "Options built only for a naming policy keep the default code AND the default messages.")]
+ public void CustomOptionsWithoutOverridesKeepDefaults() {
+ var namingOnly = new RequestBinderOptions(RequestBinderOptions.Default.ArgumentNameProvider);
+
+ Error error = BindMissingEmail(namingOnly);
+
+ Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ Check.That(error.ShortMessage).IsEqualTo("A required argument is missing.");
+ Check.That(error.DetailedMessage).IsEqualTo("The argument 'GuestEmail' is required.");
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder/BinderErrorDefinition.cs b/FirstClassErrors.RequestBinder/BinderErrorDefinition.cs
new file mode 100644
index 00000000..688c416d
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/BinderErrorDefinition.cs
@@ -0,0 +1,69 @@
+namespace FirstClassErrors.RequestBinder;
+
+///
+/// The definition of one of the binder's structural errors: its and its public messages, kept
+/// together so a consumer overrides them as one coherent unit — never a code stranded from its message. The message
+/// is a builder over the failing argument path, invoked when the error is raised (not when the options are
+/// built), so it may read the ambient culture (CultureInfo.CurrentUICulture) and return a localized message
+/// per request, consistent with the library's internationalization pattern.
+///
+///
+/// The type is immutable: and return a new definition rather than
+/// mutating this one, so deriving a consumer override from a shared default (for example
+/// ) never alters the default other callers see.
+///
+public sealed class BinderErrorDefinition {
+
+ #region Fields declarations
+
+ private readonly Func _message;
+
+ #endregion
+
+ #region Constructors declarations
+
+ /// Instantiates a structural-error definition from its code and its public-message builder.
+ /// The structural error code raised for this failure.
+ ///
+ /// Builds the public messages for a given argument path. Invoked at error emission, so it may resolve
+ /// culture-specific resources; it must not be null, but the it returns is
+ /// taken as-is (a missing short message is coalesced downstream, never rejected).
+ ///
+ /// Thrown when or is null.
+ public BinderErrorDefinition(ErrorCode code, Func message) {
+ if (code is null) { throw new ArgumentNullException(nameof(code)); }
+ if (message is null) { throw new ArgumentNullException(nameof(message)); }
+
+ Code = code;
+ _message = message;
+ }
+
+ #endregion
+
+ /// The structural error code raised for this failure.
+ public ErrorCode Code { get; }
+
+ /// Builds the public messages for the failing argument at .
+ /// The full path of the failing argument (for example Guests[1].FirstName).
+ /// The public messages to attach to the raised error.
+ public BindingMessage GetMessage(string argumentPath) {
+ return _message(argumentPath);
+ }
+
+ /// Returns a copy of this definition raising instead, keeping the same messages.
+ /// The structural error code the copy raises.
+ /// A new definition with the given code and this definition's message builder.
+ /// Thrown when is null.
+ public BinderErrorDefinition WithCode(ErrorCode code) {
+ return new BinderErrorDefinition(code, _message);
+ }
+
+ /// Returns a copy of this definition building its messages with instead, keeping the same code.
+ /// The public-message builder the copy uses.
+ /// A new definition with this definition's code and the given message builder.
+ /// Thrown when is null.
+ public BinderErrorDefinition WithMessage(Func message) {
+ return new BinderErrorDefinition(Code, message);
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder/BindingMessage.cs b/FirstClassErrors.RequestBinder/BindingMessage.cs
new file mode 100644
index 00000000..fa3d31f3
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/BindingMessage.cs
@@ -0,0 +1,30 @@
+namespace FirstClassErrors.RequestBinder;
+
+///
+/// The public messages of a binder structural error: the mandatory short summary surfaced to an end user or API
+/// client, and an optional controlled detail. It is a plain carrier, not a validated value object: following the
+/// library's "manufacturing an error never throws" doctrine it rejects nothing — a null or whitespace
+/// is coalesced to downstream, exactly as a
+/// directly-authored error would be.
+///
+public sealed class BindingMessage {
+
+ #region Constructors declarations
+
+ /// Instantiates the public messages of a binder structural error.
+ /// The mandatory short public summary, safe to surface to an end user or API client.
+ /// An optional, controlled public detail. Defaults to null.
+ public BindingMessage(string shortMessage, string? detailedMessage = null) {
+ ShortMessage = shortMessage;
+ DetailedMessage = detailedMessage;
+ }
+
+ #endregion
+
+ /// The mandatory short public summary of the error.
+ public string ShortMessage { get; }
+
+ /// The optional, controlled public detail of the error.
+ public string? DetailedMessage { get; }
+
+}
diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs
index f94eef9e..d697d9f5 100644
--- a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs
+++ b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs
@@ -56,7 +56,7 @@ public RequiredField AsRequired(Func nested = NestedBinder();
Outcome outcome = bindNested(nested);
if (outcome.IsFailure) {
- _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalidCode));
+ _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalid));
return new RequiredField(_binder, default!);
}
@@ -83,7 +83,7 @@ public OptionalReferenceField AsOptionalReference(Func nested = NestedBinder();
Outcome outcome = bindNested(nested);
if (outcome.IsFailure) {
- _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalidCode));
+ _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalid));
return new OptionalReferenceField(_binder, value: null);
}
diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs
index e70e026d..710fe25b 100644
--- a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs
+++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs
@@ -82,7 +82,7 @@ private RequiredField> BindElements(Func nested = new(element!, _envelope, _binder.Options, elementPath);
Outcome outcome = bindElement(nested);
if (outcome.IsFailure) {
- _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath, _binder.Options.ArgumentInvalidCode));
+ _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath, _binder.Options.ArgumentInvalid));
}
return outcome;
diff --git a/FirstClassErrors.RequestBinder/NestedFailure.cs b/FirstClassErrors.RequestBinder/NestedFailure.cs
index 34aaa8fd..40528391 100644
--- a/FirstClassErrors.RequestBinder/NestedFailure.cs
+++ b/FirstClassErrors.RequestBinder/NestedFailure.cs
@@ -21,12 +21,12 @@ internal static class NestedFailure {
/// The failure the nested binding returned.
/// The envelope the nested binder's build terminal produced, or null when it built none.
/// The argument path to attach when wrapping.
- /// The code to wrap under — the parent binder's configured .
+ /// The structural-error definition to wrap under — the parent binder's configured .
/// The error to record on the parent binder.
- internal static PrimaryPortError Group(Error error, PrimaryPortError? nestedEnvelope, string argumentPath, ErrorCode argumentInvalidCode) {
+ internal static PrimaryPortError Group(Error error, PrimaryPortError? nestedEnvelope, string argumentPath, BinderErrorDefinition argumentInvalid) {
return ReferenceEquals(error, nestedEnvelope)
? (PrimaryPortError)error
- : RequestBindingError.ArgumentInvalid(argumentInvalidCode, argumentPath, error);
+ : RequestBindingError.ArgumentInvalid(argumentInvalid, argumentPath, error);
}
#endregion
diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs
index a8104f69..e589761d 100644
--- a/FirstClassErrors.RequestBinder/RequestBinder.cs
+++ b/FirstClassErrors.RequestBinder/RequestBinder.cs
@@ -232,14 +232,14 @@ internal void Record(PrimaryPortError error) {
_errors.Add(error);
}
- /// Records a missing-required-argument failure at under this binder's configured .
+ /// Records a missing-required-argument failure at under this binder's configured .
internal void RecordArgumentRequired(string argumentPath) {
- Record(RequestBindingError.ArgumentRequired(Options.ArgumentRequiredCode, argumentPath));
+ Record(RequestBindingError.ArgumentRequired(Options.ArgumentRequired, argumentPath));
}
- /// Records a present-but-invalid-argument failure at under this binder's configured , wrapping .
+ /// Records a present-but-invalid-argument failure at under this binder's configured , wrapping .
internal void RecordArgumentInvalid(string argumentPath, Error cause) {
- Record(RequestBindingError.ArgumentInvalid(Options.ArgumentInvalidCode, argumentPath, cause));
+ Record(RequestBindingError.ArgumentInvalid(Options.ArgumentInvalid, argumentPath, cause));
}
///
diff --git a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs
index 08e1b7cd..3c1ac736 100644
--- a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs
+++ b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs
@@ -21,7 +21,7 @@ public sealed class RequestBinderOptions {
/// once at application startup — before any binding — to configure the binder host-wide without threading
/// options through every call; a per-call still overrides it. The first bind
/// reads it and thereby freezes it, so the default cannot drift once binding has begun. Defaults to the
- /// built-in options (C# property names and the default structural codes).
+ /// built-in options (C# property names and the default structural-error definitions).
///
/// Thrown when set to null.
/// Thrown when set after the first bind has already read it.
@@ -76,24 +76,27 @@ internal static IDisposable OverrideDefaultForTests(RequestBinderOptions options
#region Constructors declarations
- /// Instantiates options with the given argument-name provider and, optionally, custom structural error codes.
+ /// Instantiates options with the given argument-name provider and, optionally, custom structural-error definitions.
/// The provider resolving the argument name of a bound DTO property.
- ///
- /// The code raised when a required argument is missing. null keeps the default
- /// (REQUEST_ARGUMENT_REQUIRED); pass a
- /// code of your own catalog's convention to keep the binder's failures consistent with your other error codes.
+ ///
+ /// The definition — code and public messages, kept together — raised when a required argument is missing.
+ /// null keeps the default
+ /// (REQUEST_ARGUMENT_REQUIRED and its English messages). Derive from the default with
+ /// / to align the
+ /// code with your catalog, localize the messages, or both — code and message stay one coherent unit.
///
- ///
- /// The code raised when an argument is present but fails to convert. null keeps the default
- /// (REQUEST_ARGUMENT_INVALID).
+ ///
+ /// The definition — code and public messages, kept together — raised when an argument is present but fails to
+ /// convert. null keeps the default
+ /// (REQUEST_ARGUMENT_INVALID and its English messages).
///
/// Thrown when is null.
- public RequestBinderOptions(IArgumentNameProvider argumentNameProvider, ErrorCode? argumentRequiredCode = null, ErrorCode? argumentInvalidCode = null) {
+ public RequestBinderOptions(IArgumentNameProvider argumentNameProvider, BinderErrorDefinition? argumentRequired = null, BinderErrorDefinition? argumentInvalid = null) {
if (argumentNameProvider is null) { throw new ArgumentNullException(nameof(argumentNameProvider)); }
ArgumentNameProvider = argumentNameProvider;
- ArgumentRequiredCode = argumentRequiredCode ?? RequestBindingError.DefaultArgumentRequiredCode;
- ArgumentInvalidCode = argumentInvalidCode ?? RequestBindingError.DefaultArgumentInvalidCode;
+ ArgumentRequired = argumentRequired ?? RequestBindingError.DefaultArgumentRequired;
+ ArgumentInvalid = argumentInvalid ?? RequestBindingError.DefaultArgumentInvalid;
}
#endregion
@@ -101,11 +104,11 @@ public RequestBinderOptions(IArgumentNameProvider argumentNameProvider, ErrorCod
/// The provider resolving the argument name of a bound DTO property (see ).
public IArgumentNameProvider ArgumentNameProvider { get; }
- /// The code the binder raises when a required argument is missing (defaults to REQUEST_ARGUMENT_REQUIRED).
- public ErrorCode ArgumentRequiredCode { get; }
+ /// The definition — code and public messages — the binder raises when a required argument is missing (defaults to ).
+ public BinderErrorDefinition ArgumentRequired { get; }
- /// The code the binder raises when an argument is present but fails to convert (defaults to REQUEST_ARGUMENT_INVALID).
- public ErrorCode ArgumentInvalidCode { get; }
+ /// The definition — code and public messages — the binder raises when an argument is present but fails to convert (defaults to ).
+ public BinderErrorDefinition ArgumentInvalid { get; }
#region Nested types
diff --git a/FirstClassErrors.RequestBinder/RequestBindingError.cs b/FirstClassErrors.RequestBinder/RequestBindingError.cs
index 20478edb..0e97ad7c 100644
--- a/FirstClassErrors.RequestBinder/RequestBindingError.cs
+++ b/FirstClassErrors.RequestBinder/RequestBindingError.cs
@@ -16,41 +16,73 @@ public static class RequestBindingError {
#region Statics members declarations
+ ///
+ /// The default definition — code and public messages, kept together — of the missing-required-argument failure:
+ /// the code REQUEST_ARGUMENT_REQUIRED and its English messages. A consumer overrides it through
+ /// , deriving from this default with
+ /// / to change the
+ /// code, the messages, or both; whatever is left untouched keeps the value shown here.
+ ///
+ public static BinderErrorDefinition DefaultArgumentRequired { get; } =
+ new BinderErrorDefinition(
+ ErrorCode.Create("REQUEST_ARGUMENT_REQUIRED"),
+ argumentPath => new BindingMessage(
+ "A required argument is missing.",
+ $"The argument '{argumentPath}' is required."));
+
+ ///
+ /// The default definition — code and public messages, kept together — of the present-but-invalid-argument
+ /// failure: the code REQUEST_ARGUMENT_INVALID and its English messages. Override it through
+ /// , exactly as for .
+ ///
+ public static BinderErrorDefinition DefaultArgumentInvalid { get; } =
+ new BinderErrorDefinition(
+ ErrorCode.Create("REQUEST_ARGUMENT_INVALID"),
+ argumentPath => new BindingMessage(
+ "An argument is invalid.",
+ $"The argument '{argumentPath}' is invalid."));
+
///
/// The default code raised when a required argument is missing (REQUEST_ARGUMENT_REQUIRED), unless a
- /// consumer overrides it through . Exposed so a
- /// consumer can branch on the binder's structural failures symbolically rather than by string.
+ /// consumer overrides it through . Exposed so a consumer can
+ /// branch on the binder's structural failures symbolically rather than by string.
///
- public static ErrorCode DefaultArgumentRequiredCode => Code.ArgumentRequired;
+ public static ErrorCode DefaultArgumentRequiredCode => DefaultArgumentRequired.Code;
///
/// The default code raised when an argument is present but fails to convert (REQUEST_ARGUMENT_INVALID),
- /// unless a consumer overrides it through . Exposed so a
+ /// unless a consumer overrides it through . Exposed so a
/// consumer can branch on the binder's structural failures symbolically rather than by string.
///
- public static ErrorCode DefaultArgumentInvalidCode => Code.ArgumentInvalid;
+ public static ErrorCode DefaultArgumentInvalidCode => DefaultArgumentInvalid.Code;
///
/// The argument at is required but was absent from the request.
///
- /// Non-transient by nature: resubmitting the same request cannot succeed.
+ /// The structural-error definition to raise — the binder's configured , defaulting to .
+ /// The full path of the missing argument.
+ ///
+ /// Non-transient by nature: resubmitting the same request cannot succeed. The diagnostic message stays in the
+ /// library's internal language (English) by convention; only the public messages are localizable, through
+ /// .
+ ///
[DocumentedBy(nameof(ArgumentRequiredDocumentation))]
- internal static PrimaryPortError ArgumentRequired(ErrorCode code, string argumentPath) {
+ internal static PrimaryPortError ArgumentRequired(BinderErrorDefinition definition, string argumentPath) {
+ BindingMessage message = definition.GetMessage(argumentPath);
+
return PrimaryPortError.Create(
- code,
+ definition.Code,
$"Argument '{argumentPath}' is required but was missing from the request.",
Transience.NonTransient,
ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath))
- .WithPublicMessage(
- "A required argument is missing.",
- $"The argument '{argumentPath}' is required.");
+ .WithPublicMessage(message.ShortMessage, message.DetailedMessage);
}
///
/// The argument at was present but failed to convert; the conversion error is
/// attached as the inner error.
///
- /// The structural code to raise — the binder's configured code, defaulting to .
+ /// The structural-error definition to raise — the binder's configured , defaulting to .
/// The full path of the failing argument.
///
/// The error the converter failed with. Converters must fail with a or a
@@ -59,7 +91,7 @@ internal static PrimaryPortError ArgumentRequired(ErrorCode code, string argumen
///
/// Thrown when belongs to another error family.
[DocumentedBy(nameof(ArgumentInvalidDocumentation))]
- internal static PrimaryPortError ArgumentInvalid(ErrorCode code, string argumentPath, Error cause) {
+ internal static PrimaryPortError ArgumentInvalid(BinderErrorDefinition definition, string argumentPath, Error cause) {
PrimaryPortInnerErrors innerErrors = new();
switch (cause) {
case DomainError domainError:
@@ -75,14 +107,14 @@ internal static PrimaryPortError ArgumentInvalid(ErrorCode code, string argument
$"The converter of argument '{argumentPath}' failed with a {cause.GetType().Name}; a converter must fail with a DomainError or a PrimaryPortError.");
}
+ BindingMessage message = definition.GetMessage(argumentPath);
+
return PrimaryPortError.Create(
- code,
+ definition.Code,
$"Argument '{argumentPath}' is invalid.",
innerErrors,
ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath))
- .WithPublicMessage(
- "An argument is invalid.",
- $"The argument '{argumentPath}' is invalid.");
+ .WithPublicMessage(message.ShortMessage, message.DetailedMessage);
}
private static ErrorDocumentation ArgumentRequiredDocumentation() {
@@ -95,7 +127,7 @@ private static ErrorDocumentation ArgumentRequiredDocumentation() {
.AndDiagnostic("The argument name reported in the path does not match the wire format (the binder uses the C# property name unless an IArgumentNameProvider is configured).",
ErrorOrigin.Internal,
"Configure an IArgumentNameProvider aligned with the serializer naming policy.")
- .WithExamples(() => ArgumentRequired(Code.ArgumentRequired, "Guests[1].FirstName"));
+ .WithExamples(() => ArgumentRequired(DefaultArgumentRequired, "Guests[1].FirstName"));
}
private static ErrorDocumentation ArgumentInvalidDocumentation() {
@@ -108,7 +140,7 @@ private static ErrorDocumentation ArgumentInvalidDocumentation() {
.AndDiagnostic("The converter rejects values the contract intends to accept (over-strict parsing rule).",
ErrorOrigin.Internal,
"Review the value object's parsing rule against the API contract.")
- .WithExamples(() => ArgumentInvalid(Code.ArgumentInvalid, "GuestEmail", SampleCause()));
+ .WithExamples(() => ArgumentInvalid(DefaultArgumentInvalid, "GuestEmail", SampleCause()));
}
/// A representative converter failure used only by the documentation example above.
@@ -123,17 +155,6 @@ private static DomainError SampleCause() {
#region Nested types declarations
- private static class Code {
-
- #region Statics members declarations
-
- public static readonly ErrorCode ArgumentRequired = ErrorCode.Create("REQUEST_ARGUMENT_REQUIRED");
- public static readonly ErrorCode ArgumentInvalid = ErrorCode.Create("REQUEST_ARGUMENT_INVALID");
-
- #endregion
-
- }
-
private static class ErrCtxKey {
#region Statics members declarations
diff --git a/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.fr.md b/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.fr.md
index f3728553..2b6aea52 100644
--- a/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.fr.md
+++ b/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.fr.md
@@ -2,7 +2,7 @@
🌍 🇬🇧 [English](0016-make-the-binders-structural-error-codes-configurable.md) · 🇫🇷 Français (ce fichier)
-**Statut :** Proposé
+**Statut :** Remplacé par [ADR-0018](0018-bundle-the-binders-structural-error-code-and-messages.fr.md)
**Date :** 2026-07-18
**Décideurs :** Reefact
diff --git a/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.md b/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.md
index 6be4df54..889deac5 100644
--- a/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.md
+++ b/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.md
@@ -2,7 +2,7 @@
🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0016-make-the-binders-structural-error-codes-configurable.fr.md)
-**Status:** Proposed
+**Status:** Superseded by [ADR-0018](0018-bundle-the-binders-structural-error-code-and-messages.md)
**Date:** 2026-07-18
**Decision Makers:** Reefact
diff --git a/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.fr.md b/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.fr.md
new file mode 100644
index 00000000..9d2a8b79
--- /dev/null
+++ b/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.fr.md
@@ -0,0 +1,146 @@
+# ADR-0018 | Regrouper le code et les messages d’une erreur structurelle du binder dans une seule définition
+
+🌍 🇬🇧 [English](0018-bundle-the-binders-structural-error-code-and-messages.md) · 🇫🇷 Français (ce fichier)
+
+**Statut :** Accepté
+**Date :** 2026-07-18
+**Décideurs :** Reefact
+
+## Contexte
+
+* Le binder fabrique exactement deux erreurs structurelles qui lui sont propres : un
+ argument requis absent (`REQUEST_ARGUMENT_REQUIRED`) et un argument présent mais invalide
+ (`REQUEST_ARGUMENT_INVALID`). L’ADR-0016 a rendu leurs **codes** configurables sur
+ `RequestBinderOptions`, résolvant l’issue #147.
+* Chacune de ces deux erreurs porte aussi des **messages publics** — un résumé court et un
+ détail optionnel — exposés à un utilisateur final ou à un client d’API. Avant cette
+ décision, ces messages étaient des littéraux anglais codés en dur, sans point de
+ surcharge ni de localisation (issue #149).
+* Le reste de la bibliothèque localise les messages publics selon le patron
+ d’internationalisation : la fabrique d’une erreur résout des ressources propres à la
+ culture **au moment où l’erreur est construite**, si bien que le message suit la culture
+ ambiante de la requête ; le message de diagnostic reste dans une seule langue interne par
+ convention.
+* Le binder fabrique lui-même ces deux erreurs — leurs fabriques sont internes — de sorte
+ qu’un consommateur ne peut pas injecter de chaînes localisées comme il le fait lorsqu’il
+ écrit les fabriques de ses propres erreurs. C’est la faille qui brise le récit i18n de la
+ bibliothèque à la frontière de l’adaptateur primaire.
+* `RequestBinderOptions` est la configuration unique, immuable, du point d’entrée du binder,
+ fixée une fois avant que la liaison ne commence (ADR-0012) et héritée par les binders
+ imbriqués ; son défaut applicatif est gelé à la première utilisation.
+* Un code et son message sont un seul concept dans le modèle d’erreurs codées de la
+ bibliothèque. Les exposer comme deux réglages indépendants permet à un consommateur de
+ surcharger l’un et d’oublier l’autre, produisant un code séparé d’un message qui ne lui
+ correspond plus.
+* La bibliothèque est en pré-version (`v0.1.0-preview.1`), non publiée, sans consommateur
+ externe : remodeler la surface des options n’a aucun coût de migration — et cette forme
+ doit être arrêtée avant le gel de l’API v1.
+
+## Décision
+
+Chacune des deux erreurs structurelles du binder est configurée sur `RequestBinderOptions`
+comme une définition unique regroupant son code d’erreur et un constructeur de message
+évalué au moment où l’erreur est levée — remplaçant la surcharge du code seul de l’ADR-0016
+et rendant les messages publics localisables par requête.
+
+## Justification
+
+* Regrouper le code avec son message garde le modèle d’erreurs codées cohérent : les deux
+ facettes qui, ensemble, présentent un échec structurel à un consommateur sont configurées
+ comme une unité, si bien qu’une surcharge ne peut jamais séparer un code de son message —
+ le mode de défaillance qu’autorisaient les deux réglages indépendants.
+* Réutiliser `RequestBinderOptions` (fixée une fois, héritée par les binders imbriqués,
+ ADR-0012) garde un seul endroit et une seule durée de vie pour toute la configuration du
+ binder, le mécanisme même qu’utilisent déjà la politique de nommage et les codes de
+ l’ADR-0016.
+* Faire du message un **constructeur évalué à l’émission**, plutôt qu’une chaîne stockée,
+ est ce qui permet à un même hôte de servir plusieurs langues : il lit la culture ambiante
+ par requête, conformément au patron de la bibliothèque qui résout les messages quand
+ l’erreur est construite. Un message capturé à la construction des options figerait une
+ langue, et la durée de vie « gelé à la première utilisation » des options la rendrait
+ permanente — annulant l’exigence même.
+* Ne localiser que les messages publics, et laisser le diagnostic dans une seule langue
+ interne, préserve la convention selon laquelle les logs d’un même type d’erreur ne se
+ scindent pas selon la langue de la requête.
+* Faire défauter chaque définition sur le code livré et ses messages anglais garde le
+ comportement sans configuration inchangé et le catalogue propre du paquet binder exact ;
+ un consommateur qui ne surcharge rien n’est pas affecté, et les défauts sont exposés pour
+ qu’un consommateur puisse toujours brancher symboliquement sur un code structurel.
+* Remplacer la surcharge du code seul de l’ADR-0016, plutôt qu’ajouter un réglage de message
+ en parallèle, est sans coût de migration tant que la bibliothèque est en pré-version, et
+ arrête la surface des options comme un seul concept avant le gel v1.
+
+## Alternatives considérées
+
+### Un point de surcharge de message séparé, à côté des codes configurables
+
+Considéré parce que c’est le plus petit ajout à la surface que l’ADR-0016 a déjà livrée.
+
+Rejeté parce qu’il recrée exactement le découplage que le modèle d’erreurs codées existe
+pour éviter : un consommateur pourrait aligner le code et oublier le message, ou l’inverse,
+et la configuration du binder se scinderait en deux concepts sans gain.
+
+### Stocker les messages de surcharge comme des chaînes fixes sur les options
+
+Considéré parce qu’une simple chaîne est plus simple qu’un constructeur.
+
+Rejeté parce que les options gèlent à la première utilisation : des chaînes stockées
+figeraient une langue pour la durée de vie du processus — annulant la localisation par
+requête, la seule propriété qu’exige l’issue #149.
+
+### Exposer publiquement les fabriques de messages, pour que le consommateur fabrique les erreurs
+
+Considéré par symétrie avec la façon dont un consommateur localise ses propres erreurs (il
+en écrit les fabriques).
+
+Rejeté parce qu’il ouvre les invariants structurels du binder — transience, la clé de
+contexte du chemin d’argument, le câblage de l’erreur interne — à l’erreur du consommateur.
+Le binder doit continuer de fabriquer ces erreurs et n’exposer que leur présentation : le
+code et les messages publics.
+
+## Conséquences
+
+### Positives
+
+* Le code et les messages structurels du binder se surchargent comme une unité cohérente, et
+ un consommateur servant des clients non anglophones les localise par requête — clôturant
+ l’issue #149.
+* Toute la configuration du binder reste sur un seul objet d’options avec une seule durée de
+ vie (ADR-0012), héritée par les binders imbriqués.
+* Le comportement sans configuration et le catalogue propre du paquet binder sont inchangés
+ (défauts préservés et toujours documentés, et toujours exposés pour le branchement
+ symbolique).
+
+### Négatives
+
+* La surface des options change de forme : la surcharge du code seul consignée par
+ l’ADR-0016 est remplacée par une définition code-et-message. Un changement cassant,
+ acceptable en pré-version.
+* Le message de diagnostic reste non surchargeable et anglais par convention ; un
+ consommateur qui veut un diagnostic localisé n’est délibérément pas servi.
+
+### Risques
+
+* Le constructeur de message d’un consommateur s’exécute pendant la fabrication de l’erreur
+ et pourrait lever, contrairement aux fabriques toujours sûres de la bibliothèque. Cela
+ rejoint le contrat des points d’extension existants (le fournisseur de nom d’argument est
+ aussi du code consommateur invoqué pendant la liaison) et relève de la responsabilité du
+ consommateur.
+
+## Actions de suivi
+
+* Résolu à l’acceptation : l’ADR-0016 est marquée `Superseded`, remplacée par celle-ci —
+ décision du mainteneur.
+* La question de la dérive de catalogue d’un code surchargé, différée par l’ADR-0016 (liée à
+ #140), est inchangée par cette décision.
+
+## Références
+
+* ADR-0016 — rendre configurables les codes d’erreur structurels du binder ; la surcharge du
+ code seul que cette décision remplace.
+* ADR-0012 — fixer les options du binder avant que la liaison ne commence ; la durée de vie
+ des options que cette décision réutilise.
+* Issue #149 — le constat que cette décision résout.
+* Issue #147 — le constat que l’ADR-0016 a résolu (la moitié des codes).
+* [Internationalisation](../../for-users/Internationalisation.fr.md) — le patron de
+ localisation de la bibliothèque que cette décision suit.
diff --git a/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.md b/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.md
new file mode 100644
index 00000000..ece4d52d
--- /dev/null
+++ b/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.md
@@ -0,0 +1,137 @@
+# ADR-0018 | Bundle the binder's structural error code and messages in one definition
+
+🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0018-bundle-the-binders-structural-error-code-and-messages.fr.md)
+
+**Status:** Accepted
+**Date:** 2026-07-18
+**Decision Makers:** Reefact
+
+## Context
+
+* The binder manufactures exactly two structural errors of its own: a missing required
+ argument (`REQUEST_ARGUMENT_REQUIRED`) and a present-but-invalid argument
+ (`REQUEST_ARGUMENT_INVALID`). ADR-0016 made their **codes** configurable on
+ `RequestBinderOptions`, resolving issue #147.
+* Each of these two errors also carries **public messages** — a short summary and an
+ optional detail — surfaced to an end user or an API client. Before this decision those
+ messages were hardcoded English literals with no override or localization hook (issue
+ #149).
+* The rest of the library localizes public messages by the internationalization pattern:
+ an error's factory resolves culture-specific resources **at the point the error is
+ built**, so the message follows the ambient culture of the request; the diagnostic
+ message stays in one internal language by convention.
+* The binder manufactures these two errors itself — their factories are internal — so a
+ consumer cannot inject localized strings the way it does when it writes its own errors'
+ factories. This is the gap that breaks the library's i18n story at the primary-adapter
+ boundary.
+* `RequestBinderOptions` is the binder's single, immutable, entry-point configuration,
+ fixed once before binding begins (ADR-0012) and inherited by nested binders; its
+ application-wide default freezes on first use.
+* A code and its message are one concept in the library's coded-error model. Exposing them
+ as two independent knobs lets a consumer override one and forget the other, producing a
+ code stranded from a message that no longer fits it.
+* The library is pre-release (`v0.1.0-preview.1`), unpublished, with no external consumers,
+ so reshaping the options surface carries no migration cost — and the shape should be
+ settled before the v1 API freeze.
+
+## Decision
+
+Each of the binder's two structural errors is configured on `RequestBinderOptions` as a
+single definition bundling its error code with a message builder evaluated when the error
+is raised — superseding the code-only override of ADR-0016 and making the public messages
+localizable per request.
+
+## Rationale
+
+* Bundling the code with its message keeps the coded-error model coherent: the two facets
+ that together present a structural failure to a consumer are configured as one unit, so
+ an override can never strand a code from its message — the failure mode the two
+ independent knobs allowed.
+* Reusing `RequestBinderOptions` (fixed once, inherited by nested binders, ADR-0012) keeps
+ one place and one lifetime for all binder configuration, the same mechanism the naming
+ policy and ADR-0016's codes already use.
+* Making the message a **builder evaluated at emission**, rather than a stored string, is
+ what lets one host serve several languages: it reads the ambient culture per request,
+ matching the library's pattern of resolving messages when the error is built. A message
+ captured at options-construction time would fix one language, and the options'
+ freeze-on-first-use lifetime would make that permanent — defeating the very requirement.
+* Localizing only the public messages, and leaving the diagnostic in one internal language,
+ preserves the convention that logs for one error type do not fork by request language.
+* Defaulting each definition to the shipped code and its English messages keeps the
+ zero-configuration behaviour unchanged and the binder package's own catalog accurate; a
+ consumer that overrides nothing is unaffected, and the defaults are exposed so a consumer
+ can still branch on a structural code symbolically.
+* Superseding ADR-0016's code-only override, rather than adding a parallel message knob, is
+ free of migration cost while the library is pre-release, and settles the options surface
+ as one concept before the v1 freeze.
+
+## Alternatives Considered
+
+### A separate message hook alongside the configurable codes
+
+Considered because it is the smallest addition to the surface ADR-0016 already shipped.
+
+Rejected because it re-creates exactly the decoupling the coded-error model exists to
+avoid: a consumer could align the code and forget the message, or the reverse, and binder
+configuration would split across two concepts for no gain.
+
+### Store the overriding messages as fixed strings on the options
+
+Considered because a plain string is simpler than a builder.
+
+Rejected because the options freeze on first use, so stored strings would fix one language
+for the process's lifetime — defeating per-request localization, the one property issue
+#149 requires.
+
+### Expose the message-building factories publicly, so a consumer manufactures the errors
+
+Considered for symmetry with how a consumer localizes its own errors (it writes their
+factories).
+
+Rejected because it opens the binder's structural invariants — transience, the
+argument-path context key, the inner-error wiring — to consumer error. The binder must keep
+manufacturing these errors and expose only their presentation: the code and the public
+messages.
+
+## Consequences
+
+### Positive
+
+* The binder's structural code and messages are overridden as one coherent unit, and a
+ consumer serving non-English clients localizes them per request — closing issue #149.
+* All binder configuration stays on one options object with one lifetime (ADR-0012),
+ inherited by nested binders.
+* Zero-configuration behaviour and the binder package's own catalog are unchanged (defaults
+ preserved and still documented, and still exposed for symbolic branching).
+
+### Negative
+
+* The options surface changes shape: the code-only override recorded by ADR-0016 is
+ replaced by a code-and-message definition. A breaking change, acceptable pre-release.
+* The diagnostic message stays non-overridable and English by convention; a consumer that
+ wants a localized diagnostic is deliberately not served.
+
+### Risks
+
+* A consumer's message builder runs during error manufacturing and could throw, unlike the
+ library's own always-safe factories. This matches the existing extension-point contract
+ (the argument-name provider is consumer code invoked during binding too) and is the
+ consumer's responsibility.
+
+## Follow-up Actions
+
+* Resolved on acceptance: ADR-0016 is marked `Superseded` by this ADR, per the maintainer's
+ decision.
+* The overridden-code catalog-drift question deferred by ADR-0016 (relates to #140) is
+ unchanged by this decision.
+
+## References
+
+* ADR-0016 — make the binder's structural error codes configurable; the code-only override
+ this decision supersedes.
+* ADR-0012 — fix the binder options before binding begins; the options lifetime this
+ decision reuses.
+* Issue #149 — the finding this decision resolves.
+* Issue #147 — the finding ADR-0016 resolved (the codes half).
+* [Internationalization](../../for-users/Internationalization.en.md) — the library's
+ localization pattern this decision follows.
diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md
index 48f9c20f..25a48e56 100644
--- a/doc/handwritten/for-maintainers/adr/README.md
+++ b/doc/handwritten/for-maintainers/adr/README.md
@@ -189,5 +189,6 @@ Optional supporting material:
| [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) | Gate distinct collections by cardinality, otherwise by a bounded draw | Proposed |
| [ADR-0014](0014-bind-a-required-list-by-presence-not-cardinality.md) | Bind a required list by presence, not cardinality | Accepted |
| [ADR-0015](0015-cap-any-combine-at-arity-eight.md) | Cap Any.Combine at arity eight | Proposed |
-| [ADR-0016](0016-make-the-binders-structural-error-codes-configurable.md) | Make the binder's structural error codes configurable | Proposed |
+| [ADR-0016](0016-make-the-binders-structural-error-codes-configurable.md) | Make the binder's structural error codes configurable | Superseded |
| [ADR-0017](0017-provide-a-configurable-application-wide-default-for-the-binder-options.md) | Provide a configurable application-wide default for the binder options | Accepted |
+| [ADR-0018](0018-bundle-the-binders-structural-error-code-and-messages.md) | Bundle the binder's structural error code and messages in one definition | Accepted |
diff --git a/doc/handwritten/for-users/RequestBinder.en.md b/doc/handwritten/for-users/RequestBinder.en.md
index 824b0913..0cc47d70 100644
--- a/doc/handwritten/for-users/RequestBinder.en.md
+++ b/doc/handwritten/for-users/RequestBinder.en.md
@@ -398,27 +398,48 @@ created — so `Stay.check_in` is renamed consistently, top to bottom. The entry
deliberately ships only the default (C# property names): which serializer names the
wire keys is the host's knowledge, not the library's.
-## Structural error codes
+## Structural errors: codes and messages
The binder raises two coded errors of its own — `REQUEST_ARGUMENT_REQUIRED` when a
required argument is missing, and `REQUEST_ARGUMENT_INVALID` when one is present but
-fails to convert. These are the only codes the binder manufactures; every other code
+fails to convert. These are the only errors the binder manufactures; every other code
in a failure tree is yours (the converters' errors, and the envelope).
-Because those two codes land in **your** catalog, an API that prefixes its codes can
-override them so they stay consistent with the rest — set them on the options, once,
-alongside the naming policy:
+Each is a `BinderErrorDefinition` — a **code and its public messages, kept together** —
+so you override them as one coherent unit, never a code stranded from its message. Start
+from the exposed default and change the code, the messages, or both; whatever you leave
+untouched keeps the shipped value:
```csharp
var options = new RequestBinderOptions(
new SnakeCaseNames(),
- argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"),
- argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID"));
+ // align the code with your catalog's convention, keep the default messages:
+ argumentRequired: RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")),
+ // a code and its message, defined together in one place:
+ argumentInvalid: new BinderErrorDefinition(
+ ErrorCode.Create("ACME_ARGUMENT_INVALID"),
+ path => new BindingMessage("An argument is invalid.", $"The argument '{path}' is invalid.")));
var bind = Bind.WithOptions(options).PropertiesOf(request).FailWith(PlaceBookingError.Invalid);
```
-The configured codes flow through **every** structural failure — scalars, list
+The message builder runs **when the error is raised**, not when the options are built —
+so it can read the ambient culture (`CultureInfo.CurrentUICulture`) and return a message
+localized per request. A host serving several languages localizes the binder's structural
+messages through its own resource accessor, exactly as it localizes any other error:
+
+```csharp
+argumentRequired: RequestBindingError.DefaultArgumentRequired.WithMessage(
+ path => new BindingMessage(
+ BinderStrings.ArgumentRequired_Short, // .resx, resolved on CurrentUICulture
+ string.Format(BinderStrings.ArgumentRequired_Detailed, path)))
+```
+
+Only the two **public** messages are localizable; the diagnostic message stays in the
+library's internal language (English) by convention, so logs for one structural failure
+never fork by request language. See [Internationalization](Internationalization.en.md).
+
+The configured definitions flow through **every** structural failure — scalars, list
elements, and the inner failures of nested binders, which inherit them.
To **branch** on a binder failure — mapping it to an HTTP status, say — compare the
@@ -433,15 +454,15 @@ if (error.Code == RequestBindingError.DefaultArgumentRequiredCode) { return 422;
`Bind.PropertiesOf(request)` binds with `RequestBinderOptions.Default`. That default is
configurable **once, at application startup** — so a whole host (ASP.NET, a CLI, a
-worker) shares one naming policy and one set of structural codes without threading
-options through every call, and without a DI container:
+worker) shares one naming policy and one set of structural-error definitions without
+threading options through every call, and without a DI container:
```csharp
// Program.cs, before the first bind:
RequestBinderOptions.Default = new RequestBinderOptions(
new SnakeCaseNames(),
- argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"),
- argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID"));
+ argumentRequired: RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")),
+ argumentInvalid: RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("ACME_ARGUMENT_INVALID")));
// anywhere after that — no options threaded through:
var bind = Bind.PropertiesOf(request).FailWith(PlaceBookingError.Invalid);
diff --git a/doc/handwritten/for-users/RequestBinder.fr.md b/doc/handwritten/for-users/RequestBinder.fr.md
index 95d8468e..dca74862 100644
--- a/doc/handwritten/for-users/RequestBinder.fr.md
+++ b/doc/handwritten/for-users/RequestBinder.fr.md
@@ -412,28 +412,51 @@ chaque requête. La bibliothèque ne fournit délibérément que le défaut (nom
propriété C#) : quel sérialiseur nomme les clés du fil relève de la connaissance de
l’hôte, pas de la bibliothèque.
-## Codes d’erreur structurels
+## Erreurs structurelles : codes et messages
Le binder lève deux erreurs codées qui lui sont propres — `REQUEST_ARGUMENT_REQUIRED`
quand un argument requis est absent, et `REQUEST_ARGUMENT_INVALID` quand il est présent
-mais échoue à se convertir. Ce sont les seuls codes que le binder fabrique ; tout autre
+mais échoue à se convertir. Ce sont les seules erreurs que le binder fabrique ; tout autre
code d’un arbre d’échec est le vôtre (les erreurs des convertisseurs, et l’enveloppe).
-Comme ces deux codes atterrissent dans **votre** catalogue, une API qui préfixe ses
-codes peut les surcharger pour qu’ils restent cohérents avec le reste — définissez-les
-sur les options, une seule fois, à côté de la politique de nommage :
+Chacune est un `BinderErrorDefinition` — un **code et ses messages publics, tenus
+ensemble** — que vous surchargez comme une unité cohérente, jamais un code séparé de son
+message. Partez du défaut exposé et changez le code, les messages, ou les deux ; ce que
+vous laissez intact garde la valeur livrée :
```csharp
var options = new RequestBinderOptions(
new SnakeCaseNames(),
- argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"),
- argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID"));
+ // aligner le code sur la convention de votre catalogue, garder les messages par défaut :
+ argumentRequired: RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")),
+ // un code et son message, définis ensemble au même endroit :
+ argumentInvalid: new BinderErrorDefinition(
+ ErrorCode.Create("ACME_ARGUMENT_INVALID"),
+ path => new BindingMessage("An argument is invalid.", $"The argument '{path}' is invalid.")));
var bind = Bind.WithOptions(options).PropertiesOf(request).FailWith(PlaceBookingError.Invalid);
```
-Les codes configurés se propagent à **chaque** échec structurel — scalaires, éléments
-de liste, et les échecs internes des binders imbriqués, qui en héritent.
+Le constructeur de message s’exécute **au moment où l’erreur est levée**, pas à la
+construction des options — il peut donc lire la culture ambiante
+(`CultureInfo.CurrentUICulture`) et renvoyer un message localisé par requête. Un hôte qui
+sert plusieurs langues localise les messages structurels du binder via son propre
+accesseur de ressources, exactement comme il localise toute autre erreur :
+
+```csharp
+argumentRequired: RequestBindingError.DefaultArgumentRequired.WithMessage(
+ path => new BindingMessage(
+ BinderStrings.ArgumentRequired_Short, // .resx, résolu sur CurrentUICulture
+ string.Format(BinderStrings.ArgumentRequired_Detailed, path)))
+```
+
+Seuls les deux messages **publics** sont localisables ; le message de diagnostic reste
+dans la langue interne de la bibliothèque (l’anglais) par convention, pour que les logs
+d’un même échec structurel ne se scindent jamais selon la langue de la requête. Voir
+[Internationalisation](Internationalisation.fr.md).
+
+Les définitions configurées se propagent à **chaque** échec structurel — scalaires,
+éléments de liste, et les échecs internes des binders imbriqués, qui en héritent.
Pour **brancher** sur un échec du binder — le mapper vers un statut HTTP, par exemple —
comparez le code de l’erreur symboliquement, jamais à une chaîne littérale : utilisez le
@@ -448,15 +471,16 @@ if (error.Code == RequestBindingError.DefaultArgumentRequiredCode) { return 422;
`Bind.PropertiesOf(request)` lie avec `RequestBinderOptions.Default`. Ce défaut est
configurable **une seule fois, au démarrage de l’application** — un hôte entier
-(ASP.NET, une CLI, un worker) partage ainsi une politique de nommage et un jeu de codes
-structurels sans faire transiter d’options par chaque appel, et sans conteneur DI :
+(ASP.NET, une CLI, un worker) partage ainsi une politique de nommage et un jeu de
+définitions d’erreurs structurelles sans faire transiter d’options par chaque appel, et
+sans conteneur DI :
```csharp
// Program.cs, avant la première liaison :
RequestBinderOptions.Default = new RequestBinderOptions(
new SnakeCaseNames(),
- argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"),
- argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID"));
+ argumentRequired: RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")),
+ argumentInvalid: RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("ACME_ARGUMENT_INVALID")));
// n’importe où ensuite — aucune option à faire transiter :
var bind = Bind.PropertiesOf(request).FailWith(PlaceBookingError.Invalid);