diff --git a/CLAUDE.md b/CLAUDE.md
index 2bafa53..2f1ff3b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,6 +32,7 @@ errors should stay structured, documented, and close to the code.
* `FirstClassErrors.GenDoc` — error-documentation generator (+ `.UnitTests`)
* `FirstClassErrors.GenDoc.Worker` — background worker for doc generation
* `FirstClassErrors.Cli` — command-line tool
+* `FirstClassErrors.RequestBinder` — request binder for the primary-adapter boundary (+ `.UnitTests`)
* `FirstClassErrors.Usage` — usage examples
* `doc/` — documentation, including the French translation
@@ -93,7 +94,7 @@ The essentials, inlined so they hold even if `AGENTS.md` is not read:
* Follow `.github/pull_request_template.md` for every pull request.
* Do not open a pull request unless I explicitly ask for one.
* PR titles, descriptions, commits, and branch names must be written in English.
-* Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, cli, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit).
+* Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, binder, cli, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit).
* Write every pull request title per [`CONTRIBUTING.md`](CONTRIBUTING.md): name the whole change in English; a single-intention PR mirrors its commit header (`type(scope): description`), a multi-intention PR uses a short descriptive title, and issue references stay in the description, not the title.
* Enable the local commit-message hook once per clone with `git config core.hooksPath .githooks`; the same check runs in CI on every pull request.
* In PR descriptions, do not invent testing results. Only check items that were actually run.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 717241b..55e9e6f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -255,6 +255,7 @@ The scope MAY be provided. When present it MUST be lowercase and MUST be one of:
|---|---|
| `core` | `FirstClassErrors` — the runtime library (`Error`, `Outcome`, `ErrorCode`, `ErrorContextKey`, …) |
| `analyzers` | `FirstClassErrors.Analyzers` — the Roslyn analyzers and their `FCExxx` diagnostics |
+| `binder` | `FirstClassErrors.RequestBinder` — the request binder for the primary-adapter boundary |
| `cli` | `FirstClassErrors.Cli` — the command-line tool |
| `gendoc` | `FirstClassErrors.GenDoc` and its worker — the documentation generator |
| `testing` | `FirstClassErrors.Testing` — the test-support package |
diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs
new file mode 100644
index 0000000..e5fc3c9
--- /dev/null
+++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs
@@ -0,0 +1,396 @@
+#region Usings declarations
+
+using System.Linq.Expressions;
+using System.Reflection;
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.RequestBinder.UnitTests;
+
+///
+/// Pins the edges of the binding contract: the error families a converter may fail with, the wrapping of bare
+/// nested failures, the selector plumbing, the guard clauses, the binding scope's read guards, and the binder's
+/// own error documentation.
+///
+public sealed class BindingContractTests {
+
+ private static BookingRequest Request(string? email = "alice@example.org") {
+ return new BookingRequest(email, "REF-1", null, null,
+ new StayDto("2026-08-10", "2026-08-14"),
+ Tags: null,
+ Guests: [new GuestDto("Alice", null)]);
+ }
+
+ #region Error families a converter may fail with
+
+ [Fact(DisplayName = "A converter may fail with a PrimaryPortError: it is wrapped like a domain failure.")]
+ public void ConverterMayFailWithAPrimaryPortError() {
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.SimpleProperty(r => r.GuestEmail).AsRequired(_ => Outcome.Failure(
+ PrimaryPortError.Create(ErrorCode.Create("TEST_PORT_LEVEL_REJECTION"), "Rejected at the port.", Transience.NonTransient)
+ .WithPublicMessage("Rejected.")));
+
+ Outcome outcome = bind.New(_ => "never");
+ Error invalid = outcome.Error!.InnerErrors.Single();
+ Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_PORT_LEVEL_REJECTION");
+ }
+
+ [Fact(DisplayName = "A converter failing with any other error family is a contract violation, reported by throwing.")]
+ public void ConverterFailingWithAnotherFamilyIsABug() {
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ InfrastructureError foreignFamily =
+ InfrastructureError.Create(ErrorCode.Create("TEST_FOREIGN_FAMILY"), "A family the port envelope cannot hold.",
+ InteractionDirection.Outgoing, Transience.Unknown)
+ .WithPublicMessage("Foreign.");
+
+ InvalidOperationException exception = Assert.Throws(
+ () => { bind.SimpleProperty(r => r.GuestEmail).AsRequired(_ => Outcome.Failure(foreignFamily)); });
+ // The bug-channel exception must locate the bug: which argument, and which unsupported family.
+ Check.That(exception.Message).Contains("GuestEmail");
+ Check.That(exception.Message).Contains("InfrastructureError");
+ }
+
+ #endregion
+
+ #region Bare nested failures are wrapped so the path survives
+
+ [Fact(DisplayName = "A nested binding that fails without building its envelope is wrapped, so the argument path survives.")]
+ public void BareNestedFailureIsWrapped() {
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid)
+ .AsRequired(_ => Outcome.Failure(BookingDomainError.DateInvalid("raw")));
+
+ Outcome outcome = bind.New(_ => "never");
+ Error wrapped = outcome.Error!.InnerErrors.Single();
+ Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Stay");
+ Check.That(wrapped.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_DATE_INVALID");
+ }
+
+ [Fact(DisplayName = "A list element whose nested binding fails without building its envelope is wrapped under its indexed path.")]
+ public void BareNestedElementFailureIsWrapped() {
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid)
+ .AsRequired(_ => Outcome.Failure(BookingDomainError.EmailInvalid("raw")));
+
+ Outcome outcome = bind.New(_ => "never");
+ Error wrapped = outcome.Error!.InnerErrors.Single();
+ Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Guests[0]");
+ }
+
+ private static PrimaryPortError LeafPortError() {
+ return PrimaryPortError.Create(ErrorCode.Create("TEST_NESTED_PORT_LEAF"), "A bare port-level leaf, not a build-terminal envelope.",
+ Transience.NonTransient)
+ .WithPublicMessage("Rejected.");
+ }
+
+ [Fact(DisplayName = "A required nested binding that fails with a bare PrimaryPortError leaf (not its build-terminal envelope) is wrapped, so the path survives.")]
+ public void BareNestedPrimaryPortErrorLeafIsWrapped() {
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid)
+ .AsRequired(_ => Outcome.Failure(LeafPortError()));
+
+ Outcome outcome = bind.New(_ => "never");
+ Error wrapped = outcome.Error!.InnerErrors.Single();
+ // A leaf PrimaryPortError is NOT the nested build-terminal envelope, so it is wrapped under the path — exactly like a
+ // DomainError, and unlike the old by-type check that recorded it as-is and dropped the "Stay" context.
+ Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Stay");
+ Check.That(wrapped.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NESTED_PORT_LEAF");
+ }
+
+ [Fact(DisplayName = "An optional nested binding that fails with a bare PrimaryPortError leaf is wrapped under the path too.")]
+ public void BareOptionalNestedPrimaryPortErrorLeafIsWrapped() {
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid)
+ .AsOptional(_ => Outcome.Failure(LeafPortError()));
+
+ Outcome outcome = bind.New(_ => "never");
+ Error wrapped = outcome.Error!.InnerErrors.Single();
+ Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Stay");
+ Check.That(wrapped.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NESTED_PORT_LEAF");
+ }
+
+ [Fact(DisplayName = "A list element whose nested binding fails with a bare PrimaryPortError leaf is wrapped under its indexed path.")]
+ public void BareNestedElementPrimaryPortErrorLeafIsWrapped() {
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid)
+ .AsRequired(_ => Outcome.Failure(LeafPortError()));
+
+ Outcome outcome = bind.New(_ => "never");
+ Error wrapped = outcome.Error!.InnerErrors.Single();
+ Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Guests[0]");
+ Check.That(wrapped.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NESTED_PORT_LEAF");
+ }
+
+ #endregion
+
+ #region Remaining list shapes
+
+ [Fact(DisplayName = "A required list of complex properties that is missing records REQUEST_ARGUMENT_REQUIRED; its envelope is never invoked.")]
+ public void RequiredComplexListMissing() {
+ var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, Guests: null))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid)
+ .AsRequired(g => g.New(_ => new Guest("never", null)));
+
+ Outcome outcome = bind.New(_ => "never");
+ Error required = outcome.Error!.InnerErrors.Single();
+ Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Guests");
+ }
+
+ [Fact(DisplayName = "A null element that is the first failing element of a simple list is collected in order, like any other.")]
+ public void NullElementAsFirstFailureOfASimpleList() {
+ var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, Tags: ["ok", null, "not ok"], null))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse);
+
+ Outcome outcome = bind.New(_ => "never");
+ Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString()))
+ .ContainsExactly("REQUEST_ARGUMENT_REQUIRED", "REQUEST_ARGUMENT_INVALID");
+ Check.That(outcome.Error!.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
+ .ContainsExactly("Tags[1]", "Tags[2]");
+ }
+
+ [Fact(DisplayName = "An optional list of complex properties that is present still collects its failing elements.")]
+ public void OptionalComplexListPresentCollectsFailures() {
+ var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, Guests: [new GuestDto(null, null)]))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid)
+ .AsOptional(g => {
+ RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired();
+
+ return g.New(s => new Guest(s.Get(firstName), null));
+ });
+
+ Outcome outcome = bind.New(_ => "never");
+ Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_GUEST_INVALID");
+ }
+
+ #endregion
+
+ #region Selector plumbing
+
+ private sealed record Counted(int Count);
+
+ [Fact(DisplayName = "The selector resolver unwraps the Convert node a boxing selector produces.")]
+ public void SelectorResolverUnwrapsConvertNodes() {
+ // Boxing a value-type property forces the compiler to emit a Convert node around the member access.
+ Expression> boxing = c => c.Count;
+
+ PropertyInfo property = PropertySelectors.GetProperty(boxing);
+
+ Check.That(property.Name).IsEqualTo("Count");
+ }
+
+ [Fact(DisplayName = "The selector resolver unwraps EVERY stacked Convert node, not just the outermost.")]
+ public void SelectorResolverUnwrapsNestedConvertNodes() {
+ // Widening then boxing a value-type property stacks two Convert nodes: Convert(Convert(c.Count, long), object).
+ Expression> doublyBoxed = c => (long)c.Count;
+
+ PropertyInfo property = PropertySelectors.GetProperty(doublyBoxed);
+
+ Check.That(property.Name).IsEqualTo("Count");
+ }
+
+ [Fact(DisplayName = "A null selector is a programming error and throws.")]
+ public void NullSelectorThrows() {
+ Check.ThatCode(() => PropertySelectors.GetProperty(null!))
+ .Throws();
+ }
+
+ private sealed class WithField {
+
+ internal int Field = 42;
+
+ }
+
+ [Fact(DisplayName = "A selector pointing at a field rather than a property is rejected: an argument path needs a property name.")]
+ public void FieldSelectorIsRejected() {
+ Expression> fieldSelector = w => w.Field;
+
+ Check.ThatCode(() => PropertySelectors.GetProperty(fieldSelector)).Throws();
+ }
+
+ #endregion
+
+ #region Non-nullable value-type DTO properties are rejected
+
+ private sealed record ValueTypeRequest(int Count, int? OptionalCount);
+
+ [Fact(DisplayName = "Selecting a non-nullable value-type property is rejected: a missing value would be indistinguishable from its default.")]
+ public void NonNullableValueTypePropertyIsRejected() {
+ var bind = Bind.PropertiesOf(new ValueTypeRequest(0, null)).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ // A non-nullable int cannot be null, so "missing" (deserialized to 0) is undetectable -> loud programming error.
+ ArgumentException exception = Assert.Throws(() => { bind.SimpleProperty(r => r.Count); });
+ Check.That(exception.Message).Contains("Count");
+ Check.That(exception.Message).Contains("nullable");
+
+ // A nullable value type is fine: an absent argument arrives as null and is detectable.
+ Check.ThatCode(() => bind.SimpleProperty(r => r.OptionalCount)).DoesNotThrow();
+ }
+
+ #endregion
+
+ #region Guard clauses: every entry point rejects null collaborators
+
+ [Fact(DisplayName = "Every entry point rejects a null collaborator with ArgumentNullException.")]
+ public void GuardClauses() {
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ Check.ThatCode(() => Bind.PropertiesOf(null!)).Throws();
+ Check.ThatCode(() => Bind.PropertiesOf(Request()).FailWith(null!)).Throws();
+ Check.ThatCode(() => bind.WithOptions(null!)).Throws();
+ Check.ThatCode(() => bind.New(null!)).Throws();
+ Check.ThatCode(() => bind.Create(null!)).Throws();
+
+ Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail).AsRequired(null!)).Throws();
+ Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail).AsOptional(null!, "x")).Throws();
+ Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(null!)).Throws();
+ Check.ThatCode(() => bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(null!)).Throws();
+
+ Check.ThatCode(() => bind.ComplexProperty(r => r.Stay).FailWith(null!)).Throws();
+ Check.ThatCode(() => bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(null!)).Throws();
+ Check.ThatCode(() => bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(null!)).Throws();
+
+ Check.ThatCode(() => bind.ListOfSimpleProperties(r => r.Tags).AsRequired(null!)).Throws();
+ Check.ThatCode(() => bind.ListOfSimpleProperties(r => r.Tags).AsOptional(null!)).Throws();
+ Check.ThatCode(() => bind.ListOfComplexProperties(r => r.Guests).FailWith(null!)).Throws();
+ Check.ThatCode(() => bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(null!)).Throws();
+ Check.ThatCode(() => bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(null!)).Throws();
+
+ Check.ThatCode(() => new RequestBinderOptions(null!)).Throws();
+ Check.ThatCode(() => RequestBinderOptions.Default.ArgumentNameProvider.GetArgumentNameFrom(null!)).Throws();
+ }
+
+ #endregion
+
+ #region The binding scope guards its reads
+
+ [Fact(DisplayName = "The binding scope rejects a null field and a field owned by a different binder — on every Get overload, programming errors both.")]
+ public void BindingScopeGuardsItsReads() {
+ var binderA = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+ RequiredField requiredOfA = binderA.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+ OptionalReferenceField optRefOfA = binderA.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse);
+ OptionalValueField optValOfA = binderA.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse);
+
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ // A null field, on each of the three Get overloads (the assembler runs because `bind` recorded no failure):
+ Check.ThatCode(() => bind.New(s => s.Get((RequiredField)null!).Value)).Throws();
+ Check.ThatCode(() => bind.New(s => s.Get((OptionalReferenceField)null!) is null)).Throws();
+ Check.ThatCode(() => bind.New(s => s.Get((OptionalValueField)null!).HasValue)).Throws();
+
+ // A field owned by a different binder is a cross-binder mix-up — rejected loudly on every overload, not read silently.
+ Check.ThatCode(() => bind.New(s => s.Get(requiredOfA).Value)).Throws();
+ Check.ThatCode(() => bind.New(s => s.Get(optRefOfA) is null)).Throws();
+ Check.ThatCode(() => bind.New(s => s.Get(optValOfA).HasValue)).Throws();
+ }
+
+ [Fact(DisplayName = "A cross-binder field read is rejected with a message naming the different-binder cause, not a blank exception.")]
+ public void CrossBinderReadMessageNamesTheCause() {
+ var binderA = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+ RequiredField requiredOfA = binderA.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ InvalidOperationException exception = Assert.Throws(
+ () => { bind.New(s => s.Get(requiredOfA).Value); });
+ Check.That(exception.Message).Contains("different binder");
+ }
+
+ #endregion
+
+ #region The binder-owned errors carry their coded messages
+
+ [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("GuestEmail");
+
+ Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ Check.That(error.ShortMessage).IsEqualTo("A required argument is missing.");
+ Check.That(error.DetailedMessage).Contains("GuestEmail");
+ Check.That(error.DiagnosticMessage).Contains("GuestEmail");
+ Check.That(error.DiagnosticMessage).Contains("required");
+ }
+
+ [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("GuestEmail", BookingDomainError.EmailInvalid("x"));
+
+ Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(error.ShortMessage).IsEqualTo("An argument is invalid.");
+ Check.That(error.DetailedMessage).Contains("GuestEmail");
+ Check.That(error.DiagnosticMessage).Contains("GuestEmail");
+ Check.That(error.DiagnosticMessage).Contains("invalid");
+ Check.That(error.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID");
+ }
+
+ #endregion
+
+ #region The binder's own errors are documented
+
+ [Fact(DisplayName = "Every binder-owned error factory is documented: its documentation method yields a titled documentation with a live example.")]
+ public void BinderOwnedErrorsAreDocumented() {
+ MethodInfo[] documented = typeof(RequestBindingError)
+ .GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)
+ .Where(m => m.GetCustomAttribute() is not null)
+ .ToArray();
+
+ Check.That(documented).HasSize(2);
+
+ foreach (MethodInfo factory in documented) {
+ string documentationMethodName = factory.GetCustomAttribute()!.MethodName;
+ MethodInfo documentationMethod = typeof(RequestBindingError)
+ .GetMethod(documentationMethodName, BindingFlags.Static | BindingFlags.NonPublic)!;
+
+ var documentation = (ErrorDocumentation)documentationMethod.Invoke(null, null)!;
+
+ Check.That(documentation.Title).IsNotEmpty();
+ Check.That(documentation.Examples).Not.IsEmpty();
+ }
+ }
+
+ [Fact(DisplayName = "The REQUEST_ARGUMENT_REQUIRED documentation lists both diagnoses, classified external then internal.")]
+ public void RequiredArgumentDocumentationClassifiesItsDiagnoses() {
+ MethodInfo documentationMethod = typeof(RequestBindingError)
+ .GetMethod("ArgumentRequiredDocumentation", BindingFlags.Static | BindingFlags.NonPublic)!;
+ var documentation = (ErrorDocumentation)documentationMethod.Invoke(null, null)!;
+
+ // Both causes are documented, and a client omitting an argument is an EXTERNAL fault while the name-provider
+ // mismatch is INTERNAL. A dropped diagnosis or a flipped origin would mislead whoever triages the error.
+ Check.That(documentation.Diagnostics.Select(d => d.Origin))
+ .ContainsExactly(ErrorOrigin.External, ErrorOrigin.Internal);
+ }
+
+ [Fact(DisplayName = "The REQUEST_ARGUMENT_INVALID documentation lists both diagnoses, classified external then internal.")]
+ public void InvalidArgumentDocumentationClassifiesItsDiagnoses() {
+ MethodInfo documentationMethod = typeof(RequestBindingError)
+ .GetMethod("ArgumentInvalidDocumentation", BindingFlags.Static | BindingFlags.NonPublic)!;
+ var documentation = (ErrorDocumentation)documentationMethod.Invoke(null, null)!;
+
+ Check.That(documentation.Diagnostics.Select(d => d.Origin))
+ .ContainsExactly(ErrorOrigin.External, ErrorOrigin.Internal);
+ }
+
+ #endregion
+
+}
diff --git a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs
new file mode 100644
index 0000000..fda6d04
--- /dev/null
+++ b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs
@@ -0,0 +1,96 @@
+#region Usings declarations
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.RequestBinder.UnitTests;
+
+///
+/// End-to-end scenario mirroring the reference example of the design spec (issue #126): a full booking command
+/// bound in one pass, exercising every converter shape, then rejoining the Outcome pipeline.
+///
+public sealed class BookingEndToEndTests {
+
+ private static Outcome BindStay(RequestBinder stay) {
+ RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse);
+ RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse);
+
+ return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut)));
+ }
+
+ private static Outcome BindGuest(RequestBinder guest) {
+ RequiredField firstName = guest.SimpleProperty(g => g.FirstName).AsRequired();
+ OptionalReferenceField email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse);
+
+ return guest.New(s => new Guest(s.Get(firstName), s.Get(email)));
+ }
+
+ private static Outcome BindCommand(BookingRequest request) {
+ var bind = Bind.PropertiesOf(request).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+ RequiredField reference = bind.SimpleProperty(r => r.Reference).AsRequired();
+ RequiredField currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR");
+ OptionalValueField maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse);
+ OptionalReferenceField stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay);
+ RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse);
+ RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest);
+
+ return bind.New(s => new BookingCommand(
+ s.Get(email), s.Get(reference), s.Get(currency), s.Get(maxNights),
+ s.Get(stay), s.Get(tags), s.Get(guests)));
+ }
+
+ [Fact(DisplayName = "A fully valid request binds into the complete command and flows through Then/Finally.")]
+ public void FullyValidRequestBindsAndRejoinsThePipeline() {
+ BookingRequest request = new(
+ "alice@example.org", "REF-42", null, "7",
+ new StayDto("2026-08-10", "2026-08-14"),
+ ["vip"],
+ [new GuestDto("Alice", "alice@example.org"), new GuestDto("Bob", null)]);
+
+ string confirmation = BindCommand(request)
+ .Then(command => Outcome.Success($"{command.Reference}:{command.Currency.Code}:{command.MaxNights}:{command.Guests.Count}"))
+ .Finally(result => result, error => $"rejected:{error.Code}");
+
+ Check.That(confirmation).IsEqualTo("REF-42:EUR:7:2");
+ }
+
+ [Fact(DisplayName = "A request failing at every level yields the full error tree of the spec, and no exception.")]
+ public void InvalidRequestYieldsTheFullTree() {
+ BookingRequest request = new(
+ "not-an-email", null, "EURO", "0",
+ new StayDto("not-a-date", "2026-08-14"),
+ ["ok", "not ok"],
+ [new GuestDto("Alice", null), new GuestDto(null, "still-not-an-email")]);
+
+ Outcome outcome = BindCommand(request);
+
+ Check.That(outcome.IsFailure).IsTrue();
+ Error envelope = outcome.Error!;
+ Check.That(envelope.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID");
+
+ // Envelope children, in declaration order:
+ // GuestEmail invalid, Reference missing, Currency invalid, MaxNights invalid,
+ // Stay envelope (present but invalid), Tags[1] invalid, Guests[1] envelope.
+ Check.That(envelope.InnerErrors).HasSize(7);
+ Check.That(envelope.InnerErrors.Select(e => e.Code.ToString())).ContainsExactly(
+ "REQUEST_ARGUMENT_INVALID", // GuestEmail
+ "REQUEST_ARGUMENT_REQUIRED", // Reference
+ "REQUEST_ARGUMENT_INVALID", // Currency
+ "REQUEST_ARGUMENT_INVALID", // MaxNights
+ "TEST_STAY_INVALID", // Stay (nested envelope)
+ "REQUEST_ARGUMENT_INVALID", // Tags[1]
+ "TEST_GUEST_INVALID"); // Guests[1] (nested envelope)
+
+ Error stayEnvelope = envelope.InnerErrors[4];
+ Check.That(stayEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf)).ContainsExactly("Stay.CheckIn");
+ Check.That(stayEnvelope.InnerErrors[0].InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_DATE_INVALID");
+
+ Error guestEnvelope = envelope.InnerErrors[6];
+ Check.That(guestEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
+ .ContainsExactly("Guests[1].FirstName", "Guests[1].Email");
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs
new file mode 100644
index 0000000..f732883
--- /dev/null
+++ b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs
@@ -0,0 +1,87 @@
+#region Usings declarations
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.RequestBinder.UnitTests;
+
+public sealed class ComplexPropertyBindingTests {
+
+ private static Outcome BindStay(RequestBinder stay) {
+ RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse);
+ RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse);
+
+ return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut)));
+ }
+
+ private static BookingRequest RequestWith(StayDto? stay) {
+ return new BookingRequest("alice@example.org", "REF-1", "EUR", null, stay, Tags: null, Guests: null);
+ }
+
+ [Fact(DisplayName = "A required complex property binds through its nested binder when every field is valid.")]
+ public void RequiredComplexBinds() {
+ var bind = Bind.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ RequiredField stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay);
+
+ Outcome outcome = bind.New(s => s.Get(stay));
+ Check.That(outcome.IsSuccess).IsTrue();
+ Check.That(outcome.GetResultOrThrow().CheckIn.Value).IsEqualTo(new DateOnly(2026, 8, 10));
+ }
+
+ [Fact(DisplayName = "A failed nested binding surfaces as its own envelope, whose inner paths are prefixed with the property name.")]
+ public void NestedFailureSurfacesAsItsEnvelopeWithPrefixedPaths() {
+ var bind = Bind.PropertiesOf(RequestWith(new StayDto("not-a-date", null))).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay);
+
+ Outcome outcome = bind.New(_ => "never");
+ Check.That(outcome.IsFailure).IsTrue();
+
+ Error stayEnvelope = outcome.Error!.InnerErrors.Single();
+ Check.That(stayEnvelope.Code.ToString()).IsEqualTo("TEST_STAY_INVALID");
+
+ // Both nested failures are collected, and their paths carry the "Stay." prefix.
+ Check.That(stayEnvelope.InnerErrors).HasSize(2);
+ Check.That(stayEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
+ .ContainsExactly("Stay.CheckIn", "Stay.CheckOut");
+ Check.That(stayEnvelope.InnerErrors[0].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(stayEnvelope.InnerErrors[1].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ }
+
+ [Fact(DisplayName = "A required complex property that is missing records REQUEST_ARGUMENT_REQUIRED; its envelope is never invoked.")]
+ public void RequiredComplexMissing() {
+ var bind = Bind.PropertiesOf(RequestWith(stay: null)).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay);
+
+ Outcome outcome = bind.New(_ => "never");
+ Error required = outcome.Error!.InnerErrors.Single();
+ Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Stay");
+ }
+
+ [Fact(DisplayName = "An optional complex property yields null when absent — recording nothing — and binds when present.")]
+ public void OptionalComplex() {
+ var absent = Bind.PropertiesOf(RequestWith(stay: null)).FailWith(BookingEnvelopeError.CommandInvalid);
+ OptionalReferenceField none = absent.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay);
+ Check.That(absent.New(s => s.Get(none) is null).GetResultOrThrow()).IsTrue();
+
+ var present = Bind.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))).FailWith(BookingEnvelopeError.CommandInvalid);
+ OptionalReferenceField some = present.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay);
+ Check.That(present.New(s => s.Get(some)!.CheckOut.Value).GetResultOrThrow()).IsEqualTo(new DateOnly(2026, 8, 14));
+ }
+
+ [Fact(DisplayName = "An optional complex property that is present but invalid records its envelope.")]
+ public void OptionalComplexPresentButInvalidRecords() {
+ var bind = Bind.PropertiesOf(RequestWith(new StayDto(null, null))).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay);
+
+ Outcome outcome = bind.New(_ => "never");
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_STAY_INVALID");
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder.UnitTests/FirstClassErrors.RequestBinder.UnitTests.csproj b/FirstClassErrors.RequestBinder.UnitTests/FirstClassErrors.RequestBinder.UnitTests.csproj
new file mode 100644
index 0000000..0aec062
--- /dev/null
+++ b/FirstClassErrors.RequestBinder.UnitTests/FirstClassErrors.RequestBinder.UnitTests.csproj
@@ -0,0 +1,35 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs
new file mode 100644
index 0000000..153ff26
--- /dev/null
+++ b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs
@@ -0,0 +1,172 @@
+#region Usings declarations
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.RequestBinder.UnitTests;
+
+public sealed class ListBindingTests {
+
+ private static Outcome BindGuest(RequestBinder guest) {
+ RequiredField firstName = guest.SimpleProperty(g => g.FirstName).AsRequired();
+ OptionalReferenceField email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse);
+
+ return guest.New(s => new Guest(s.Get(firstName), s.Get(email)));
+ }
+
+ private static BookingRequest RequestWith(IReadOnlyList? tags = null, IReadOnlyList? guests = null) {
+ return new BookingRequest("alice@example.org", "REF-1", "EUR", null, Stay: null, tags, guests);
+ }
+
+ [Fact(DisplayName = "A required list of simple properties binds every element, in order.")]
+ public void RequiredSimpleListBinds() {
+ var bind = Bind.PropertiesOf(RequestWith(tags: ["vip", "late-checkout"])).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse);
+
+ Outcome> outcome = bind.New(s => s.Get(tags));
+ Check.That(outcome.GetResultOrThrow().Select(t => t.Value)).ContainsExactly("vip", "late-checkout");
+ }
+
+ [Fact(DisplayName = "A required list that is missing records REQUEST_ARGUMENT_REQUIRED.")]
+ public void RequiredSimpleListMissing() {
+ var bind = Bind.PropertiesOf(RequestWith(tags: null)).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse);
+
+ Outcome outcome = bind.New(_ => "never");
+ Error required = outcome.Error!.InnerErrors.Single();
+ Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Tags");
+ }
+
+ [Fact(DisplayName = "Every failing element of a list is collected, each under its indexed path — one bad element never hides the others.")]
+ public void EveryFailingElementIsCollected() {
+ var bind = Bind.PropertiesOf(RequestWith(tags: ["ok", "not ok", null, "fine"])).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse);
+
+ Outcome outcome = bind.New(_ => "never");
+ Check.That(outcome.Error!.InnerErrors).HasSize(2);
+ Check.That(outcome.Error!.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
+ .ContainsExactly("Tags[1]", "Tags[2]");
+ Check.That(outcome.Error!.InnerErrors[0].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(outcome.Error!.InnerErrors[1].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ }
+
+ [Fact(DisplayName = "An optional list that is absent binds an empty list — never null — and records nothing.")]
+ public void OptionalListAbsentBindsEmpty() {
+ var bind = Bind.PropertiesOf(RequestWith(tags: null)).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse);
+
+ Outcome> outcome = bind.New(s => s.Get(tags));
+ Check.That(outcome.IsSuccess).IsTrue();
+ Check.That(outcome.GetResultOrThrow()).IsEmpty();
+ }
+
+ [Fact(DisplayName = "A required list of complex properties binds every element through its own nested binder.")]
+ public void RequiredComplexListBinds() {
+ var bind = Bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", "alice@example.org"), new GuestDto("Bob", null)]))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ RequiredField> guests =
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest);
+
+ Outcome> outcome = bind.New(s => s.Get(guests));
+ Check.That(outcome.GetResultOrThrow().Select(g => g.FirstName)).ContainsExactly("Alice", "Bob");
+ Check.That(outcome.GetResultOrThrow()[1].Email).IsNull();
+ }
+
+ [Fact(DisplayName = "Each failing element of a complex list records its own envelope, whose inner paths carry the indexed prefix.")]
+ public void FailingComplexElementsRecordTheirEnvelopes() {
+ var bind = Bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", null), new GuestDto(null, "nope"), new GuestDto(null, null)]))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest);
+
+ Outcome outcome = bind.New(_ => "never");
+ Check.That(outcome.Error!.InnerErrors).HasSize(2);
+
+ Error second = outcome.Error!.InnerErrors[0];
+ Check.That(second.Code.ToString()).IsEqualTo("TEST_GUEST_INVALID");
+ Check.That(second.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
+ .ContainsExactly("Guests[1].FirstName", "Guests[1].Email");
+
+ Error third = outcome.Error!.InnerErrors[1];
+ Check.That(third.InnerErrors.Select(BindingAssertions.ArgumentPathOf)).ContainsExactly("Guests[2].FirstName");
+ }
+
+ [Fact(DisplayName = "A null element of a complex list records REQUEST_ARGUMENT_REQUIRED under its indexed path.")]
+ public void NullComplexElementIsRequired() {
+ var bind = Bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", null), null]))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest);
+
+ Outcome outcome = bind.New(_ => "never");
+ Error required = outcome.Error!.InnerErrors.Single();
+ Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Guests[1]");
+ }
+
+ [Fact(DisplayName = "A null element does not hide the failing elements that follow it — each is still collected under its own indexed path.")]
+ public void NullComplexElementDoesNotHideLaterFailures() {
+ var bind = Bind.PropertiesOf(RequestWith(guests: [null, new GuestDto(null, "nope")]))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest);
+
+ Outcome outcome = bind.New(_ => "never");
+
+ // Both the null element AND the invalid element after it must be collected — the null must not short-circuit.
+ Check.That(outcome.Error!.InnerErrors).HasSize(2);
+
+ Error nullElement = outcome.Error!.InnerErrors[0];
+ Check.That(nullElement.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ Check.That(BindingAssertions.ArgumentPathOf(nullElement)).IsEqualTo("Guests[0]");
+
+ Error secondEnvelope = outcome.Error!.InnerErrors[1];
+ Check.That(secondEnvelope.Code.ToString()).IsEqualTo("TEST_GUEST_INVALID");
+ Check.That(secondEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
+ .ContainsExactly("Guests[1].FirstName", "Guests[1].Email");
+ }
+
+ [Fact(DisplayName = "An optional complex list that is absent binds an empty list and records nothing.")]
+ public void OptionalComplexListAbsentBindsEmpty() {
+ var bind = Bind.PropertiesOf(RequestWith(guests: null)).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ RequiredField> guests =
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(BindGuest);
+
+ Outcome> outcome = bind.New(s => s.Get(guests));
+ Check.That(outcome.IsSuccess).IsTrue();
+ Check.That(outcome.GetResultOrThrow()).IsEmpty();
+ }
+
+ [Fact(DisplayName = "A custom argument-name provider renames the inner paths of complex-list elements: the element binder inherits the parent options.")]
+ public void CustomNameProviderRenamesComplexListElementPaths() {
+ var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", null, null, null, null, Guests: [new GuestDto(null, "nope")]))
+ .FailWith(BookingEnvelopeError.CommandInvalid)
+ .WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider()));
+
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest);
+
+ Outcome outcome = bind.New(_ => "never");
+ Error guestEnvelope = outcome.Error!.InnerErrors.Single(e => e.Code.ToString() == "TEST_GUEST_INVALID");
+ // FirstName (required, absent) and Email ("nope", invalid) are both collected; their names must be
+ // snake_cased by the INHERITED provider, not the default PascalCase — proving the element binder inherits options.
+ Check.That(guestEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
+ .ContainsExactly("guests[0].first_name", "guests[0].email");
+ }
+
+ private sealed class SnakeCaseNameProvider : IArgumentNameProvider {
+
+ public string GetArgumentNameFrom(System.Reflection.PropertyInfo property) {
+ return string.Concat(property.Name.Select((c, i) => i > 0 && char.IsUpper(c) ? "_" + char.ToLowerInvariant(c) : char.ToLowerInvariant(c).ToString()));
+ }
+
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs
new file mode 100644
index 0000000..ad52e17
--- /dev/null
+++ b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs
@@ -0,0 +1,212 @@
+#region Usings declarations
+
+using System.Reflection;
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.RequestBinder.UnitTests;
+
+public sealed class RequestBinderTests {
+
+ private static Outcome BindStay(RequestBinder stay) {
+ RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse);
+ RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse);
+
+ return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut)));
+ }
+
+ private static readonly ErrorCode CheckOutNotAfterCheckIn = ErrorCode.Create("TEST_CHECKOUT_NOT_AFTER_CHECKIN");
+
+ // A validating factory: every field is already bound, yet a cross-field rule — check-out strictly after check-in —
+ // can still reject an all-valid combination. That is exactly what Create (unlike New) can express.
+ private static Outcome AssembleStay(BookingDate checkIn, BookingDate checkOut) {
+ return checkOut.Value > checkIn.Value
+ ? Outcome.Success(new Stay(checkIn, checkOut))
+ : Outcome.Failure(DomainError.Create(CheckOutNotAfterCheckIn, "Check-out must be after check-in.")
+ .WithPublicMessage("The stay dates are inconsistent."));
+ }
+
+ [Fact(DisplayName = "New assembles the command exactly once when every property bound.")]
+ public void NewAssemblesOnceOnSuccess() {
+ var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", "EUR", null, null, null, null))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+ RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+
+ int assembled = 0;
+ Outcome outcome = bind.New(s => {
+ assembled++;
+
+ return s.Get(email).Value;
+ });
+
+ Check.That(outcome.IsSuccess).IsTrue();
+ Check.That(assembled).IsEqualTo(1);
+ }
+
+ [Fact(DisplayName = "New never runs the assembler when a failure was recorded — field reads are safe by construction.")]
+ public void NewNeverAssemblesOnFailure() {
+ var bind = Bind.PropertiesOf(new BookingRequest(null, null, null, null, null, null, null))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+ bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+
+ bool assembled = false;
+ Outcome outcome = bind.New(_ => {
+ assembled = true;
+
+ return "never";
+ });
+
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(assembled).IsFalse();
+ }
+
+ [Fact(DisplayName = "Create flattens the validating factory's success into Outcome — the command itself, not a nested Outcome.")]
+ public void CreateFlattensFactorySuccess() {
+ var bind = Bind.PropertiesOf(new StayDto("2026-08-10", "2026-08-14"))
+ .FailWith(BookingEnvelopeError.StayInvalid);
+ RequiredField checkIn = bind.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse);
+ RequiredField checkOut = bind.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse);
+
+ Outcome outcome = bind.Create(s => AssembleStay(s.Get(checkIn), s.Get(checkOut)));
+
+ Check.That(outcome.IsSuccess).IsTrue();
+ Check.That(outcome.GetResultOrThrow().CheckOut.Value).IsEqualTo(new DateOnly(2026, 8, 14));
+ }
+
+ [Fact(DisplayName = "Create returns the validating factory's own failure as-is — a cross-field rule surfaces undisguised, not wrapped in the binder envelope.")]
+ public void CreateReturnsFactoryFailureAsIs() {
+ var bind = Bind.PropertiesOf(new StayDto("2026-08-14", "2026-08-10"))
+ .FailWith(BookingEnvelopeError.StayInvalid);
+ RequiredField checkIn = bind.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse);
+ RequiredField checkOut = bind.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse);
+
+ Outcome outcome = bind.Create(s => AssembleStay(s.Get(checkIn), s.Get(checkOut)));
+
+ Check.That(outcome.IsFailure).IsTrue();
+ // The factory's error is returned directly: its own code, and NOT wrapped under the binder's TEST_STAY_INVALID envelope.
+ Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_CHECKOUT_NOT_AFTER_CHECKIN");
+ Check.That(outcome.Error).IsInstanceOf();
+ }
+
+ [Fact(DisplayName = "Create never calls the validating factory when a binding failed — it returns the envelope, factory untouched.")]
+ public void CreateNeverCallsFactoryOnBindingFailure() {
+ var bind = Bind.PropertiesOf(new StayDto(null, "2026-08-14"))
+ .FailWith(BookingEnvelopeError.StayInvalid);
+ RequiredField checkIn = bind.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse);
+ RequiredField checkOut = bind.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse);
+
+ bool factoryCalled = false;
+ Outcome outcome = bind.Create(s => {
+ factoryCalled = true;
+
+ return AssembleStay(s.Get(checkIn), s.Get(checkOut));
+ });
+
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(factoryCalled).IsFalse();
+ Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_STAY_INVALID");
+ Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())).ContainsExactly("REQUEST_ARGUMENT_REQUIRED");
+ }
+
+ [Fact(DisplayName = "Every failing property is collected into the envelope, in declaration order — collect-all, not first-failure.")]
+ public void CollectsEveryFailure() {
+ var bind = Bind.PropertiesOf(new BookingRequest("nope", null, "EURO", null, null, null, null))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+ bind.SimpleProperty(r => r.Reference).AsRequired();
+ bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR");
+
+ Outcome outcome = bind.New(_ => "never");
+ Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID");
+ Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString()))
+ .ContainsExactly("REQUEST_ARGUMENT_INVALID", "REQUEST_ARGUMENT_REQUIRED", "REQUEST_ARGUMENT_INVALID");
+ Check.That(outcome.Error!.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
+ .ContainsExactly("GuestEmail", "Reference", "Currency");
+ }
+
+ [Fact(DisplayName = "A fully invalid request binds without a single exception being thrown.")]
+ public void FullyInvalidRequestThrowsNothing() {
+ Check.ThatCode(() => {
+ var bind = Bind.PropertiesOf(new BookingRequest("nope", null, "EURO", "-1", new StayDto(null, "bad"), ["a b", null], [null, new GuestDto(null, "x")]))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+ bind.SimpleProperty(r => r.Reference).AsRequired();
+ bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR");
+ bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse);
+ bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay);
+ bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse);
+ bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(g => {
+ RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired();
+
+ return g.New(s => new Guest(s.Get(firstName), null));
+ });
+
+ return bind.New(_ => "never");
+ })
+ .DoesNotThrow();
+ }
+
+ [Fact(DisplayName = "A converter that throws is a bug: the exception propagates to the host, undisguised.")]
+ public void ThrowingConverterPropagates() {
+ var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, null))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail)
+ .AsRequired(_ => throw new FormatException("converter bug")))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "A selector that is not a direct property access is a programming error and throws.")]
+ public void InvalidSelectorThrows() {
+ var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, null))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ // A method call on the property — not a direct member access; the exception echoes the offending selector.
+ ArgumentException exception = Assert.Throws(
+ () => { bind.SimpleProperty(r => r.GuestEmail!.ToUpperInvariant()); });
+ Check.That(exception.Message).Contains("ToUpperInvariant");
+ // A nested property access — the member's base is not the request parameter.
+ Check.ThatCode(() => bind.SimpleProperty(r => r.Stay!.CheckIn))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "A custom argument-name provider renames the paths — and nested binders inherit it.")]
+ public void CustomNameProviderRenamesPaths() {
+ var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", null, null, new StayDto(null, null), null, null))
+ .FailWith(BookingEnvelopeError.CommandInvalid)
+ .WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider()));
+
+ bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+ bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay);
+
+ Outcome outcome = bind.New(_ => "never");
+ Error stayEnvelope = outcome.Error!.InnerErrors.Single(e => e.Code.ToString() == "TEST_STAY_INVALID");
+ Check.That(stayEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
+ .ContainsExactly("stay.check_in", "stay.check_out");
+ }
+
+ [Fact(DisplayName = "Binding failures are non-transient: resubmitting the same request cannot succeed.")]
+ public void MissingArgumentIsNonTransient() {
+ var bind = Bind.PropertiesOf(new BookingRequest(null, null, null, null, null, null, null))
+ .FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+
+ Outcome outcome = bind.New(_ => "never");
+ var required = (InfrastructureError)outcome.Error!.InnerErrors.Single();
+ Check.That(required.Transience).IsEqualTo(Transience.NonTransient);
+ }
+
+ private sealed class SnakeCaseNameProvider : IArgumentNameProvider {
+
+ public string GetArgumentNameFrom(PropertyInfo property) {
+ return string.Concat(property.Name.Select((c, i) => i > 0 && char.IsUpper(c) ? "_" + char.ToLowerInvariant(c) : char.ToLowerInvariant(c).ToString()));
+ }
+
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs
new file mode 100644
index 0000000..0acb03a
--- /dev/null
+++ b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs
@@ -0,0 +1,155 @@
+#region Usings declarations
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.RequestBinder.UnitTests;
+
+public sealed class SimplePropertyBindingTests {
+
+ private static BookingRequest Request(string? email = "alice@example.org",
+ string? currency = "EUR",
+ string? nights = "3") {
+ return new BookingRequest(email, "REF-1", currency, nights, Stay: null, Tags: null, Guests: null);
+ }
+
+ [Fact(DisplayName = "A required property that is present and valid binds its value.")]
+ public void RequiredPresentAndValidBinds() {
+ var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+
+ Outcome outcome = bind.New(s => s.Get(email).Value);
+ Check.That(outcome.IsSuccess).IsTrue();
+ Check.That(outcome.GetResultOrThrow()).IsEqualTo("alice@example.org");
+ }
+
+ [Fact(DisplayName = "A required property that is missing fails the build with REQUEST_ARGUMENT_REQUIRED, carrying the argument path.")]
+ public void RequiredMissingFails() {
+ var bind = Bind.PropertiesOf(Request(email: null)).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+
+ Outcome outcome = bind.New(_ => "never");
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID");
+
+ Error required = outcome.Error!.InnerErrors.Single();
+ Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("GuestEmail");
+ }
+
+ [Fact(DisplayName = "A required property that is present but invalid fails with REQUEST_ARGUMENT_INVALID wrapping the converter's error.")]
+ public void RequiredInvalidWrapsTheConverterError() {
+ var bind = Bind.PropertiesOf(Request(email: "not-an-email")).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+
+ Outcome outcome = bind.New(_ => "never");
+ Check.That(outcome.IsFailure).IsTrue();
+
+ Error invalid = outcome.Error!.InnerErrors.Single();
+ Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(BindingAssertions.ArgumentPathOf(invalid)).IsEqualTo("GuestEmail");
+ Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID");
+ }
+
+ [Fact(DisplayName = "A required property without conversion binds the raw value when present, and fails when missing.")]
+ public void RequiredWithoutConversion() {
+ var present = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+ RequiredField reference = present.SimpleProperty(r => r.Reference).AsRequired();
+ Check.That(present.New(s => s.Get(reference)).GetResultOrThrow()).IsEqualTo("REF-1");
+
+ var missing = Bind.PropertiesOf(new BookingRequest(null, null, null, null, null, null, null)).FailWith(BookingEnvelopeError.CommandInvalid);
+ missing.SimpleProperty(r => r.Reference).AsRequired();
+ Outcome outcome = missing.New(_ => "never");
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
+ }
+
+ [Fact(DisplayName = "An optional property with a fallback converts the provided value when present, and the fallback when absent.")]
+ public void OptionalWithFallback() {
+ var provided = Bind.PropertiesOf(Request(currency: "USD")).FailWith(BookingEnvelopeError.CommandInvalid);
+ RequiredField providedCurrency = provided.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR");
+ Check.That(provided.New(s => s.Get(providedCurrency).Code).GetResultOrThrow()).IsEqualTo("USD");
+
+ var absent = Bind.PropertiesOf(Request(currency: null)).FailWith(BookingEnvelopeError.CommandInvalid);
+ RequiredField defaulted = absent.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR");
+ Check.That(absent.New(s => s.Get(defaulted).Code).GetResultOrThrow()).IsEqualTo("EUR");
+ }
+
+ [Fact(DisplayName = "An optional property that is present but invalid still records an error: optional never means malformed.")]
+ public void OptionalPresentButInvalidRecords() {
+ var bind = Bind.PropertiesOf(Request(currency: "EURO")).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR");
+
+ Outcome outcome = bind.New(_ => "never");
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ }
+
+ [Fact(DisplayName = "An optional fallback that does not convert is a developer bug and throws, naming the misconfigured argument.")]
+ public void InvalidFallbackIsABug() {
+ var bind = Bind.PropertiesOf(Request(currency: null)).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ InvalidOperationException exception = Assert.Throws(
+ () => { bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "NOT-A-CURRENCY"); });
+ Check.That(exception.Message).Contains("Currency");
+ // The exception must carry the converter's DIAGNOSTIC detail (the raw offending value), not the sanitized
+ // public summary — so a developer can see WHY the configured fallback is rejected.
+ Check.That(exception.Message).Contains("NOT-A-CURRENCY");
+ }
+
+ [Fact(DisplayName = "An optional reference property yields null when absent — recording nothing — and the value when present.")]
+ public void OptionalReference() {
+ var absent = Bind.PropertiesOf(Request(email: null)).FailWith(BookingEnvelopeError.CommandInvalid);
+ OptionalReferenceField none = absent.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse);
+ Check.That(absent.New(s => s.Get(none) is null).GetResultOrThrow()).IsTrue();
+
+ var present = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
+ OptionalReferenceField some = present.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse);
+ Check.That(present.New(s => s.Get(some)!.Value).GetResultOrThrow()).IsEqualTo("alice@example.org");
+ }
+
+ [Fact(DisplayName = "An optional reference property that is present but invalid records REQUEST_ARGUMENT_INVALID wrapping the converter's error.")]
+ public void OptionalReferencePresentButInvalidRecords() {
+ var bind = Bind.PropertiesOf(Request(email: "nope")).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse);
+
+ Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single();
+ Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(BindingAssertions.ArgumentPathOf(invalid)).IsEqualTo("GuestEmail");
+ Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID");
+ }
+
+ [Fact(DisplayName = "An optional value property yields a real null when absent — never default(T): an absent count is null, not 0.")]
+ public void OptionalValueYieldsNullWhenAbsent() {
+ var absent = Bind.PropertiesOf(Request(nights: null)).FailWith(BookingEnvelopeError.CommandInvalid);
+ OptionalValueField none = absent.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse);
+ // Project to a non-null bool: New's TCommand cannot itself be int? (Nullable is not `notnull`);
+ // a real consumer flows s.Get(none) straight into a command constructor argument instead.
+ Outcome absentOutcome = absent.New(s => s.Get(none).HasValue);
+ Check.That(absentOutcome.IsSuccess).IsTrue();
+ Check.That(absentOutcome.GetResultOrThrow()).IsFalse();
+
+ var present = Bind.PropertiesOf(Request(nights: "5")).FailWith(BookingEnvelopeError.CommandInvalid);
+ OptionalValueField some = present.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse);
+ Check.That(present.New(s => s.Get(some) ?? 0).GetResultOrThrow()).IsEqualTo(5);
+ }
+
+ [Fact(DisplayName = "An optional value property that is present but invalid records REQUEST_ARGUMENT_INVALID wrapping the converter's error.")]
+ public void OptionalValuePresentButInvalidRecords() {
+ var bind = Bind.PropertiesOf(Request(nights: "-2")).FailWith(BookingEnvelopeError.CommandInvalid);
+
+ bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse);
+
+ Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single();
+ Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
+ Check.That(BindingAssertions.ArgumentPathOf(invalid)).IsEqualTo("MaxNights");
+ Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NOT_A_POSITIVE_NUMBER");
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder.UnitTests/TestModel.cs b/FirstClassErrors.RequestBinder.UnitTests/TestModel.cs
new file mode 100644
index 0000000..abd0da3
--- /dev/null
+++ b/FirstClassErrors.RequestBinder.UnitTests/TestModel.cs
@@ -0,0 +1,196 @@
+namespace FirstClassErrors.RequestBinder.UnitTests;
+
+#region Request DTOs
+
+internal sealed record BookingRequest(
+ string? GuestEmail,
+ string? Reference,
+ string? Currency,
+ string? MaxNights,
+ StayDto? Stay,
+ IReadOnlyList? Tags,
+ IReadOnlyList? Guests);
+
+internal sealed record StayDto(string? CheckIn, string? CheckOut);
+
+internal sealed record GuestDto(string? FirstName, string? Email);
+
+#endregion
+
+#region Bound command & value objects
+
+internal sealed record BookingCommand(
+ EmailAddress GuestEmail,
+ string Reference,
+ Currency Currency,
+ int? MaxNights,
+ Stay? Stay,
+ IReadOnlyList Tags,
+ IReadOnlyList Guests);
+
+internal sealed record Stay(BookingDate CheckIn, BookingDate CheckOut);
+
+internal sealed record Guest(string FirstName, EmailAddress? Email);
+
+internal sealed class EmailAddress {
+
+ private EmailAddress(string value) {
+ Value = value;
+ }
+
+ public string Value { get; }
+
+ public static Outcome Parse(string raw) {
+ return raw.Contains('@')
+ ? Outcome.Success(new EmailAddress(raw))
+ : Outcome.Failure(BookingDomainError.EmailInvalid(raw));
+ }
+
+}
+
+internal sealed class BookingDate {
+
+ private BookingDate(DateOnly value) {
+ Value = value;
+ }
+
+ public DateOnly Value { get; }
+
+ public static Outcome Parse(string raw) {
+ return DateOnly.TryParse(raw, out DateOnly parsed)
+ ? Outcome.Success(new BookingDate(parsed))
+ : Outcome.Failure(BookingDomainError.DateInvalid(raw));
+ }
+
+}
+
+internal sealed class Currency {
+
+ private Currency(string code) {
+ Code = code;
+ }
+
+ public string Code { get; }
+
+ public static Outcome Parse(string raw) {
+ return raw.Length == 3
+ ? Outcome.Success(new Currency(raw))
+ : Outcome.Failure(BookingDomainError.CurrencyInvalid(raw));
+ }
+
+}
+
+internal sealed class Tag {
+
+ private Tag(string value) {
+ Value = value;
+ }
+
+ public string Value { get; }
+
+ public static Outcome Parse(string raw) {
+ return raw.Length > 0 && !raw.Contains(' ')
+ ? Outcome.Success(new Tag(raw))
+ : Outcome.Failure(BookingDomainError.TagInvalid(raw));
+ }
+
+}
+
+internal static class PositiveInt {
+
+ public static Outcome Parse(string raw) {
+ return int.TryParse(raw, out int parsed) && parsed > 0
+ ? Outcome.Success(parsed)
+ : Outcome.Failure(BookingDomainError.NotAPositiveNumber(raw));
+ }
+
+}
+
+#endregion
+
+#region Test error factories
+
+/// The leaf domain errors the test value objects fail with.
+internal static class BookingDomainError {
+
+ internal static DomainError EmailInvalid(string raw) {
+ return DomainError.Create(Code.EmailInvalid, $"'{raw}' is not a valid email address.")
+ .WithPublicMessage("The email address is invalid.");
+ }
+
+ internal static DomainError DateInvalid(string raw) {
+ return DomainError.Create(Code.DateInvalid, $"'{raw}' is not a valid date.")
+ .WithPublicMessage("The date is invalid.");
+ }
+
+ internal static DomainError CurrencyInvalid(string raw) {
+ return DomainError.Create(Code.CurrencyInvalid, $"'{raw}' is not a valid ISO currency code.")
+ .WithPublicMessage("The currency is invalid.");
+ }
+
+ internal static DomainError TagInvalid(string raw) {
+ return DomainError.Create(Code.TagInvalid, $"'{raw}' is not a valid tag.")
+ .WithPublicMessage("The tag is invalid.");
+ }
+
+ internal static DomainError NotAPositiveNumber(string raw) {
+ return DomainError.Create(Code.NotAPositiveNumber, $"'{raw}' is not a strictly positive number.")
+ .WithPublicMessage("The number must be strictly positive.");
+ }
+
+ private static class Code {
+
+ public static readonly ErrorCode EmailInvalid = ErrorCode.Create("TEST_EMAIL_INVALID");
+ public static readonly ErrorCode DateInvalid = ErrorCode.Create("TEST_DATE_INVALID");
+ public static readonly ErrorCode CurrencyInvalid = ErrorCode.Create("TEST_CURRENCY_INVALID");
+ public static readonly ErrorCode TagInvalid = ErrorCode.Create("TEST_TAG_INVALID");
+ public static readonly ErrorCode NotAPositiveNumber = ErrorCode.Create("TEST_NOT_A_POSITIVE_NUMBER");
+
+ }
+
+}
+
+/// The envelope factories the test binders fail with.
+internal static class BookingEnvelopeError {
+
+ internal static PrimaryPortError CommandInvalid(PrimaryPortInnerErrors violations) {
+ return PrimaryPortError.Create(Code.CommandInvalid, "The booking command is invalid.", violations)
+ .WithPublicMessage("We could not accept your booking request.");
+ }
+
+ internal static PrimaryPortError StayInvalid(PrimaryPortInnerErrors violations) {
+ return PrimaryPortError.Create(Code.StayInvalid, "The stay is invalid.", violations)
+ .WithPublicMessage("The stay dates are invalid.");
+ }
+
+ internal static PrimaryPortError GuestInvalid(PrimaryPortInnerErrors violations) {
+ return PrimaryPortError.Create(Code.GuestInvalid, "The guest information is invalid.", violations)
+ .WithPublicMessage("A guest's information is invalid.");
+ }
+
+ private static class Code {
+
+ public static readonly ErrorCode CommandInvalid = ErrorCode.Create("TEST_BOOKING_COMMAND_INVALID");
+ public static readonly ErrorCode StayInvalid = ErrorCode.Create("TEST_STAY_INVALID");
+ public static readonly ErrorCode GuestInvalid = ErrorCode.Create("TEST_GUEST_INVALID");
+
+ }
+
+}
+
+#endregion
+
+#region Shared assertion helpers
+
+internal static class BindingAssertions {
+
+ /// The full argument path recorded in a binding error's context ("RequestArgument" entry).
+ internal static string? ArgumentPathOf(Error error) {
+ error.Context.ToNameDictionary().TryGetValue("RequestArgument", out object? path);
+
+ return path as string;
+ }
+
+}
+
+#endregion
diff --git a/FirstClassErrors.RequestBinder/Bind.cs b/FirstClassErrors.RequestBinder/Bind.cs
new file mode 100644
index 0000000..c0a34b8
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/Bind.cs
@@ -0,0 +1,39 @@
+namespace FirstClassErrors.RequestBinder;
+
+///
+/// The entry point of request binding: converts an incoming request DTO into a typed command or query of value
+/// objects at the primary-adapter boundary, collecting every failure — instead of stopping at the first —
+/// into a single coded tree.
+///
+///
+///
+/// var bind = Bind.PropertiesOf(request).FailWith(InvalidBookingCommandError.Invalid);
+///
+/// var email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
+/// var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay);
+///
+/// Outcome<PlaceBookingCommand> command =
+/// bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(stay)));
+///
+///
+public static class Bind {
+
+ #region Statics members declarations
+
+ ///
+ /// Starts binding the properties of a request DTO. Declare the failure envelope next, with
+ /// .
+ ///
+ /// The type of the request DTO.
+ /// The request DTO to bind.
+ /// The stage on which the failure envelope is declared.
+ /// Thrown when is null.
+ public static RequestBinderEnvelopeStage PropertiesOf(TRequest request) {
+ if (request is null) { throw new ArgumentNullException(nameof(request)); }
+
+ return new RequestBinderEnvelopeStage(request);
+ }
+
+ #endregion
+
+}
diff --git a/FirstClassErrors.RequestBinder/BindingAssembler.cs b/FirstClassErrors.RequestBinder/BindingAssembler.cs
new file mode 100644
index 0000000..a8cead1
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/BindingAssembler.cs
@@ -0,0 +1,16 @@
+namespace FirstClassErrors.RequestBinder;
+
+///
+/// Assembles the bound command inside , reading each bound value
+/// from the supplied — the only channel through which a bound value is reachable — and
+/// returning the command directly (a total new that cannot fail). For a command produced by a validating
+/// factory that returns an , use instead.
+///
+///
+/// A dedicated delegate — rather than Func<BindingScope, TCommand> — is required because
+/// is a ref struct and cannot be used as a generic type argument.
+///
+/// The type of the assembled command or query.
+/// The scope through which bound values are read.
+/// The assembled command.
+public delegate TCommand BindingAssembler(BindingScope scope);
diff --git a/FirstClassErrors.RequestBinder/BindingScope.cs b/FirstClassErrors.RequestBinder/BindingScope.cs
new file mode 100644
index 0000000..18a3ad6
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/BindingScope.cs
@@ -0,0 +1,87 @@
+namespace FirstClassErrors.RequestBinder;
+
+///
+/// The reader handed to a build terminal's assembler ( /
+/// ): the only channel through which a bound value can
+/// be obtained from a field token ( and its optional siblings).
+///
+///
+///
+/// Safety by construction. is a readonly ref struct: it cannot be
+/// captured, boxed, stored in a field, or returned, so it lives only for the duration of the assembler it is
+/// passed to. And a build terminal ( /
+/// ) creates one only on its success branch —
+/// after it has verified that not a single binding failure was recorded. A field token exposes no public value
+/// member, so the one way to read a bound value is Get through this scope, and this scope only ever
+/// exists where every binding is known to have succeeded. Reading a value before a build terminal runs, or
+/// outside its assembler, is therefore not merely discouraged: it does not compile.
+///
+///
+/// A token produced by a different binder is rejected with :
+/// mixing tokens across binders is a programming error, so it throws loudly rather than reading a value this
+/// scope never validated.
+///
+///
+public readonly ref struct BindingScope {
+
+ #region Fields declarations
+
+ private readonly object _owner;
+
+ #endregion
+
+ #region Constructors declarations
+
+ internal BindingScope(object owner) {
+ _owner = owner;
+ }
+
+ #endregion
+
+ /// Reads a required bound value.
+ /// The type of the bound value.
+ /// The token returned when the property was bound.
+ /// The bound value.
+ /// Thrown when is null.
+ /// Thrown when was produced by a different binder.
+ public TProperty Get(RequiredField field) {
+ if (field is null) { throw new ArgumentNullException(nameof(field)); }
+ EnsureOwned(field.Owner);
+
+ return field.Value;
+ }
+
+ /// Reads an optional reference-type bound value, or null when the argument was absent.
+ /// The reference type of the bound value.
+ /// The token returned when the property was bound.
+ /// The bound value, or null when the argument was absent.
+ /// Thrown when is null.
+ /// Thrown when was produced by a different binder.
+ public TProperty? Get(OptionalReferenceField field) where TProperty : class {
+ if (field is null) { throw new ArgumentNullException(nameof(field)); }
+ EnsureOwned(field.Owner);
+
+ return field.Value;
+ }
+
+ /// Reads an optional value-type bound value as a real — null when absent.
+ /// The value type of the bound value.
+ /// The token returned when the property was bound.
+ /// The bound value, or null when the argument was absent.
+ /// Thrown when is null.
+ /// Thrown when was produced by a different binder.
+ public TProperty? Get(OptionalValueField field) where TProperty : struct {
+ if (field is null) { throw new ArgumentNullException(nameof(field)); }
+ EnsureOwned(field.Owner);
+
+ return field.Value;
+ }
+
+ private void EnsureOwned(object owner) {
+ if (!ReferenceEquals(owner, _owner)) {
+ throw new InvalidOperationException(
+ "This bound field was produced by a different binder. A field can only be read inside the New/Create terminal of the binder that bound it.");
+ }
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs
new file mode 100644
index 0000000..67294c8
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs
@@ -0,0 +1,96 @@
+namespace FirstClassErrors.RequestBinder;
+
+///
+/// Binds a complex request property through a nested binder: the binding function receives a child
+/// — prefixed with this property's path, inheriting the parent options,
+/// failing with the envelope declared on the previous stage — and typically lives in a dedicated method, passed
+/// as a method group.
+///
+/// The type of the request DTO.
+/// The type of the nested DTO.
+public sealed class ComplexPropertyConverter {
+
+ #region Fields declarations
+
+ private readonly RequestBinder _binder;
+ private readonly string _argumentPath;
+ private readonly TArgument? _value;
+ private readonly bool _isMissing;
+ private readonly Func _envelope;
+
+ #endregion
+
+ #region Constructors declarations
+
+ internal ComplexPropertyConverter(RequestBinder binder,
+ string argumentPath,
+ TArgument? value,
+ bool isMissing,
+ Func envelope) {
+ _binder = binder;
+ _argumentPath = argumentPath;
+ _value = value;
+ _isMissing = isMissing;
+ _envelope = envelope;
+ }
+
+ #endregion
+
+ ///
+ /// Binds a required complex argument: missing records REQUEST_ARGUMENT_REQUIRED; a failed nested
+ /// binding records its envelope, whose inner errors carry the full, prefixed argument paths.
+ ///
+ /// The type the nested binding produces.
+ /// The nested binding function (typically a method group such as BindStay).
+ /// The bound field token.
+ /// Thrown when is null.
+ public RequiredField AsRequired(Func, Outcome> bindNested) where TProperty : notnull {
+ if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); }
+
+ if (_isMissing) {
+ _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath));
+
+ return new RequiredField(_binder, default!);
+ }
+
+ RequestBinder nested = NestedBinder();
+ Outcome outcome = bindNested(nested);
+ if (outcome.IsFailure) {
+ _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath));
+
+ return new RequiredField(_binder, default!);
+ }
+
+ return new RequiredField(_binder, outcome.GetResultOrThrow());
+ }
+
+ ///
+ /// Binds an optional complex argument: absent yields a null
+ /// value and records nothing; a present-but-invalid nested
+ /// binding records its envelope.
+ ///
+ /// The reference type the nested binding produces.
+ /// The nested binding function (typically a method group such as BindAddress).
+ /// The bound field token.
+ /// Thrown when is null.
+ public OptionalReferenceField AsOptional(Func, Outcome> bindNested) where TProperty : class {
+ if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); }
+
+ if (_isMissing) { return new OptionalReferenceField(_binder, value: null); }
+
+ RequestBinder nested = NestedBinder();
+ Outcome outcome = bindNested(nested);
+ if (outcome.IsFailure) {
+ _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath));
+
+ return new OptionalReferenceField(_binder, value: null);
+ }
+
+ return new OptionalReferenceField(_binder, outcome.GetResultOrThrow());
+ }
+
+ private RequestBinder NestedBinder() {
+ return new RequestBinder(_value!, _envelope, _binder.Options, _argumentPath);
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs b/FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs
new file mode 100644
index 0000000..04ccb80
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs
@@ -0,0 +1,44 @@
+namespace FirstClassErrors.RequestBinder;
+
+///
+/// The mandatory envelope stage of a complex property: declares the envelope error the nested binding's failures
+/// are grouped into, before AsRequired / AsOptional become available.
+///
+/// The type of the request DTO.
+/// The type of the nested DTO.
+public sealed class ComplexPropertyEnvelopeStage {
+
+ #region Fields declarations
+
+ private readonly RequestBinder _binder;
+ private readonly string _argumentPath;
+ private readonly TArgument? _value;
+ private readonly bool _isMissing;
+
+ #endregion
+
+ #region Constructors declarations
+
+ internal ComplexPropertyEnvelopeStage(RequestBinder binder, string argumentPath, TArgument? value, bool isMissing) {
+ _binder = binder;
+ _argumentPath = argumentPath;
+ _value = value;
+ _isMissing = isMissing;
+ }
+
+ #endregion
+
+ ///
+ /// Declares the nested envelope: the factory producing the under which the
+ /// nested binding's failures are grouped.
+ ///
+ /// The nested envelope factory, receiving the collected failures.
+ /// The converter stage offering AsRequired / AsOptional.
+ /// Thrown when is null.
+ public ComplexPropertyConverter FailWith(Func envelope) {
+ if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); }
+
+ return new ComplexPropertyConverter(_binder, _argumentPath, _value, _isMissing, envelope);
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder/DefaultArgumentNameProvider.cs b/FirstClassErrors.RequestBinder/DefaultArgumentNameProvider.cs
new file mode 100644
index 0000000..6df9218
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/DefaultArgumentNameProvider.cs
@@ -0,0 +1,26 @@
+#region Usings declarations
+
+using System.Reflection;
+
+#endregion
+
+namespace FirstClassErrors.RequestBinder;
+
+///
+/// The default : the argument name is the C# property name, unchanged.
+///
+///
+/// It deliberately reads no serialization attribute: which serializer names the wire keys is the host's
+/// knowledge, not this library's. Provide your own through
+/// when the error paths must match serialized names.
+///
+internal sealed class DefaultArgumentNameProvider : IArgumentNameProvider {
+
+ ///
+ public string GetArgumentNameFrom(PropertyInfo property) {
+ if (property is null) { throw new ArgumentNullException(nameof(property)); }
+
+ return property.Name;
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder/FirstClassErrors.RequestBinder.csproj b/FirstClassErrors.RequestBinder/FirstClassErrors.RequestBinder.csproj
new file mode 100644
index 0000000..d89691e
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/FirstClassErrors.RequestBinder.csproj
@@ -0,0 +1,70 @@
+
+
+
+
+ netstandard2.0
+ enable
+ enable
+ latest
+
+
+ true
+
+
+ 0.1.0-dev
+
+
+ FirstClassErrors.RequestBinder
+ Sylvain AURAT
+ Reefact
+
+
+
+ Fluent, framework-agnostic request binding for FirstClassErrors: converts an incoming request DTO into a typed command or query of value objects at the primary-adapter boundary, collecting every failure into a coded, documented PrimaryPortError tree — with no exception thrown on the invalid-input path.
+
+
+
+ error-handling;errors;request-binding;validation;value-objects;command;cqrs;hexagonal;primary-adapter;outcome;firstclasserrors
+ Apache-2.0
+ false
+
+
+ https://github.com/Reefact/first-class-errors
+ https://github.com/Reefact/first-class-errors.git
+ git
+
+ © Reefact 2026
+
+
+ icon.png
+ readme.md
+ Initial preview release of the FirstClassErrors request binder.
+
+
+
+
+
+
+ <_Parameter1>FirstClassErrors.RequestBinder.UnitTests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FirstClassErrors.RequestBinder/IArgumentNameProvider.cs b/FirstClassErrors.RequestBinder/IArgumentNameProvider.cs
new file mode 100644
index 0000000..21acb23
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/IArgumentNameProvider.cs
@@ -0,0 +1,24 @@
+#region Usings declarations
+
+using System.Reflection;
+
+#endregion
+
+namespace FirstClassErrors.RequestBinder;
+
+///
+/// Provides the argument name used in error paths for a bound DTO property.
+///
+///
+/// The default () uses the C# property name. Plug a
+/// serializer-aware implementation (reading, for example, JsonPropertyName attributes or a naming
+/// policy) so the paths reported in errors match the keys the client actually sent.
+///
+public interface IArgumentNameProvider {
+
+ /// Returns the argument name to use in error paths for the given DTO property.
+ /// The DTO property being bound.
+ /// The client-facing argument name.
+ string GetArgumentNameFrom(PropertyInfo property);
+
+}
diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs
new file mode 100644
index 0000000..94ce653
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs
@@ -0,0 +1,108 @@
+namespace FirstClassErrors.RequestBinder;
+
+///
+/// Binds a list request property whose elements are each bound by a nested binder. Every failing element
+/// records its own envelope, whose inner errors carry the full, indexed argument paths
+/// (Guests[1].FirstName) — so one bad element never hides the others.
+///
+/// The type of the request DTO.
+/// The element type of the DTO list.
+public sealed class ListOfComplexPropertiesConverter {
+
+ #region Fields declarations
+
+ private readonly RequestBinder _binder;
+ private readonly string _argumentPath;
+ private readonly IEnumerable? _values;
+ private readonly bool _isMissing;
+ private readonly Func _envelope;
+
+ #endregion
+
+ #region Constructors declarations
+
+ internal ListOfComplexPropertiesConverter(RequestBinder binder,
+ string argumentPath,
+ IEnumerable? values,
+ bool isMissing,
+ Func envelope) {
+ _binder = binder;
+ _argumentPath = argumentPath;
+ _values = values;
+ _isMissing = isMissing;
+ _envelope = envelope;
+ }
+
+ #endregion
+
+ ///
+ /// Binds a required list: a missing list records REQUEST_ARGUMENT_REQUIRED; each failing element
+ /// records its envelope under its indexed path.
+ ///
+ /// The type each element's nested binding produces.
+ /// The nested binding function applied to each element (typically a method group).
+ /// The bound field token.
+ /// Thrown when is null.
+ public RequiredField> AsRequired(Func, Outcome> bindElement) where TProperty : notnull {
+ if (bindElement is null) { throw new ArgumentNullException(nameof(bindElement)); }
+
+ if (_isMissing) {
+ _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath));
+
+ return new RequiredField>(_binder, default!);
+ }
+
+ return BindElements(bindElement);
+ }
+
+ ///
+ /// Binds an optional list: absent yields an empty list (never null) and records nothing; each
+ /// failing element of a present list still records its envelope under its indexed path.
+ ///
+ /// The type each element's nested binding produces.
+ /// The nested binding function applied to each element (typically a method group).
+ /// The bound field token.
+ /// Thrown when is null.
+ public RequiredField> AsOptional(Func, Outcome> bindElement) where TProperty : notnull {
+ if (bindElement is null) { throw new ArgumentNullException(nameof(bindElement)); }
+
+ if (_isMissing) {
+ IReadOnlyList empty = new List();
+
+ return new RequiredField>(_binder, empty);
+ }
+
+ return BindElements(bindElement);
+ }
+
+ private RequiredField> BindElements(Func, Outcome> bindElement) where TProperty : notnull {
+ List bound = new();
+ int index = 0;
+
+ foreach (TArgument? element in _values!) {
+ string elementPath = $"{_argumentPath}[{index}]";
+ index++;
+
+ if (element is null) {
+ _binder.Record(RequestBindingError.ArgumentRequired(elementPath));
+
+ continue;
+ }
+
+ RequestBinder nested = new(element!, _envelope, _binder.Options, elementPath);
+ Outcome outcome = bindElement(nested);
+ if (outcome.IsFailure) {
+ _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath));
+
+ continue;
+ }
+
+ bound.Add(outcome.GetResultOrThrow());
+ }
+
+ // The value is read only through a BindingScope, which a build terminal creates solely when no failure was recorded —
+ // i.e. only when every element bound — so `bound` is the complete list exactly when it is observed.
+ return new RequiredField>(_binder, bound);
+ }
+
+}
diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs
new file mode 100644
index 0000000..72aeafd
--- /dev/null
+++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs
@@ -0,0 +1,44 @@
+namespace FirstClassErrors.RequestBinder;
+
+///