From c5d8c036485d10b583010000363517b9170e2bf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 11:59:33 +0000 Subject: [PATCH 01/14] chore: add the binder scope to the commit convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare the binder scope — FirstClassErrors.RequestBinder, the request binder for the primary-adapter boundary — in the commit linter, the CONTRIBUTING scope table and the CLAUDE.md scope list, ahead of the commits that introduce the component. Refs: #126 --- CLAUDE.md | 2 +- CONTRIBUTING.md | 1 + tools/commit-lint/lint-commit-message.sh | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2bafa53..d595578 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,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/tools/commit-lint/lint-commit-message.sh b/tools/commit-lint/lint-commit-message.sh index e77a2ae..f7e35e4 100755 --- a/tools/commit-lint/lint-commit-message.sh +++ b/tools/commit-lint/lint-commit-message.sh @@ -24,9 +24,9 @@ set -u TYPES='feat|fix|build|chore|ci|docs|perf|refactor|revert|style|test' -SCOPES='core|analyzers|cli|gendoc|testing' +SCOPES='core|analyzers|binder|cli|gendoc|testing' TYPES_HUMAN='feat, fix, build, chore, ci, docs, perf, refactor, revert, style, test' -SCOPES_HUMAN='core, analyzers, cli, gendoc, testing' +SCOPES_HUMAN='core, analyzers, binder, cli, gendoc, testing' MAX=72 # --- options ------------------------------------------------------------------ From c2cba5b4f764757f6313e1fc0129e5d273b7c567 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 11:59:33 +0000 Subject: [PATCH 02/14] feat(binder): add the request binder for the primary-adapter boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce FirstClassErrors.RequestBinder, the framework-agnostic request binder designed in #126: Bind.PropertiesOf(request).FailWith(envelope) opens a binder whose SimpleProperty / ComplexProperty / ListOfSimpleProperties / ListOfComplexProperties stages convert a request DTO into value objects through Outcome-returning converters, collecting every failure — coded, path-carrying, grouped under a PrimaryPortError envelope — instead of stopping at the first. Build assembles the command only when nothing failed, so property handles are safe by construction, and the invalid-input path throws no exception: the binder catches nothing, leaving genuine bugs to the host boundary. The binder ships its own documented errors (REQUEST_ARGUMENT_REQUIRED, REQUEST_ARGUMENT_INVALID, with the full argument path in context), a pluggable per-binder argument-name provider, and an end-to-end unit-test suite covering every converter shape, nesting, list indexing, collect-all and the no-throw contract. Refs: #126 --- CLAUDE.md | 1 + .../BookingEndToEndTests.cs | 96 +++++++++ .../ComplexPropertyBindingTests.cs | 88 ++++++++ ...ClassErrors.RequestBinder.UnitTests.csproj | 35 ++++ .../ListBindingTests.cs | 126 +++++++++++ .../RequestBinderTests.cs | 148 +++++++++++++ .../SimplePropertyBindingTests.cs | 146 +++++++++++++ .../TestModel.cs | 196 ++++++++++++++++++ FirstClassErrors.RequestBinder/Bind.cs | 39 ++++ .../ComplexPropertyConverter.cs | 108 ++++++++++ .../ComplexPropertyEnvelopeStage.cs | 44 ++++ .../DefaultArgumentNameProvider.cs | 26 +++ .../FirstClassErrors.RequestBinder.csproj | 70 +++++++ .../IArgumentNameProvider.cs | 24 +++ .../ListOfComplexPropertiesConverter.cs | 118 +++++++++++ .../ListOfComplexPropertiesEnvelopeStage.cs | 44 ++++ .../ListOfSimplePropertiesConverter.cs | 110 ++++++++++ .../OptionalReferenceProperty.cs | 38 ++++ .../OptionalValueProperty.cs | 39 ++++ .../PropertySelectors.cs | 42 ++++ .../README.nuget.md | 20 ++ .../RequestBinder.cs | 164 +++++++++++++++ .../RequestBinderEnvelopeStage.cs | 39 ++++ .../RequestBinderOptions.cs | 33 +++ .../RequestBindingError.cs | 135 ++++++++++++ .../RequiredProperty.cs | 38 ++++ .../SimplePropertyConverter.cs | 169 +++++++++++++++ FirstClassErrors.sln | 28 +++ 28 files changed, 2164 insertions(+) create mode 100644 FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs create mode 100644 FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs create mode 100644 FirstClassErrors.RequestBinder.UnitTests/FirstClassErrors.RequestBinder.UnitTests.csproj create mode 100644 FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs create mode 100644 FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs create mode 100644 FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs create mode 100644 FirstClassErrors.RequestBinder.UnitTests/TestModel.cs create mode 100644 FirstClassErrors.RequestBinder/Bind.cs create mode 100644 FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs create mode 100644 FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs create mode 100644 FirstClassErrors.RequestBinder/DefaultArgumentNameProvider.cs create mode 100644 FirstClassErrors.RequestBinder/FirstClassErrors.RequestBinder.csproj create mode 100644 FirstClassErrors.RequestBinder/IArgumentNameProvider.cs create mode 100644 FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs create mode 100644 FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs create mode 100644 FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs create mode 100644 FirstClassErrors.RequestBinder/OptionalReferenceProperty.cs create mode 100644 FirstClassErrors.RequestBinder/OptionalValueProperty.cs create mode 100644 FirstClassErrors.RequestBinder/PropertySelectors.cs create mode 100644 FirstClassErrors.RequestBinder/README.nuget.md create mode 100644 FirstClassErrors.RequestBinder/RequestBinder.cs create mode 100644 FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs create mode 100644 FirstClassErrors.RequestBinder/RequestBinderOptions.cs create mode 100644 FirstClassErrors.RequestBinder/RequestBindingError.cs create mode 100644 FirstClassErrors.RequestBinder/RequiredProperty.cs create mode 100644 FirstClassErrors.RequestBinder/SimplePropertyConverter.cs diff --git a/CLAUDE.md b/CLAUDE.md index d595578..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 diff --git a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs new file mode 100644 index 0000000..c2e708a --- /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) { + RequiredProperty checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredProperty checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + return stay.Build(() => new Stay(checkIn.Value, checkOut.Value)); + } + + private static Outcome BindGuest(RequestBinder guest) { + RequiredProperty firstName = guest.SimpleProperty(g => g.FirstName).AsRequired(); + OptionalReferenceProperty email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse); + + return guest.Build(() => new Guest(firstName.Value, email.Value)); + } + + private static Outcome BindCommand(BookingRequest request) { + var bind = Bind.PropertiesOf(request).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredProperty email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredProperty reference = bind.SimpleProperty(r => r.Reference).AsRequired(); + RequiredProperty currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + OptionalValueProperty maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + OptionalReferenceProperty stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); + RequiredProperty> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredProperty> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + + return bind.Build(() => new BookingCommand( + email.Value, reference.Value, currency.Value, maxNights.Value, + stay.Value, tags.Value, guests.Value)); + } + + [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..761e95f --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs @@ -0,0 +1,88 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +public sealed class ComplexPropertyBindingTests { + + private static Outcome BindStay(RequestBinder stay) { + RequiredProperty checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredProperty checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + return stay.Build(() => new Stay(checkIn.Value, checkOut.Value)); + } + + 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); + + RequiredProperty stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + + Outcome outcome = bind.Build(() => stay.Value); + 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.Build(() => "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.Build(() => "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); + OptionalReferenceProperty none = absent.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); + Check.That(none.Value).IsNull(); + Check.That(absent.Build(() => "built").IsSuccess).IsTrue(); + + var present = Bind.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalReferenceProperty some = present.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); + Check.That(some.Value!.CheckOut.Value).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.Build(() => "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..49a342c --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs @@ -0,0 +1,126 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +public sealed class ListBindingTests { + + private static Outcome BindGuest(RequestBinder guest) { + RequiredProperty firstName = guest.SimpleProperty(g => g.FirstName).AsRequired(); + OptionalReferenceProperty email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse); + + return guest.Build(() => new Guest(firstName.Value, email.Value)); + } + + 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); + + RequiredProperty> tags = bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + + Outcome> outcome = bind.Build(() => tags.Value); + 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.Build(() => "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.Build(() => "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); + + RequiredProperty> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + + Outcome> outcome = bind.Build(() => tags.Value); + 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); + + RequiredProperty> guests = + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + + Outcome> outcome = bind.Build(() => guests.Value); + 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.Build(() => "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.Build(() => "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 = "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); + + RequiredProperty> guests = + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(BindGuest); + + Outcome> outcome = bind.Build(() => guests.Value); + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEmpty(); + } + +} diff --git a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs new file mode 100644 index 0000000..17b8b3f --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs @@ -0,0 +1,148 @@ +#region Usings declarations + +using System.Reflection; + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +public sealed class RequestBinderTests { + + private static Outcome BindStay(RequestBinder stay) { + RequiredProperty checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredProperty checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + return stay.Build(() => new Stay(checkIn.Value, checkOut.Value)); + } + + [Fact(DisplayName = "Build assembles the command exactly once when every property bound.")] + public void BuildAssemblesOnceOnSuccess() { + var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", "EUR", null, null, null, null)) + .FailWith(BookingEnvelopeError.CommandInvalid); + RequiredProperty email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + int assembled = 0; + Outcome outcome = bind.Build(() => { + assembled++; + + return email.Value.Value; + }); + + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(assembled).IsEqualTo(1); + } + + [Fact(DisplayName = "Build never runs the assembly function when a failure was recorded — handle values are safe by construction.")] + public void BuildNeverAssemblesOnFailure() { + 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.Build(() => { + assembled = true; + + return "never"; + }); + + Check.That(outcome.IsFailure).IsTrue(); + Check.That(assembled).IsFalse(); + } + + [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.Build(() => "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 => { + RequiredProperty firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); + + return g.Build(() => new Guest(firstName.Value, null)); + }); + + return bind.Build(() => "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); + + Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail!.ToUpperInvariant())) + .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.Build(() => "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.Build(() => "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..2b01b27 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs @@ -0,0 +1,146 @@ +#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); + + RequiredProperty email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + Outcome outcome = bind.Build(() => email.Value.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.Build(() => "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.Build(() => "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); + RequiredProperty reference = present.SimpleProperty(r => r.Reference).AsRequired(); + Check.That(present.Build(() => reference.Value).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.Build(() => "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); + RequiredProperty providedCurrency = provided.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + Check.That(provided.Build(() => providedCurrency.Value.Code).GetResultOrThrow()).IsEqualTo("USD"); + + var absent = Bind.PropertiesOf(Request(currency: null)).FailWith(BookingEnvelopeError.CommandInvalid); + RequiredProperty defaulted = absent.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + Check.That(absent.Build(() => defaulted.Value.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.Build(() => "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.")] + public void InvalidFallbackIsABug() { + var bind = Bind.PropertiesOf(Request(currency: null)).FailWith(BookingEnvelopeError.CommandInvalid); + + Check.ThatCode(() => bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "NOT-A-CURRENCY")) + .Throws(); + } + + [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); + OptionalReferenceProperty none = absent.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + Check.That(none.Value).IsNull(); + Check.That(absent.Build(() => "built").IsSuccess).IsTrue(); + + var present = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalReferenceProperty some = present.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + Check.That(some.Value!.Value).IsEqualTo("alice@example.org"); + } + + [Fact(DisplayName = "An optional reference property that is present but invalid records an error.")] + public void OptionalReferencePresentButInvalidRecords() { + var bind = Bind.PropertiesOf(Request(email: "nope")).FailWith(BookingEnvelopeError.CommandInvalid); + + OptionalReferenceProperty email = bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + + Check.That(email.Value).IsNull(); + Check.That(bind.Build(() => "never").IsFailure).IsTrue(); + } + + [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); + OptionalValueProperty none = absent.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + Check.That(none.Value).IsNull(); + Check.That(none.Value.HasValue).IsFalse(); + Check.That(absent.Build(() => "built").IsSuccess).IsTrue(); + + var present = Bind.PropertiesOf(Request(nights: "5")).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalValueProperty some = present.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + Check.That(some.Value).IsEqualTo(5); + } + + [Fact(DisplayName = "An optional value property that is present but invalid records an error.")] + public void OptionalValuePresentButInvalidRecords() { + var bind = Bind.PropertiesOf(Request(nights: "-2")).FailWith(BookingEnvelopeError.CommandInvalid); + + OptionalValueProperty nights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + + Check.That(nights.Value).IsNull(); + Check.That(bind.Build(() => "never").IsFailure).IsTrue(); + } + +} 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..b1f2f33 --- /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.Build(() => new PlaceBookingCommand(email.Value, stay.Value)); +/// +/// +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/ComplexPropertyConverter.cs b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs new file mode 100644 index 0000000..f8f9f19 --- /dev/null +++ b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs @@ -0,0 +1,108 @@ +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 property handle. + /// Thrown when is null. + public RequiredProperty AsRequired(Func, Outcome> bindNested) where TProperty : notnull { + if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); } + + if (_isMissing) { + PrimaryPortError error = RequestBindingError.ArgumentRequired(_argumentPath); + _binder.Record(error); + + return new RequiredProperty(default!, error); + } + + Outcome outcome = BindNested(bindNested); + if (outcome.IsFailure) { + PrimaryPortError grouped = Grouped(outcome.Error!); + _binder.Record(grouped); + + return new RequiredProperty(default!, grouped); + } + + return new RequiredProperty(outcome.GetResultOrThrow(), failure: null); + } + + /// + /// Binds an optional complex argument: absent yields a null + /// 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 property handle. + /// Thrown when is null. + public OptionalReferenceProperty AsOptional(Func, Outcome> bindNested) where TProperty : class { + if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); } + + if (_isMissing) { return new OptionalReferenceProperty(value: null, failure: null); } + + Outcome outcome = BindNested(bindNested); + if (outcome.IsFailure) { + PrimaryPortError grouped = Grouped(outcome.Error!); + _binder.Record(grouped); + + return new OptionalReferenceProperty(value: null, grouped); + } + + return new OptionalReferenceProperty(outcome.GetResultOrThrow(), failure: null); + } + + private Outcome BindNested(Func, Outcome> bindNested) where TProperty : notnull { + RequestBinder nested = new(_value!, _envelope, _binder.Options, _argumentPath); + + return bindNested(nested); + } + + /// + /// A nested binding built with Build fails with its own envelope, already a + /// that self-describes the group — record it as-is. Any other failure (a + /// nested function returning a bare conversion failure) is wrapped so the argument path is preserved. + /// + private PrimaryPortError Grouped(Error error) { + return error as PrimaryPortError ?? RequestBindingError.ArgumentInvalid(_argumentPath, error); + } + +} 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..0f9d398 --- /dev/null +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs @@ -0,0 +1,118 @@ +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 property handle. + /// Thrown when is null. + public RequiredProperty> AsRequired(Func, Outcome> bindElement) where TProperty : notnull { + if (bindElement is null) { throw new ArgumentNullException(nameof(bindElement)); } + + if (_isMissing) { + PrimaryPortError error = RequestBindingError.ArgumentRequired(_argumentPath); + _binder.Record(error); + + return new RequiredProperty>(default!, error); + } + + 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 property handle. + /// Thrown when is null. + public RequiredProperty> AsOptional(Func, Outcome> bindElement) where TProperty : notnull { + if (bindElement is null) { throw new ArgumentNullException(nameof(bindElement)); } + + if (_isMissing) { + IReadOnlyList empty = new List(); + + return new RequiredProperty>(empty, failure: null); + } + + return BindElements(bindElement); + } + + private RequiredProperty> BindElements(Func, Outcome> bindElement) where TProperty : notnull { + List bound = new(); + PrimaryPortError? firstFailure = null; + int index = 0; + + foreach (TArgument? element in _values!) { + string elementPath = $"{_argumentPath}[{index}]"; + index++; + + if (element is null) { + // Every failing element is recorded (collect-all); the FIRST one also seeds the handle's outcome. + PrimaryPortError missing = RequestBindingError.ArgumentRequired(elementPath); + _binder.Record(missing); + firstFailure ??= missing; + + continue; + } + + RequestBinder nested = new(element!, _envelope, _binder.Options, elementPath); + Outcome outcome = bindElement(nested); + if (outcome.IsFailure) { + Error error = outcome.Error!; + PrimaryPortError grouped = error as PrimaryPortError ?? RequestBindingError.ArgumentInvalid(elementPath, error); + _binder.Record(grouped); + firstFailure ??= grouped; + + continue; + } + + bound.Add(outcome.GetResultOrThrow()); + } + + if (firstFailure is not null) { + return new RequiredProperty>(default!, firstFailure); + } + + return new RequiredProperty>(bound, failure: null); + } + +} 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; + +/// +/// The mandatory envelope stage of a list of complex properties: declares the envelope error each failing +/// element's nested binding is grouped into, before AsRequired / AsOptional become available. +/// +/// The type of the request DTO. +/// The element type of the DTO list. +public sealed class ListOfComplexPropertiesEnvelopeStage { + + #region Fields declarations + + private readonly RequestBinder _binder; + private readonly string _argumentPath; + private readonly IEnumerable? _values; + private readonly bool _isMissing; + + #endregion + + #region Constructors declarations + + internal ListOfComplexPropertiesEnvelopeStage(RequestBinder binder, string argumentPath, IEnumerable? values, bool isMissing) { + _binder = binder; + _argumentPath = argumentPath; + _values = values; + _isMissing = isMissing; + } + + #endregion + + /// + /// Declares the per-element envelope: the factory producing the under which + /// each failing element's nested-binding failures are grouped. + /// + /// The per-element envelope factory, receiving the collected failures of one element. + /// The converter stage offering AsRequired / AsOptional. + /// Thrown when is null. + public ListOfComplexPropertiesConverter FailWith(Func envelope) { + if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); } + + return new ListOfComplexPropertiesConverter(_binder, _argumentPath, _values, _isMissing, envelope); + } + +} diff --git a/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs new file mode 100644 index 0000000..e1b16c0 --- /dev/null +++ b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs @@ -0,0 +1,110 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Binds a list request property whose elements are converted by a plain value-object converter. Each failing +/// element is recorded individually, under its indexed path (Tags[2]), so one bad element never hides the +/// others. +/// +/// The type of the request DTO. +/// The element type of the DTO list. +public sealed class ListOfSimplePropertiesConverter { + + #region Fields declarations + + private readonly RequestBinder _binder; + private readonly string _argumentPath; + private readonly IEnumerable? _values; + private readonly bool _isMissing; + + #endregion + + #region Constructors declarations + + internal ListOfSimplePropertiesConverter(RequestBinder binder, string argumentPath, IEnumerable? values, bool isMissing) { + _binder = binder; + _argumentPath = argumentPath; + _values = values; + _isMissing = isMissing; + } + + #endregion + + /// + /// Binds a required list: a missing list records REQUEST_ARGUMENT_REQUIRED; each failing element + /// records REQUEST_ARGUMENT_INVALID under its indexed path. + /// + /// The type of the element value object. + /// The value-object converter applied to each element. + /// The bound property handle. + /// Thrown when is null. + public RequiredProperty> AsRequired(Func> convertElement) where TProperty : notnull { + if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); } + + if (_isMissing) { + PrimaryPortError error = RequestBindingError.ArgumentRequired(_argumentPath); + _binder.Record(error); + + return new RequiredProperty>(default!, error); + } + + return ConvertElements(convertElement); + } + + /// + /// Binds an optional list: absent yields an empty list (never null) and records nothing; each + /// failing element of a present list still records REQUEST_ARGUMENT_INVALID under its indexed path. + /// + /// The type of the element value object. + /// The value-object converter applied to each element. + /// The bound property handle. + /// Thrown when is null. + public RequiredProperty> AsOptional(Func> convertElement) where TProperty : notnull { + if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); } + + if (_isMissing) { + IReadOnlyList empty = new List(); + + return new RequiredProperty>(empty, failure: null); + } + + return ConvertElements(convertElement); + } + + private RequiredProperty> ConvertElements(Func> convertElement) where TProperty : notnull { + List converted = new(); + PrimaryPortError? firstFailure = null; + int index = 0; + + foreach (TArgument? element in _values!) { + string elementPath = $"{_argumentPath}[{index}]"; + index++; + + if (element is null) { + // Every failing element is recorded (collect-all); the FIRST one also seeds the handle's outcome. + PrimaryPortError missing = RequestBindingError.ArgumentRequired(elementPath); + _binder.Record(missing); + firstFailure ??= missing; + + continue; + } + + Outcome outcome = convertElement(element!); + if (outcome.IsFailure) { + PrimaryPortError invalid = RequestBindingError.ArgumentInvalid(elementPath, outcome.Error!); + _binder.Record(invalid); + firstFailure ??= invalid; + + continue; + } + + converted.Add(outcome.GetResultOrThrow()); + } + + if (firstFailure is not null) { + return new RequiredProperty>(default!, firstFailure); + } + + return new RequiredProperty>(converted, failure: null); + } + +} diff --git a/FirstClassErrors.RequestBinder/OptionalReferenceProperty.cs b/FirstClassErrors.RequestBinder/OptionalReferenceProperty.cs new file mode 100644 index 0000000..0d4b6e4 --- /dev/null +++ b/FirstClassErrors.RequestBinder/OptionalReferenceProperty.cs @@ -0,0 +1,38 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The result of binding an optional reference-type property without a fallback: is the +/// bound value, or null when the argument was absent from the request. +/// +/// +/// is a pure property (see for the guarantee). +/// Inside , Value == null means exactly "the +/// argument was absent": an argument that was present but invalid has recorded an error on the binder, so +/// Build never runs and that value is never observed. +/// +/// The reference type of the bound property. +public sealed class OptionalReferenceProperty where TProperty : class { + + #region Fields declarations + + private readonly TProperty? _value; + private readonly Error? _failure; + + #endregion + + #region Constructors declarations + + internal OptionalReferenceProperty(TProperty? value, Error? failure) { + _value = value; + _failure = failure; + } + + #endregion + + /// Gets the bound value, or null when the argument was absent from the request. + public TProperty? Value => _value; + + /// The failure recorded for this property, or null when it bound successfully or was absent. Kept for inspection. + internal Error? Failure => _failure; + +} diff --git a/FirstClassErrors.RequestBinder/OptionalValueProperty.cs b/FirstClassErrors.RequestBinder/OptionalValueProperty.cs new file mode 100644 index 0000000..33b2eb6 --- /dev/null +++ b/FirstClassErrors.RequestBinder/OptionalValueProperty.cs @@ -0,0 +1,39 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The result of binding an optional value-type property without a fallback: is the bound +/// value, or null when the argument was absent from the request — a real +/// null, never default(T) (an absent count is null, not 0). +/// +/// +/// is a pure property (see for the guarantee). +/// Inside , Value == null means exactly "the +/// argument was absent": an argument that was present but invalid has recorded an error on the binder, so +/// Build never runs and that value is never observed. +/// +/// The value type of the bound property. +public sealed class OptionalValueProperty where TProperty : struct { + + #region Fields declarations + + private readonly TProperty? _value; + private readonly Error? _failure; + + #endregion + + #region Constructors declarations + + internal OptionalValueProperty(TProperty? value, Error? failure) { + _value = value; + _failure = failure; + } + + #endregion + + /// Gets the bound value, or null when the argument was absent from the request. + public TProperty? Value => _value; + + /// The failure recorded for this property, or null when it bound successfully or was absent. Kept for inspection. + internal Error? Failure => _failure; + +} diff --git a/FirstClassErrors.RequestBinder/PropertySelectors.cs b/FirstClassErrors.RequestBinder/PropertySelectors.cs new file mode 100644 index 0000000..917ec61 --- /dev/null +++ b/FirstClassErrors.RequestBinder/PropertySelectors.cs @@ -0,0 +1,42 @@ +#region Usings declarations + +using System.Linq.Expressions; +using System.Reflection; + +#endregion + +namespace FirstClassErrors.RequestBinder; + +/// +/// Resolves the behind a property-selector lambda (r => r.GuestEmail). +/// +/// +/// Only a direct property access on the lambda parameter is a valid selector: the property name is what the +/// argument path is built from, so an arbitrary expression has no meaningful name. An invalid selector is a +/// programming error and is reported by throwing (the binder's bug channel). +/// +internal static class PropertySelectors { + + #region Statics members declarations + + internal static PropertyInfo GetProperty(Expression> selector) { + if (selector is null) { throw new ArgumentNullException(nameof(selector)); } + + // The compiler may wrap the member access in a Convert node (e.g. IReadOnlyList -> IEnumerable). + Expression body = selector.Body; + while (body is UnaryExpression { NodeType: ExpressionType.Convert } convert) { + body = convert.Operand; + } + + if (body is not MemberExpression { Member: PropertyInfo property } member || member.Expression is not ParameterExpression) { + throw new ArgumentException( + $"The selector '{selector}' must be a direct property access on the request parameter (e.g. r => r.GuestEmail).", + nameof(selector)); + } + + return property; + } + + #endregion + +} diff --git a/FirstClassErrors.RequestBinder/README.nuget.md b/FirstClassErrors.RequestBinder/README.nuget.md new file mode 100644 index 0000000..558bcb4 --- /dev/null +++ b/FirstClassErrors.RequestBinder/README.nuget.md @@ -0,0 +1,20 @@ +# FirstClassErrors.RequestBinder + +Fluent, framework-agnostic request binding for [FirstClassErrors](https://github.com/Reefact/first-class-errors): convert an incoming request DTO (body + route) into a typed command or query of value objects at the primary-adapter boundary. + +* **Collect-all**: every failing field is reported at once — no fix-one-resubmit loop. +* **First-class errors**: failures are coded, documented `PrimaryPortError` trees (code + argument path + public/diagnostic messages), not flat strings. +* **No throw on the invalid-input path**: converters return `Outcome`; the binder returns `Outcome`. Exceptions are reserved for genuine bugs. +* **Framework-agnostic**: works the same for HTTP controllers, message consumers, CLIs and gRPC handlers. + +```csharp +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 command = + bind.Build(() => new PlaceBookingCommand(email.Value, stay.Value)); +``` + +See the [repository documentation](https://github.com/Reefact/first-class-errors) for the full guide. diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs new file mode 100644 index 0000000..52ba9e1 --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -0,0 +1,164 @@ +#region Usings declarations + +using System.Linq.Expressions; +using System.Reflection; + +#endregion + +namespace FirstClassErrors.RequestBinder; + +/// +/// Binds the properties of a request DTO into value objects, collecting every failure — instead of +/// stopping at the first — and grouping them under the envelope declared with +/// . +/// +/// +/// +/// No throw on the invalid-input path. Converters return ; every failure is +/// recorded as a coded error and surfaces once, as the failure of . A request +/// whose every field is invalid raises zero exceptions. Exceptions are reserved for programming errors +/// (a converter that throws, an invalid selector, a mis-declared fallback): the binder catches nothing, so a +/// genuine bug propagates to the host's exception boundary instead of being disguised as a client error. +/// +/// +/// Instances are created through and are not thread-safe: a binder +/// binds one request, in one scope. +/// +/// +/// The type of the request DTO. +public sealed class RequestBinder { + + #region Fields declarations + + private readonly TRequest _request; + private readonly Func _envelope; + private readonly string? _argumentPrefix; + private readonly List _errors = new(); + + #endregion + + #region Constructors declarations + + internal RequestBinder(TRequest request, Func envelope, RequestBinderOptions options, string? argumentPrefix) { + _request = request; + _envelope = envelope; + Options = options; + _argumentPrefix = argumentPrefix; + } + + #endregion + + /// The options this binder (and every binder nested under it) binds with. + internal RequestBinderOptions Options { get; private set; } + + /// Indicates whether at least one binding failure has been recorded. + internal bool HasErrors => _errors.Count > 0; + + /// + /// Replaces the binder options (for example to plug a serializer-aware + /// ). Call it before binding any property; nested binders inherit the + /// options in effect when they are created. + /// + /// The options to bind with. + /// This binder, for chaining. + /// Thrown when is null. + public RequestBinder WithOptions(RequestBinderOptions options) { + if (options is null) { throw new ArgumentNullException(nameof(options)); } + + Options = options; + + return this; + } + + /// + /// Selects a scalar property, converted by a plain value-object converter + /// (Func<TArgument, Outcome<T>>). + /// + /// The type of the DTO property. + /// A direct property access on the request parameter (e.g. r => r.GuestEmail). + /// The converter stage offering AsRequired / AsOptional and their variants. + public SimplePropertyConverter SimpleProperty(Expression> selector) { + (string path, object? value) = ResolveArgument(selector); + + return new SimplePropertyConverter(this, path, (TArgument?)value, value is null); + } + + /// + /// Selects a complex property, bound by a nested binder. Declare the nested envelope next, with + /// . + /// + /// The type of the nested DTO. + /// A direct property access on the request parameter (e.g. r => r.Stay). + /// The stage on which the nested envelope is declared. + public ComplexPropertyEnvelopeStage ComplexProperty(Expression> selector) { + (string path, object? value) = ResolveArgument(selector); + + return new ComplexPropertyEnvelopeStage(this, path, (TArgument?)value, value is null); + } + + /// + /// Selects a list property whose elements are converted by a plain value-object converter. + /// + /// The element type of the DTO list. + /// A direct property access on the request parameter (e.g. r => r.Tags). + /// The converter stage offering AsRequired / AsOptional. + public ListOfSimplePropertiesConverter ListOfSimpleProperties(Expression?>> selector) { + (string path, object? value) = ResolveArgument(selector); + + return new ListOfSimplePropertiesConverter(this, path, (IEnumerable?)value, value is null); + } + + /// + /// Selects a list property whose elements are bound by a nested binder (one per element). Declare the + /// per-element envelope next, with + /// . + /// + /// The element type of the DTO list. + /// A direct property access on the request parameter (e.g. r => r.Guests). + /// The stage on which the per-element envelope is declared. + public ListOfComplexPropertiesEnvelopeStage ListOfComplexProperties(Expression?>> selector) { + (string path, object? value) = ResolveArgument(selector); + + return new ListOfComplexPropertiesEnvelopeStage(this, path, (IEnumerable?)value, value is null); + } + + /// + /// Terminal: assembles the command when — and only when — no binding failure was recorded; otherwise returns + /// the failure of the envelope grouping every recorded error. Inside , every + /// property handle's Value is therefore valid by construction. + /// + /// The type of the bound command or query. + /// The assembly function, reading the bound values from the property handles. + /// The bound command, or the envelope failure. + /// Thrown when is null. + public Outcome Build(Func assemble) where TCommand : notnull { + if (assemble is null) { throw new ArgumentNullException(nameof(assemble)); } + + if (_errors.Count == 0) { return Outcome.Success(assemble()); } + + PrimaryPortInnerErrors innerErrors = new(); + foreach (PrimaryPortError error in _errors) { + innerErrors.Add(error); + } + + return Outcome.Failure(_envelope(innerErrors)); + } + + /// Records a binding failure; it will surface in the envelope built by . + internal void Record(PrimaryPortError error) { + _errors.Add(error); + } + + /// Prepends this binder's argument prefix to a path segment ("CheckIn" -> "Stay.CheckIn"). + internal string PathOf(string argumentName) { + return _argumentPrefix is null ? argumentName : $"{_argumentPrefix}.{argumentName}"; + } + + private (string Path, object? Value) ResolveArgument(Expression> selector) { + PropertyInfo property = PropertySelectors.GetProperty(selector); + string path = PathOf(Options.ArgumentNameProvider.GetArgumentNameFrom(property)); + + return (path, property.GetValue(_request)); + } + +} diff --git a/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs b/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs new file mode 100644 index 0000000..5ef071e --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs @@ -0,0 +1,39 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The mandatory first stage of a request binder: declares the envelope error every binding failure is grouped +/// into. Mirrors the staged-builder pattern the library uses for errors themselves (an error can never be left +/// without its public message; a binder can never be left without its envelope). +/// +/// The type of the request DTO. +public sealed class RequestBinderEnvelopeStage { + + #region Fields declarations + + private readonly TRequest _request; + + #endregion + + #region Constructors declarations + + internal RequestBinderEnvelopeStage(TRequest request) { + _request = request; + } + + #endregion + + /// + /// Declares the envelope: the factory producing the single under which every + /// failure recorded during the binding is grouped — typically an aggregate factory of the application's error + /// catalog, passed as a method group. + /// + /// The envelope factory, receiving the collected failures. + /// The request binder. + /// Thrown when is null. + public RequestBinder FailWith(Func envelope) { + if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); } + + return new RequestBinder(_request, envelope, RequestBinderOptions.Default, argumentPrefix: null); + } + +} diff --git a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs new file mode 100644 index 0000000..ec8074e --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs @@ -0,0 +1,33 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The binding options of a . Options are per-binder — passed through +/// and inherited by nested binders — never global mutable +/// state. +/// +public sealed class RequestBinderOptions { + + #region Statics members declarations + + /// The default options: argument names are the C# property names. + public static RequestBinderOptions Default { get; } = new(new DefaultArgumentNameProvider()); + + #endregion + + #region Constructors declarations + + /// Instantiates options with the given argument-name provider. + /// The provider resolving the argument name of a bound DTO property. + /// Thrown when is null. + public RequestBinderOptions(IArgumentNameProvider argumentNameProvider) { + if (argumentNameProvider is null) { throw new ArgumentNullException(nameof(argumentNameProvider)); } + + ArgumentNameProvider = argumentNameProvider; + } + + #endregion + + /// The provider resolving the argument name of a bound DTO property (see ). + public IArgumentNameProvider ArgumentNameProvider { get; } + +} diff --git a/FirstClassErrors.RequestBinder/RequestBindingError.cs b/FirstClassErrors.RequestBinder/RequestBindingError.cs new file mode 100644 index 0000000..8b3d4eb --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequestBindingError.cs @@ -0,0 +1,135 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Provides the structural binding errors raised by the request binder itself: an argument that is missing, and +/// an argument that is present but does not convert. Both carry the full argument path (for example +/// Guests[1].FirstName) in their context, so the failing field is identifiable without parsing messages. +/// +/// +/// These are the only errors the binder manufactures on its own. Every other error in a binding failure tree +/// comes from the application: the conversion errors returned by the value-object converters, and the envelope +/// errors declared with FailWith. +/// +[ProvidesErrorsFor("RequestBinding", + Description = "Structural validation of an incoming request at the primary-adapter boundary: required arguments and value conversions.")] +public static class RequestBindingError { + + #region Statics members declarations + + /// + /// The argument at is required but was absent from the request. + /// + /// Non-transient by nature: resubmitting the same request cannot succeed. + [DocumentedBy(nameof(ArgumentRequiredDocumentation))] + internal static PrimaryPortError ArgumentRequired(string argumentPath) { + return PrimaryPortError.Create( + Code.ArgumentRequired, + $"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."); + } + + /// + /// The argument at was present but failed to convert; the conversion error is + /// attached as the inner error. + /// + /// The full path of the failing argument. + /// + /// The error the converter failed with. Converters must fail with a or a + /// — the two families a accepts. Any + /// other family is a converter bug, reported by throwing (the binder's bug channel), never recorded. + /// + /// Thrown when belongs to another error family. + [DocumentedBy(nameof(ArgumentInvalidDocumentation))] + internal static PrimaryPortError ArgumentInvalid(string argumentPath, Error cause) { + PrimaryPortInnerErrors innerErrors = new(); + switch (cause) { + case DomainError domainError: + innerErrors.Add(domainError); + + break; + case PrimaryPortError primaryPortError: + innerErrors.Add(primaryPortError); + + break; + default: + throw new InvalidOperationException( + $"The converter of argument '{argumentPath}' failed with a {cause.GetType().Name}; a converter must fail with a DomainError or a PrimaryPortError."); + } + + return PrimaryPortError.Create( + Code.ArgumentInvalid, + $"Argument '{argumentPath}' is invalid.", + innerErrors, + ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath)) + .WithPublicMessage( + "An argument is invalid.", + $"The argument '{argumentPath}' is invalid."); + } + + private static ErrorDocumentation ArgumentRequiredDocumentation() { + return DescribeError.WithTitle("Required request argument missing") + .WithDescription("An incoming request omits an argument that the bound command requires. The full path of the missing argument is carried in the error context.") + .WithRule("Every argument bound with AsRequired must be present in the request.") + .WithDiagnostic("The client did not send the argument (wrong payload shape, renamed field, or version mismatch between client and API).", + ErrorOrigin.External, + "Compare the request payload with the API contract; check the client and API versions.") + .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("Guests[1].FirstName")); + } + + private static ErrorDocumentation ArgumentInvalidDocumentation() { + return DescribeError.WithTitle("Request argument invalid") + .WithDescription("An incoming request carries an argument that fails to convert into its value object. The full path of the failing argument is carried in the error context, and the precise conversion error is attached as the inner error.") + .WithRule("Every bound argument must convert successfully into its target value object.") + .WithDiagnostic("The client sent a malformed value (wrong format, out of range, unknown identifier).", + ErrorOrigin.External, + "Read the inner error: it is the value object's own coded error and states the violated rule.") + .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("GuestEmail", SampleCause())); + } + + /// A representative converter failure used only by the documentation example above. + private static DomainError SampleCause() { + return DomainError.Create( + ErrorCode.Create("EMAIL_ADDRESS_INVALID"), + "The value 'not-an-email' is not a valid email address.") + .WithPublicMessage("The email address is invalid."); + } + + #endregion + + #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 + + public static readonly ErrorContextKey RequestArgument = + ErrorContextKey.Create("RequestArgument", "Full path of the request argument that failed to bind (e.g. 'Guests[1].FirstName')."); + + #endregion + + } + + #endregion + +} diff --git a/FirstClassErrors.RequestBinder/RequiredProperty.cs b/FirstClassErrors.RequestBinder/RequiredProperty.cs new file mode 100644 index 0000000..0a0ca0a --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequiredProperty.cs @@ -0,0 +1,38 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The result of binding a required property (or an optional property with a fallback): a handle whose +/// is meant to be read inside . +/// +/// +/// is a pure property: the binding outcome is unwrapped once, by the binder, at +/// binding time — never lazily by the property itself. When the binding failed, the failure has already been +/// recorded on the binder, so never runs the assembly +/// function and the (default) value is never observed. Reading therefore never throws. +/// +/// The type of the bound property. +public sealed class RequiredProperty { + + #region Fields declarations + + private readonly TProperty _value; + private readonly Error? _failure; + + #endregion + + #region Constructors declarations + + internal RequiredProperty(TProperty value, Error? failure) { + _value = value; + _failure = failure; + } + + #endregion + + /// Gets the bound value. Safe by construction inside Build (see the class remarks). + public TProperty Value => _value; + + /// The failure recorded for this property, or null when it bound successfully. Kept for inspection. + internal Error? Failure => _failure; + +} diff --git a/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs b/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs new file mode 100644 index 0000000..6ee0485 --- /dev/null +++ b/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs @@ -0,0 +1,169 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Converts a scalar request property into a value object, through a plain converter +/// (Func<TArgument, Outcome<TProperty>>). +/// +/// +/// A converter fails by returning Outcome.Failure with a or a +/// — never by throwing. The binder catches nothing: a converter that throws is a +/// bug, and the exception propagates to the host's exception boundary. +/// +/// The type of the request DTO. +/// The type of the DTO property. +public sealed class SimplePropertyConverter { + + #region Fields declarations + + private readonly RequestBinder _binder; + private readonly string _argumentPath; + private readonly TArgument? _value; + private readonly bool _isMissing; + + #endregion + + #region Constructors declarations + + internal SimplePropertyConverter(RequestBinder binder, string argumentPath, TArgument? value, bool isMissing) { + _binder = binder; + _argumentPath = argumentPath; + _value = value; + _isMissing = isMissing; + } + + #endregion + + /// + /// Binds a required argument: missing records REQUEST_ARGUMENT_REQUIRED, a failed conversion records + /// REQUEST_ARGUMENT_INVALID wrapping the converter's error. + /// + /// The type of the value object. + /// The value-object converter. + /// The bound property handle. + /// Thrown when is null. + public RequiredProperty AsRequired(Func> convert) where TProperty : notnull { + if (convert is null) { throw new ArgumentNullException(nameof(convert)); } + + if (_isMissing) { return RequiredMissing(); } + + return RecordIfInvalid(convert(_value!)); + } + + /// + /// Binds a required argument without conversion: only the presence is checked, and the raw value is the bound + /// value. + /// + /// The bound property handle. + public RequiredProperty AsRequired() { + if (_isMissing) { + PrimaryPortError error = RequestBindingError.ArgumentRequired(_argumentPath); + _binder.Record(error); + + return new RequiredProperty(default!, error); + } + + return new RequiredProperty(_value!, failure: null); + } + + /// + /// Binds an optional argument with a fallback: when the argument is absent, + /// is converted instead, so the bound property always has a value. A present-but-invalid argument still + /// records REQUEST_ARGUMENT_INVALID — optional means "may be absent", never "may be malformed". + /// + /// The type of the value object. + /// The value-object converter. + /// The raw value converted when the argument is absent. + /// The bound property handle. + /// Thrown when is null. + /// + /// Thrown when itself fails to convert: a fallback is developer-supplied + /// configuration, so an invalid one is a bug, not a client error. + /// + public RequiredProperty AsOptional(Func> convert, TArgument rawFallback) where TProperty : notnull { + if (convert is null) { throw new ArgumentNullException(nameof(convert)); } + + if (!_isMissing) { return RecordIfInvalid(convert(_value!)); } + + Outcome fallback = convert(rawFallback); + if (fallback.IsFailure) { + throw new InvalidOperationException( + $"The configured fallback of optional argument '{_argumentPath}' does not convert: {fallback.Error!.DiagnosticMessage}"); + } + + return new RequiredProperty(fallback.GetResultOrThrow(), failure: null); + } + + /// + /// Binds an optional reference-type argument without a fallback: absent yields a null + /// and records nothing; a present-but-invalid + /// argument records REQUEST_ARGUMENT_INVALID. + /// + /// The reference type of the value object. + /// The value-object converter. + /// The bound property handle. + /// Thrown when is null. + public OptionalReferenceProperty AsOptionalReference(Func> convert) where TProperty : class { + if (convert is null) { throw new ArgumentNullException(nameof(convert)); } + + if (_isMissing) { return new OptionalReferenceProperty(value: null, failure: null); } + + Outcome outcome = convert(_value!); + if (outcome.IsFailure) { + PrimaryPortError wrapped = RecordInvalid(outcome.Error!); + + return new OptionalReferenceProperty(value: null, wrapped); + } + + return new OptionalReferenceProperty(outcome.GetResultOrThrow(), failure: null); + } + + /// + /// Binds an optional value-type argument without a fallback: absent yields a null + /// — a real null, + /// never default(TProperty) — and records nothing; a present-but-invalid argument records + /// REQUEST_ARGUMENT_INVALID. + /// + /// The value type of the value object. + /// The value-object converter. + /// The bound property handle. + /// Thrown when is null. + public OptionalValueProperty AsOptionalValue(Func> convert) where TProperty : struct { + if (convert is null) { throw new ArgumentNullException(nameof(convert)); } + + if (_isMissing) { return new OptionalValueProperty(value: null, failure: null); } + + Outcome outcome = convert(_value!); + if (outcome.IsFailure) { + PrimaryPortError wrapped = RecordInvalid(outcome.Error!); + + return new OptionalValueProperty(value: null, wrapped); + } + + return new OptionalValueProperty(outcome.GetResultOrThrow(), failure: null); + } + + private RequiredProperty RequiredMissing() where TProperty : notnull { + PrimaryPortError error = RequestBindingError.ArgumentRequired(_argumentPath); + _binder.Record(error); + + return new RequiredProperty(default!, error); + } + + private RequiredProperty RecordIfInvalid(Outcome outcome) where TProperty : notnull { + if (outcome.IsFailure) { + PrimaryPortError wrapped = RecordInvalid(outcome.Error!); + + return new RequiredProperty(default!, wrapped); + } + + return new RequiredProperty(outcome.GetResultOrThrow(), failure: null); + } + + private PrimaryPortError RecordInvalid(Error cause) { + PrimaryPortError wrapped = RequestBindingError.ArgumentInvalid(_argumentPath, cause); + _binder.Record(wrapped); + + return wrapped; + } + +} diff --git a/FirstClassErrors.sln b/FirstClassErrors.sln index 7bc5950..f1c34ca 100644 --- a/FirstClassErrors.sln +++ b/FirstClassErrors.sln @@ -41,6 +41,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.Cli.UnitTe EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.PropertyTests", "FirstClassErrors.PropertyTests\FirstClassErrors.PropertyTests.csproj", "{BB28120C-1D68-469D-928A-51AE454D2487}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.RequestBinder", "FirstClassErrors.RequestBinder\FirstClassErrors.RequestBinder.csproj", "{7991C766-2720-43E5-A9CB-2F3D6C2025FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.RequestBinder.UnitTests", "FirstClassErrors.RequestBinder.UnitTests\FirstClassErrors.RequestBinder.UnitTests.csproj", "{75EA57DD-0128-4EB9-ADBE-567BFF602A93}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -205,6 +209,30 @@ Global {BB28120C-1D68-469D-928A-51AE454D2487}.Release|x64.Build.0 = Release|Any CPU {BB28120C-1D68-469D-928A-51AE454D2487}.Release|x86.ActiveCfg = Release|Any CPU {BB28120C-1D68-469D-928A-51AE454D2487}.Release|x86.Build.0 = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|x64.ActiveCfg = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|x64.Build.0 = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|x86.ActiveCfg = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|x86.Build.0 = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|Any CPU.Build.0 = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|x64.ActiveCfg = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|x64.Build.0 = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|x86.ActiveCfg = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|x86.Build.0 = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|x64.ActiveCfg = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|x64.Build.0 = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|x86.ActiveCfg = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|x86.Build.0 = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|Any CPU.Build.0 = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x64.ActiveCfg = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x64.Build.0 = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x86.ActiveCfg = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 6d42572e05709972906bae99b35cfe5306850176 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 12:09:10 +0000 Subject: [PATCH 03/14] refactor(binder): drop the unused HasErrors probe The internal HasErrors property had no caller: recording and surfacing failures both go through Record and Build. Coverage flagged it as dead code; remove it rather than test it. Refs: #126 --- FirstClassErrors.RequestBinder/RequestBinder.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs index 52ba9e1..6d7707b 100644 --- a/FirstClassErrors.RequestBinder/RequestBinder.cs +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -51,9 +51,6 @@ internal RequestBinder(TRequest request, FuncThe options this binder (and every binder nested under it) binds with. internal RequestBinderOptions Options { get; private set; } - /// Indicates whether at least one binding failure has been recorded. - internal bool HasErrors => _errors.Count > 0; - /// /// Replaces the binder options (for example to plug a serializer-aware /// ). Call it before binding any property; nested binders inherit the From 739f3c61dac4b13b558f2aa3688ed1106fbd163b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 12:09:10 +0000 Subject: [PATCH 04/14] test(binder): pin the contract edges to full coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line and branch coverage measurement showed the first suite left real gaps beyond guard clauses: the PrimaryPortError converter-failure family, the foreign-family contract violation (which must throw), bare nested failures that bypass the envelope (which must be wrapped so the path survives), the missing required complex list, the failing optional complex list, the Convert-node unwrapping of boxing selectors, and the handles inspection seam. Cover each, plus every null guard and the documentation methods of the binder-owned errors, bringing the assembly to 100% line and branch coverage — measured, with the dead spots inspected rather than assumed. Refs: #126 --- .../BindingContractTests.cs | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs new file mode 100644 index 0000000..585b597 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -0,0 +1,236 @@ +#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 handles' inspection seam, 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.Build(() => "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."); + + Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail) + .AsRequired(_ => Outcome.Failure(foreignFamily))) + .Throws(); + } + + #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.Build(() => "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.Build(() => "never"); + Error wrapped = outcome.Error!.InnerErrors.Single(); + Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Guests[0]"); + } + + #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.Build(() => new Guest("never", null))); + + Outcome outcome = bind.Build(() => "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 failure of a simple list seeds the handle's failure 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.Build(() => "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 => { + RequiredProperty firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); + + return g.Build(() => new Guest(firstName.Value, null)); + }); + + Outcome outcome = bind.Build(() => "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 = "A null selector is a programming error and throws.")] + public void NullSelectorThrows() { + Check.ThatCode(() => PropertySelectors.GetProperty(null!)) + .Throws(); + } + + #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.Build(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 Handle inspection seam + + [Fact(DisplayName = "A handle exposes its recorded failure for inspection — and null when the binding succeeded or the argument was absent.")] + public void HandlesExposeTheirFailure() { + var bind = Bind.PropertiesOf(new BookingRequest(null, "REF-1", null, null, null, null, null)) + .FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredProperty failed = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredProperty bound = bind.SimpleProperty(r => r.Reference).AsRequired(); + OptionalReferenceProperty absent = bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + OptionalValueProperty nothing = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + + Check.That(failed.Failure!.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(bound.Failure).IsNull(); + Check.That(absent.Failure).IsNull(); + Check.That(nothing.Failure).IsNull(); + } + + #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(); + } + } + + #endregion + +} From 5b74d93950a72682d763a3575fb90ddb57436ea2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 17:23:19 +0000 Subject: [PATCH 05/14] feat(binder)!: read bound values through a Build-scoped reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a bound value out of a property handle relied on `handle.Value`, a pure property that returned the stored value. Read inside `Build` it was safe by construction, but nothing stopped a consumer from reading it *outside* `Build`, on a failed binding: a required reference yielded a silent `null` (NRE far from the binding site), and an optional value-type present-but-invalid was indistinguishable from absent — a silent, diagnosable-nowhere failure the library exists to forbid. The recorded failure was `internal`, so a consumer had no way to even test for it. Replace the value-bearing handles with opaque field tokens read through a `BindingScope`, so an invalid or premature read cannot be written: - `AsRequired`/`AsOptional*` now return `RequiredField` / `OptionalReferenceField` / `OptionalValueField` — tokens with no public value member. - `Build` takes a `BindingAssembler` whose single argument is a `BindingScope`; the value of a token is reachable only through `scope.Get(token)`. - `BindingScope` is a `readonly ref struct` (so it cannot escape the assembler) and `Build` constructs one only on its zero-error success branch. Reading a value before `Build`, or outside the assembler, therefore does not compile; a token from a different binder is rejected with `InvalidOperationException` (the binder's programming-error channel). The happy path stays `new Command(read.Get(email), read.Get(stay), …)`, with a named token at each argument. Collect-all, indexed list paths and nested envelopes are unchanged (failures already lived in the binder's ledger, not in the handles). netstandard2.0 stays green: the compiler synthesises the ref-like attribute. Binder tests keep 100% line and branch coverage (47 tests). BREAKING CHANGE: `handle.Value` is removed; read bound values through the `BindingScope` passed to `Build`. `Build` now takes `BindingAssembler` instead of `Func`. The handle types `RequiredProperty`, `OptionalReferenceProperty` and `OptionalValueProperty` are renamed `RequiredField`, `OptionalReferenceField` and `OptionalValueField`. Refs: #126 --- .../BindingContractTests.cs | 63 ++++++++------ .../BookingEndToEndTests.cs | 34 ++++---- .../ComplexPropertyBindingTests.cs | 25 +++--- .../ListBindingTests.cs | 30 +++---- .../RequestBinderTests.cs | 32 ++++--- .../SimplePropertyBindingTests.cs | 57 ++++++------ FirstClassErrors.RequestBinder/Bind.cs | 2 +- .../BindingAssembler.cs | 14 +++ .../BindingScope.cs | 86 +++++++++++++++++++ .../ComplexPropertyConverter.cs | 33 ++++--- .../ListOfComplexPropertiesConverter.cs | 41 ++++----- .../ListOfSimplePropertiesConverter.cs | 39 ++++----- .../OptionalReferenceField.cs | 37 ++++++++ .../OptionalReferenceProperty.cs | 38 -------- .../OptionalValueField.cs | 38 ++++++++ .../OptionalValueProperty.cs | 39 --------- .../README.nuget.md | 2 +- .../RequestBinder.cs | 11 +-- .../RequiredField.cs | 38 ++++++++ .../RequiredProperty.cs | 38 -------- .../SimplePropertyConverter.cs | 73 ++++++++-------- 21 files changed, 429 insertions(+), 341 deletions(-) create mode 100644 FirstClassErrors.RequestBinder/BindingAssembler.cs create mode 100644 FirstClassErrors.RequestBinder/BindingScope.cs create mode 100644 FirstClassErrors.RequestBinder/OptionalReferenceField.cs delete mode 100644 FirstClassErrors.RequestBinder/OptionalReferenceProperty.cs create mode 100644 FirstClassErrors.RequestBinder/OptionalValueField.cs delete mode 100644 FirstClassErrors.RequestBinder/OptionalValueProperty.cs create mode 100644 FirstClassErrors.RequestBinder/RequiredField.cs delete mode 100644 FirstClassErrors.RequestBinder/RequiredProperty.cs diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index 585b597..df410fe 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -11,8 +11,8 @@ 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 handles' inspection seam, and the binder's own -/// error documentation. +/// 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 { @@ -33,7 +33,7 @@ public void ConverterMayFailWithAPrimaryPortError() { PrimaryPortError.Create(ErrorCode.Create("TEST_PORT_LEVEL_REJECTION"), "Rejected at the port.", Transience.NonTransient) .WithPublicMessage("Rejected."))); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "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"); @@ -64,7 +64,7 @@ public void BareNestedFailureIsWrapped() { bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) .AsRequired(_ => Outcome.Failure(BookingDomainError.DateInvalid("raw"))); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Error wrapped = outcome.Error!.InnerErrors.Single(); Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Stay"); @@ -78,7 +78,7 @@ public void BareNestedElementFailureIsWrapped() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) .AsRequired(_ => Outcome.Failure(BookingDomainError.EmailInvalid("raw"))); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Error wrapped = outcome.Error!.InnerErrors.Single(); Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Guests[0]"); @@ -94,22 +94,22 @@ public void RequiredComplexListMissing() { .FailWith(BookingEnvelopeError.CommandInvalid); bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) - .AsRequired(g => g.Build(() => new Guest("never", null))); + .AsRequired(g => g.Build(_ => new Guest("never", null))); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "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 failure of a simple list seeds the handle's failure like any other.")] + [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.Build(() => "never"); + Outcome outcome = bind.Build(_ => "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)) @@ -123,12 +123,12 @@ public void OptionalComplexListPresentCollectsFailures() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) .AsOptional(g => { - RequiredProperty firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); + RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); - return g.Build(() => new Guest(firstName.Value, null)); + return g.Build(read => new Guest(read.Get(firstName), null)); }); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_GUEST_INVALID"); } @@ -154,6 +154,19 @@ public void NullSelectorThrows() { .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 Guard clauses: every entry point rejects null collaborators @@ -188,22 +201,22 @@ public void GuardClauses() { #endregion - #region Handle inspection seam + #region The binding scope guards its reads - [Fact(DisplayName = "A handle exposes its recorded failure for inspection — and null when the binding succeeded or the argument was absent.")] - public void HandlesExposeTheirFailure() { - var bind = Bind.PropertiesOf(new BookingRequest(null, "REF-1", null, null, null, null, null)) - .FailWith(BookingEnvelopeError.CommandInvalid); + [Fact(DisplayName = "The binding scope rejects a null field (every overload) and a field owned by a different binder — programming errors both.")] + public void BindingScopeGuardsItsReads() { + var binderA = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + RequiredField tokenOfA = binderA.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); - RequiredProperty failed = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - RequiredProperty bound = bind.SimpleProperty(r => r.Reference).AsRequired(); - OptionalReferenceProperty absent = bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); - OptionalValueProperty nothing = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + // A null field, on each of the three Get overloads (the assembler runs because `bind` recorded no failure): + Check.ThatCode(() => bind.Build(read => read.Get((RequiredField)null!).Value)).Throws(); + Check.ThatCode(() => bind.Build(read => read.Get((OptionalReferenceField)null!) is null)).Throws(); + Check.ThatCode(() => bind.Build(read => read.Get((OptionalValueField)null!).HasValue)).Throws(); - Check.That(failed.Failure!.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); - Check.That(bound.Failure).IsNull(); - Check.That(absent.Failure).IsNull(); - Check.That(nothing.Failure).IsNull(); + // A field owned by a different binder is a cross-binder mix-up — rejected loudly, not read silently. + Check.ThatCode(() => bind.Build(read => read.Get(tokenOfA).Value)).Throws(); } #endregion diff --git a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs index c2e708a..aaec6c2 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs @@ -13,33 +13,33 @@ namespace FirstClassErrors.RequestBinder.UnitTests; public sealed class BookingEndToEndTests { private static Outcome BindStay(RequestBinder stay) { - RequiredProperty checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); - RequiredProperty checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); - return stay.Build(() => new Stay(checkIn.Value, checkOut.Value)); + return stay.Build(read => new Stay(read.Get(checkIn), read.Get(checkOut))); } private static Outcome BindGuest(RequestBinder guest) { - RequiredProperty firstName = guest.SimpleProperty(g => g.FirstName).AsRequired(); - OptionalReferenceProperty email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse); + RequiredField firstName = guest.SimpleProperty(g => g.FirstName).AsRequired(); + OptionalReferenceField email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse); - return guest.Build(() => new Guest(firstName.Value, email.Value)); + return guest.Build(read => new Guest(read.Get(firstName), read.Get(email))); } private static Outcome BindCommand(BookingRequest request) { var bind = Bind.PropertiesOf(request).FailWith(BookingEnvelopeError.CommandInvalid); - RequiredProperty email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - RequiredProperty reference = bind.SimpleProperty(r => r.Reference).AsRequired(); - RequiredProperty currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); - OptionalValueProperty maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); - OptionalReferenceProperty stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); - RequiredProperty> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); - RequiredProperty> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - - return bind.Build(() => new BookingCommand( - email.Value, reference.Value, currency.Value, maxNights.Value, - stay.Value, tags.Value, guests.Value)); + 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.Build(read => new BookingCommand( + read.Get(email), read.Get(reference), read.Get(currency), read.Get(maxNights), + read.Get(stay), read.Get(tags), read.Get(guests))); } [Fact(DisplayName = "A fully valid request binds into the complete command and flows through Then/Finally.")] diff --git a/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs index 761e95f..e7232a7 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs @@ -9,10 +9,10 @@ namespace FirstClassErrors.RequestBinder.UnitTests; public sealed class ComplexPropertyBindingTests { private static Outcome BindStay(RequestBinder stay) { - RequiredProperty checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); - RequiredProperty checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); - return stay.Build(() => new Stay(checkIn.Value, checkOut.Value)); + return stay.Build(read => new Stay(read.Get(checkIn), read.Get(checkOut))); } private static BookingRequest RequestWith(StayDto? stay) { @@ -23,9 +23,9 @@ private static BookingRequest RequestWith(StayDto? stay) { public void RequiredComplexBinds() { var bind = Bind.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))).FailWith(BookingEnvelopeError.CommandInvalid); - RequiredProperty stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + RequiredField stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); - Outcome outcome = bind.Build(() => stay.Value); + Outcome outcome = bind.Build(read => read.Get(stay)); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow().CheckIn.Value).IsEqualTo(new DateOnly(2026, 8, 10)); } @@ -36,7 +36,7 @@ public void NestedFailureSurfacesAsItsEnvelopeWithPrefixedPaths() { bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Error stayEnvelope = outcome.Error!.InnerErrors.Single(); @@ -56,7 +56,7 @@ public void RequiredComplexMissing() { bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Error required = outcome.Error!.InnerErrors.Single(); Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Stay"); @@ -65,13 +65,12 @@ public void RequiredComplexMissing() { [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); - OptionalReferenceProperty none = absent.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); - Check.That(none.Value).IsNull(); - Check.That(absent.Build(() => "built").IsSuccess).IsTrue(); + OptionalReferenceField none = absent.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); + Check.That(absent.Build(read => read.Get(none) is null).GetResultOrThrow()).IsTrue(); var present = Bind.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))).FailWith(BookingEnvelopeError.CommandInvalid); - OptionalReferenceProperty some = present.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); - Check.That(some.Value!.CheckOut.Value).IsEqualTo(new DateOnly(2026, 8, 14)); + OptionalReferenceField some = present.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); + Check.That(present.Build(read => read.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.")] @@ -80,7 +79,7 @@ public void OptionalComplexPresentButInvalidRecords() { bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_STAY_INVALID"); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs index 49a342c..14ab93c 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs @@ -9,10 +9,10 @@ namespace FirstClassErrors.RequestBinder.UnitTests; public sealed class ListBindingTests { private static Outcome BindGuest(RequestBinder guest) { - RequiredProperty firstName = guest.SimpleProperty(g => g.FirstName).AsRequired(); - OptionalReferenceProperty email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse); + RequiredField firstName = guest.SimpleProperty(g => g.FirstName).AsRequired(); + OptionalReferenceField email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse); - return guest.Build(() => new Guest(firstName.Value, email.Value)); + return guest.Build(read => new Guest(read.Get(firstName), read.Get(email))); } private static BookingRequest RequestWith(IReadOnlyList? tags = null, IReadOnlyList? guests = null) { @@ -23,9 +23,9 @@ private static BookingRequest RequestWith(IReadOnlyList? tags = null, I public void RequiredSimpleListBinds() { var bind = Bind.PropertiesOf(RequestWith(tags: ["vip", "late-checkout"])).FailWith(BookingEnvelopeError.CommandInvalid); - RequiredProperty> tags = bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); - Outcome> outcome = bind.Build(() => tags.Value); + Outcome> outcome = bind.Build(read => read.Get(tags)); Check.That(outcome.GetResultOrThrow().Select(t => t.Value)).ContainsExactly("vip", "late-checkout"); } @@ -35,7 +35,7 @@ public void RequiredSimpleListMissing() { bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Error required = outcome.Error!.InnerErrors.Single(); Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Tags"); @@ -47,7 +47,7 @@ public void EveryFailingElementIsCollected() { bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Check.That(outcome.Error!.InnerErrors).HasSize(2); Check.That(outcome.Error!.InnerErrors.Select(BindingAssertions.ArgumentPathOf)) .ContainsExactly("Tags[1]", "Tags[2]"); @@ -59,9 +59,9 @@ public void EveryFailingElementIsCollected() { public void OptionalListAbsentBindsEmpty() { var bind = Bind.PropertiesOf(RequestWith(tags: null)).FailWith(BookingEnvelopeError.CommandInvalid); - RequiredProperty> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); - Outcome> outcome = bind.Build(() => tags.Value); + Outcome> outcome = bind.Build(read => read.Get(tags)); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow()).IsEmpty(); } @@ -71,10 +71,10 @@ public void RequiredComplexListBinds() { var bind = Bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", "alice@example.org"), new GuestDto("Bob", null)])) .FailWith(BookingEnvelopeError.CommandInvalid); - RequiredProperty> guests = + RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - Outcome> outcome = bind.Build(() => guests.Value); + Outcome> outcome = bind.Build(read => read.Get(guests)); Check.That(outcome.GetResultOrThrow().Select(g => g.FirstName)).ContainsExactly("Alice", "Bob"); Check.That(outcome.GetResultOrThrow()[1].Email).IsNull(); } @@ -86,7 +86,7 @@ public void FailingComplexElementsRecordTheirEnvelopes() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Check.That(outcome.Error!.InnerErrors).HasSize(2); Error second = outcome.Error!.InnerErrors[0]; @@ -105,7 +105,7 @@ public void NullComplexElementIsRequired() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Error required = outcome.Error!.InnerErrors.Single(); Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Guests[1]"); @@ -115,10 +115,10 @@ public void NullComplexElementIsRequired() { public void OptionalComplexListAbsentBindsEmpty() { var bind = Bind.PropertiesOf(RequestWith(guests: null)).FailWith(BookingEnvelopeError.CommandInvalid); - RequiredProperty> guests = + RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(BindGuest); - Outcome> outcome = bind.Build(() => guests.Value); + Outcome> outcome = bind.Build(read => read.Get(guests)); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow()).IsEmpty(); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs index 17b8b3f..64ea711 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs @@ -11,37 +11,37 @@ namespace FirstClassErrors.RequestBinder.UnitTests; public sealed class RequestBinderTests { private static Outcome BindStay(RequestBinder stay) { - RequiredProperty checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); - RequiredProperty checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); - return stay.Build(() => new Stay(checkIn.Value, checkOut.Value)); + return stay.Build(read => new Stay(read.Get(checkIn), read.Get(checkOut))); } [Fact(DisplayName = "Build assembles the command exactly once when every property bound.")] public void BuildAssemblesOnceOnSuccess() { var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", "EUR", null, null, null, null)) .FailWith(BookingEnvelopeError.CommandInvalid); - RequiredProperty email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); int assembled = 0; - Outcome outcome = bind.Build(() => { + Outcome outcome = bind.Build(read => { assembled++; - return email.Value.Value; + return read.Get(email).Value; }); Check.That(outcome.IsSuccess).IsTrue(); Check.That(assembled).IsEqualTo(1); } - [Fact(DisplayName = "Build never runs the assembly function when a failure was recorded — handle values are safe by construction.")] + [Fact(DisplayName = "Build never runs the assembler when a failure was recorded — field reads are safe by construction.")] public void BuildNeverAssemblesOnFailure() { 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.Build(() => { + Outcome outcome = bind.Build(_ => { assembled = true; return "never"; @@ -60,7 +60,7 @@ public void CollectsEveryFailure() { bind.SimpleProperty(r => r.Reference).AsRequired(); bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "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"); @@ -81,12 +81,12 @@ public void FullyInvalidRequestThrowsNothing() { 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 => { - RequiredProperty firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); + RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); - return g.Build(() => new Guest(firstName.Value, null)); + return g.Build(read => new Guest(read.Get(firstName), null)); }); - return bind.Build(() => "never"); + return bind.Build(_ => "never"); }) .DoesNotThrow(); } @@ -106,8 +106,12 @@ 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. Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail!.ToUpperInvariant())) .Throws(); + // 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.")] @@ -119,7 +123,7 @@ public void CustomNameProviderRenamesPaths() { bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "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"); @@ -132,7 +136,7 @@ public void MissingArgumentIsNonTransient() { bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); var required = (InfrastructureError)outcome.Error!.InnerErrors.Single(); Check.That(required.Transience).IsEqualTo(Transience.NonTransient); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs index 2b01b27..c17cf47 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs @@ -18,9 +18,9 @@ private static BookingRequest Request(string? email = "alice@example.org", public void RequiredPresentAndValidBinds() { var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); - RequiredProperty email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - Outcome outcome = bind.Build(() => email.Value.Value); + Outcome outcome = bind.Build(read => read.Get(email).Value); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow()).IsEqualTo("alice@example.org"); } @@ -31,7 +31,7 @@ public void RequiredMissingFails() { bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID"); @@ -46,7 +46,7 @@ public void RequiredInvalidWrapsTheConverterError() { bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Error invalid = outcome.Error!.InnerErrors.Single(); @@ -58,12 +58,12 @@ public void RequiredInvalidWrapsTheConverterError() { [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); - RequiredProperty reference = present.SimpleProperty(r => r.Reference).AsRequired(); - Check.That(present.Build(() => reference.Value).GetResultOrThrow()).IsEqualTo("REF-1"); + RequiredField reference = present.SimpleProperty(r => r.Reference).AsRequired(); + Check.That(present.Build(read => read.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.Build(() => "never"); + Outcome outcome = missing.Build(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); } @@ -71,12 +71,12 @@ public void RequiredWithoutConversion() { [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); - RequiredProperty providedCurrency = provided.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); - Check.That(provided.Build(() => providedCurrency.Value.Code).GetResultOrThrow()).IsEqualTo("USD"); + RequiredField providedCurrency = provided.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + Check.That(provided.Build(read => read.Get(providedCurrency).Code).GetResultOrThrow()).IsEqualTo("USD"); var absent = Bind.PropertiesOf(Request(currency: null)).FailWith(BookingEnvelopeError.CommandInvalid); - RequiredProperty defaulted = absent.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); - Check.That(absent.Build(() => defaulted.Value.Code).GetResultOrThrow()).IsEqualTo("EUR"); + RequiredField defaulted = absent.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + Check.That(absent.Build(read => read.Get(defaulted).Code).GetResultOrThrow()).IsEqualTo("EUR"); } [Fact(DisplayName = "An optional property that is present but invalid still records an error: optional never means malformed.")] @@ -85,7 +85,7 @@ public void OptionalPresentButInvalidRecords() { bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); - Outcome outcome = bind.Build(() => "never"); + Outcome outcome = bind.Build(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); } @@ -101,46 +101,45 @@ public void InvalidFallbackIsABug() { [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); - OptionalReferenceProperty none = absent.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); - Check.That(none.Value).IsNull(); - Check.That(absent.Build(() => "built").IsSuccess).IsTrue(); + OptionalReferenceField none = absent.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + Check.That(absent.Build(read => read.Get(none) is null).GetResultOrThrow()).IsTrue(); var present = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); - OptionalReferenceProperty some = present.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); - Check.That(some.Value!.Value).IsEqualTo("alice@example.org"); + OptionalReferenceField some = present.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + Check.That(present.Build(read => read.Get(some)!.Value).GetResultOrThrow()).IsEqualTo("alice@example.org"); } [Fact(DisplayName = "An optional reference property that is present but invalid records an error.")] public void OptionalReferencePresentButInvalidRecords() { var bind = Bind.PropertiesOf(Request(email: "nope")).FailWith(BookingEnvelopeError.CommandInvalid); - OptionalReferenceProperty email = bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); - Check.That(email.Value).IsNull(); - Check.That(bind.Build(() => "never").IsFailure).IsTrue(); + Check.That(bind.Build(_ => "never").IsFailure).IsTrue(); } [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); - OptionalValueProperty none = absent.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); - Check.That(none.Value).IsNull(); - Check.That(none.Value.HasValue).IsFalse(); - Check.That(absent.Build(() => "built").IsSuccess).IsTrue(); + OptionalValueField none = absent.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + // Project to a non-null bool: Build's TCommand cannot itself be int? (Nullable is not `notnull`); + // a real consumer flows read.Get(none) straight into a command constructor argument instead. + Outcome absentOutcome = absent.Build(read => read.Get(none).HasValue); + Check.That(absentOutcome.IsSuccess).IsTrue(); + Check.That(absentOutcome.GetResultOrThrow()).IsFalse(); var present = Bind.PropertiesOf(Request(nights: "5")).FailWith(BookingEnvelopeError.CommandInvalid); - OptionalValueProperty some = present.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); - Check.That(some.Value).IsEqualTo(5); + OptionalValueField some = present.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + Check.That(present.Build(read => read.Get(some) ?? 0).GetResultOrThrow()).IsEqualTo(5); } [Fact(DisplayName = "An optional value property that is present but invalid records an error.")] public void OptionalValuePresentButInvalidRecords() { var bind = Bind.PropertiesOf(Request(nights: "-2")).FailWith(BookingEnvelopeError.CommandInvalid); - OptionalValueProperty nights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); - Check.That(nights.Value).IsNull(); - Check.That(bind.Build(() => "never").IsFailure).IsTrue(); + Check.That(bind.Build(_ => "never").IsFailure).IsTrue(); } } diff --git a/FirstClassErrors.RequestBinder/Bind.cs b/FirstClassErrors.RequestBinder/Bind.cs index b1f2f33..357d1db 100644 --- a/FirstClassErrors.RequestBinder/Bind.cs +++ b/FirstClassErrors.RequestBinder/Bind.cs @@ -13,7 +13,7 @@ namespace FirstClassErrors.RequestBinder; /// var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); /// /// Outcome<PlaceBookingCommand> command = -/// bind.Build(() => new PlaceBookingCommand(email.Value, stay.Value)); +/// bind.Build(read => new PlaceBookingCommand(read.Get(email), read.Get(stay))); /// /// public static class Bind { diff --git a/FirstClassErrors.RequestBinder/BindingAssembler.cs b/FirstClassErrors.RequestBinder/BindingAssembler.cs new file mode 100644 index 0000000..41ab06c --- /dev/null +++ b/FirstClassErrors.RequestBinder/BindingAssembler.cs @@ -0,0 +1,14 @@ +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. +/// +/// +/// 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 read); diff --git a/FirstClassErrors.RequestBinder/BindingScope.cs b/FirstClassErrors.RequestBinder/BindingScope.cs new file mode 100644 index 0000000..226ea2b --- /dev/null +++ b/FirstClassErrors.RequestBinder/BindingScope.cs @@ -0,0 +1,86 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The reader handed to '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 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 Build, 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 Build of the binder that bound it."); + } + } + +} diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs index f8f9f19..b96fd92 100644 --- a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs +++ b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs @@ -42,52 +42,49 @@ internal ComplexPropertyConverter(RequestBinder /// /// The type the nested binding produces. /// The nested binding function (typically a method group such as BindStay). - /// The bound property handle. + /// The bound field token. /// Thrown when is null. - public RequiredProperty AsRequired(Func, Outcome> bindNested) where TProperty : notnull { + public RequiredField AsRequired(Func, Outcome> bindNested) where TProperty : notnull { if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); } if (_isMissing) { - PrimaryPortError error = RequestBindingError.ArgumentRequired(_argumentPath); - _binder.Record(error); + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); - return new RequiredProperty(default!, error); + return new RequiredField(_binder, default!); } Outcome outcome = BindNested(bindNested); if (outcome.IsFailure) { - PrimaryPortError grouped = Grouped(outcome.Error!); - _binder.Record(grouped); + _binder.Record(Grouped(outcome.Error!)); - return new RequiredProperty(default!, grouped); + return new RequiredField(_binder, default!); } - return new RequiredProperty(outcome.GetResultOrThrow(), failure: null); + return new RequiredField(_binder, outcome.GetResultOrThrow()); } /// /// Binds an optional complex argument: absent yields a null - /// and records nothing; a present-but-invalid - /// nested binding records its envelope. + /// 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 property handle. + /// The bound field token. /// Thrown when is null. - public OptionalReferenceProperty AsOptional(Func, Outcome> bindNested) where TProperty : class { + public OptionalReferenceField AsOptional(Func, Outcome> bindNested) where TProperty : class { if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); } - if (_isMissing) { return new OptionalReferenceProperty(value: null, failure: null); } + if (_isMissing) { return new OptionalReferenceField(_binder, value: null); } Outcome outcome = BindNested(bindNested); if (outcome.IsFailure) { - PrimaryPortError grouped = Grouped(outcome.Error!); - _binder.Record(grouped); + _binder.Record(Grouped(outcome.Error!)); - return new OptionalReferenceProperty(value: null, grouped); + return new OptionalReferenceField(_binder, value: null); } - return new OptionalReferenceProperty(outcome.GetResultOrThrow(), failure: null); + return new OptionalReferenceField(_binder, outcome.GetResultOrThrow()); } private Outcome BindNested(Func, Outcome> bindNested) where TProperty : notnull { diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs index 0f9d398..f59fa9f 100644 --- a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs @@ -41,16 +41,15 @@ internal ListOfComplexPropertiesConverter(RequestBinder /// /// The type each element's nested binding produces. /// The nested binding function applied to each element (typically a method group). - /// The bound property handle. + /// The bound field token. /// Thrown when is null. - public RequiredProperty> AsRequired(Func, Outcome> bindElement) where TProperty : notnull { + public RequiredField> AsRequired(Func, Outcome> bindElement) where TProperty : notnull { if (bindElement is null) { throw new ArgumentNullException(nameof(bindElement)); } if (_isMissing) { - PrimaryPortError error = RequestBindingError.ArgumentRequired(_argumentPath); - _binder.Record(error); + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); - return new RequiredProperty>(default!, error); + return new RequiredField>(_binder, default!); } return BindElements(bindElement); @@ -62,34 +61,30 @@ public RequiredProperty> AsRequired(Func /// The type each element's nested binding produces. /// The nested binding function applied to each element (typically a method group). - /// The bound property handle. + /// The bound field token. /// Thrown when is null. - public RequiredProperty> AsOptional(Func, Outcome> bindElement) where TProperty : notnull { + 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 RequiredProperty>(empty, failure: null); + return new RequiredField>(_binder, empty); } return BindElements(bindElement); } - private RequiredProperty> BindElements(Func, Outcome> bindElement) where TProperty : notnull { - List bound = new(); - PrimaryPortError? firstFailure = null; - int index = 0; + 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) { - // Every failing element is recorded (collect-all); the FIRST one also seeds the handle's outcome. - PrimaryPortError missing = RequestBindingError.ArgumentRequired(elementPath); - _binder.Record(missing); - firstFailure ??= missing; + _binder.Record(RequestBindingError.ArgumentRequired(elementPath)); continue; } @@ -97,10 +92,8 @@ private RequiredProperty> BindElements(Func< RequestBinder nested = new(element!, _envelope, _binder.Options, elementPath); Outcome outcome = bindElement(nested); if (outcome.IsFailure) { - Error error = outcome.Error!; - PrimaryPortError grouped = error as PrimaryPortError ?? RequestBindingError.ArgumentInvalid(elementPath, error); - _binder.Record(grouped); - firstFailure ??= grouped; + Error error = outcome.Error!; + _binder.Record(error as PrimaryPortError ?? RequestBindingError.ArgumentInvalid(elementPath, error)); continue; } @@ -108,11 +101,9 @@ private RequiredProperty> BindElements(Func< bound.Add(outcome.GetResultOrThrow()); } - if (firstFailure is not null) { - return new RequiredProperty>(default!, firstFailure); - } - - return new RequiredProperty>(bound, failure: null); + // The value is read only through a BindingScope, which Build 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/ListOfSimplePropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs index e1b16c0..dfb1ad5 100644 --- a/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs @@ -35,16 +35,15 @@ internal ListOfSimplePropertiesConverter(RequestBinder binder, string /// /// The type of the element value object. /// The value-object converter applied to each element. - /// The bound property handle. + /// The bound field token. /// Thrown when is null. - public RequiredProperty> AsRequired(Func> convertElement) where TProperty : notnull { + public RequiredField> AsRequired(Func> convertElement) where TProperty : notnull { if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); } if (_isMissing) { - PrimaryPortError error = RequestBindingError.ArgumentRequired(_argumentPath); - _binder.Record(error); + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); - return new RequiredProperty>(default!, error); + return new RequiredField>(_binder, default!); } return ConvertElements(convertElement); @@ -56,43 +55,37 @@ public RequiredProperty> AsRequired(Func /// The type of the element value object. /// The value-object converter applied to each element. - /// The bound property handle. + /// The bound field token. /// Thrown when is null. - public RequiredProperty> AsOptional(Func> convertElement) where TProperty : notnull { + public RequiredField> AsOptional(Func> convertElement) where TProperty : notnull { if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); } if (_isMissing) { IReadOnlyList empty = new List(); - return new RequiredProperty>(empty, failure: null); + return new RequiredField>(_binder, empty); } return ConvertElements(convertElement); } - private RequiredProperty> ConvertElements(Func> convertElement) where TProperty : notnull { - List converted = new(); - PrimaryPortError? firstFailure = null; - int index = 0; + private RequiredField> ConvertElements(Func> convertElement) where TProperty : notnull { + List converted = new(); + int index = 0; foreach (TArgument? element in _values!) { string elementPath = $"{_argumentPath}[{index}]"; index++; if (element is null) { - // Every failing element is recorded (collect-all); the FIRST one also seeds the handle's outcome. - PrimaryPortError missing = RequestBindingError.ArgumentRequired(elementPath); - _binder.Record(missing); - firstFailure ??= missing; + _binder.Record(RequestBindingError.ArgumentRequired(elementPath)); continue; } Outcome outcome = convertElement(element!); if (outcome.IsFailure) { - PrimaryPortError invalid = RequestBindingError.ArgumentInvalid(elementPath, outcome.Error!); - _binder.Record(invalid); - firstFailure ??= invalid; + _binder.Record(RequestBindingError.ArgumentInvalid(elementPath, outcome.Error!)); continue; } @@ -100,11 +93,9 @@ private RequiredProperty> ConvertElements(Fu converted.Add(outcome.GetResultOrThrow()); } - if (firstFailure is not null) { - return new RequiredProperty>(default!, firstFailure); - } - - return new RequiredProperty>(converted, failure: null); + // The value is read only through a BindingScope, which Build creates solely when no failure was recorded — + // i.e. only when every element converted — so `converted` is the complete list exactly when it is observed. + return new RequiredField>(_binder, converted); } } diff --git a/FirstClassErrors.RequestBinder/OptionalReferenceField.cs b/FirstClassErrors.RequestBinder/OptionalReferenceField.cs new file mode 100644 index 0000000..36a6a81 --- /dev/null +++ b/FirstClassErrors.RequestBinder/OptionalReferenceField.cs @@ -0,0 +1,37 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// A token standing for a bound optional reference-type property. Read through +/// , its value is the bound value, or +/// null when the argument was absent from the request. +/// +/// +/// Returned by the AsOptionalReference family. Inside +/// a null read means exactly "the argument was absent": a present-but-invalid argument recorded a failure on +/// the binder, so Build never runs the assembler and that state is never observed. +/// +/// The reference type of the bound property. +public sealed class OptionalReferenceField where TProperty : class { + + #region Fields declarations + + private readonly TProperty? _value; + + #endregion + + #region Constructors declarations + + internal OptionalReferenceField(object owner, TProperty? value) { + Owner = owner; + _value = value; + } + + #endregion + + /// The binder that produced this token; used to reject a token read through a different binder's scope. + internal object Owner { get; } + + /// The bound value, or null when absent. Internal: consumers reach it only through . + internal TProperty? Value => _value; + +} diff --git a/FirstClassErrors.RequestBinder/OptionalReferenceProperty.cs b/FirstClassErrors.RequestBinder/OptionalReferenceProperty.cs deleted file mode 100644 index 0d4b6e4..0000000 --- a/FirstClassErrors.RequestBinder/OptionalReferenceProperty.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace FirstClassErrors.RequestBinder; - -/// -/// The result of binding an optional reference-type property without a fallback: is the -/// bound value, or null when the argument was absent from the request. -/// -/// -/// is a pure property (see for the guarantee). -/// Inside , Value == null means exactly "the -/// argument was absent": an argument that was present but invalid has recorded an error on the binder, so -/// Build never runs and that value is never observed. -/// -/// The reference type of the bound property. -public sealed class OptionalReferenceProperty where TProperty : class { - - #region Fields declarations - - private readonly TProperty? _value; - private readonly Error? _failure; - - #endregion - - #region Constructors declarations - - internal OptionalReferenceProperty(TProperty? value, Error? failure) { - _value = value; - _failure = failure; - } - - #endregion - - /// Gets the bound value, or null when the argument was absent from the request. - public TProperty? Value => _value; - - /// The failure recorded for this property, or null when it bound successfully or was absent. Kept for inspection. - internal Error? Failure => _failure; - -} diff --git a/FirstClassErrors.RequestBinder/OptionalValueField.cs b/FirstClassErrors.RequestBinder/OptionalValueField.cs new file mode 100644 index 0000000..67e207a --- /dev/null +++ b/FirstClassErrors.RequestBinder/OptionalValueField.cs @@ -0,0 +1,38 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// A token standing for a bound optional value-type property. Read through +/// , its value is the bound value as a +/// real null when the argument was absent, never default(T) (an absent +/// count is null, not 0). +/// +/// +/// Returned by the AsOptionalValue family. Inside a +/// null read means exactly "the argument was absent": a present-but-invalid argument recorded a failure on +/// the binder, so Build never runs the assembler and that state is never observed. +/// +/// The value type of the bound property. +public sealed class OptionalValueField where TProperty : struct { + + #region Fields declarations + + private readonly TProperty? _value; + + #endregion + + #region Constructors declarations + + internal OptionalValueField(object owner, TProperty? value) { + Owner = owner; + _value = value; + } + + #endregion + + /// The binder that produced this token; used to reject a token read through a different binder's scope. + internal object Owner { get; } + + /// The bound value, or null when absent. Internal: consumers reach it only through . + internal TProperty? Value => _value; + +} diff --git a/FirstClassErrors.RequestBinder/OptionalValueProperty.cs b/FirstClassErrors.RequestBinder/OptionalValueProperty.cs deleted file mode 100644 index 33b2eb6..0000000 --- a/FirstClassErrors.RequestBinder/OptionalValueProperty.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace FirstClassErrors.RequestBinder; - -/// -/// The result of binding an optional value-type property without a fallback: is the bound -/// value, or null when the argument was absent from the request — a real -/// null, never default(T) (an absent count is null, not 0). -/// -/// -/// is a pure property (see for the guarantee). -/// Inside , Value == null means exactly "the -/// argument was absent": an argument that was present but invalid has recorded an error on the binder, so -/// Build never runs and that value is never observed. -/// -/// The value type of the bound property. -public sealed class OptionalValueProperty where TProperty : struct { - - #region Fields declarations - - private readonly TProperty? _value; - private readonly Error? _failure; - - #endregion - - #region Constructors declarations - - internal OptionalValueProperty(TProperty? value, Error? failure) { - _value = value; - _failure = failure; - } - - #endregion - - /// Gets the bound value, or null when the argument was absent from the request. - public TProperty? Value => _value; - - /// The failure recorded for this property, or null when it bound successfully or was absent. Kept for inspection. - internal Error? Failure => _failure; - -} diff --git a/FirstClassErrors.RequestBinder/README.nuget.md b/FirstClassErrors.RequestBinder/README.nuget.md index 558bcb4..f2610df 100644 --- a/FirstClassErrors.RequestBinder/README.nuget.md +++ b/FirstClassErrors.RequestBinder/README.nuget.md @@ -14,7 +14,7 @@ var email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); Outcome command = - bind.Build(() => new PlaceBookingCommand(email.Value, stay.Value)); + bind.Build(read => new PlaceBookingCommand(read.Get(email), read.Get(stay))); ``` See the [repository documentation](https://github.com/Reefact/first-class-errors) for the full guide. diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs index 6d7707b..ddefb40 100644 --- a/FirstClassErrors.RequestBinder/RequestBinder.cs +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -121,17 +121,18 @@ public ListOfComplexPropertiesEnvelopeStage ListOfComplexPr /// /// Terminal: assembles the command when — and only when — no binding failure was recorded; otherwise returns - /// the failure of the envelope grouping every recorded error. Inside , every - /// property handle's Value is therefore valid by construction. + /// the failure of the envelope grouping every recorded error. The assembler receives a + /// and reads each bound value through it; because that scope is created only on this + /// success branch, every read is valid by construction. /// /// The type of the bound command or query. - /// The assembly function, reading the bound values from the property handles. + /// The assembler, reading the bound values from the supplied . /// The bound command, or the envelope failure. /// Thrown when is null. - public Outcome Build(Func assemble) where TCommand : notnull { + public Outcome Build(BindingAssembler assemble) where TCommand : notnull { if (assemble is null) { throw new ArgumentNullException(nameof(assemble)); } - if (_errors.Count == 0) { return Outcome.Success(assemble()); } + if (_errors.Count == 0) { return Outcome.Success(assemble(new BindingScope(this))); } PrimaryPortInnerErrors innerErrors = new(); foreach (PrimaryPortError error in _errors) { diff --git a/FirstClassErrors.RequestBinder/RequiredField.cs b/FirstClassErrors.RequestBinder/RequiredField.cs new file mode 100644 index 0000000..5b10961 --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequiredField.cs @@ -0,0 +1,38 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// A token standing for a bound required property (or an optional property with a fallback). It carries no public +/// value: the bound value is reachable only through +/// , inside +/// — where every binding is known to have succeeded. +/// +/// +/// Returned by the AsRequired family. When the binding failed, the failure has already been recorded on the +/// binder (so it surfaces in the envelope built by Build) and this token stands in with no readable value — +/// harmless, because Build never runs the assembler when any failure was recorded. +/// +/// The type of the bound property. +public sealed class RequiredField { + + #region Fields declarations + + private readonly TProperty _value; + + #endregion + + #region Constructors declarations + + internal RequiredField(object owner, TProperty value) { + Owner = owner; + _value = value; + } + + #endregion + + /// The binder that produced this token; used to reject a token read through a different binder's scope. + internal object Owner { get; } + + /// The bound value. Internal: consumers reach it only through . + internal TProperty Value => _value; + +} diff --git a/FirstClassErrors.RequestBinder/RequiredProperty.cs b/FirstClassErrors.RequestBinder/RequiredProperty.cs deleted file mode 100644 index 0a0ca0a..0000000 --- a/FirstClassErrors.RequestBinder/RequiredProperty.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace FirstClassErrors.RequestBinder; - -/// -/// The result of binding a required property (or an optional property with a fallback): a handle whose -/// is meant to be read inside . -/// -/// -/// is a pure property: the binding outcome is unwrapped once, by the binder, at -/// binding time — never lazily by the property itself. When the binding failed, the failure has already been -/// recorded on the binder, so never runs the assembly -/// function and the (default) value is never observed. Reading therefore never throws. -/// -/// The type of the bound property. -public sealed class RequiredProperty { - - #region Fields declarations - - private readonly TProperty _value; - private readonly Error? _failure; - - #endregion - - #region Constructors declarations - - internal RequiredProperty(TProperty value, Error? failure) { - _value = value; - _failure = failure; - } - - #endregion - - /// Gets the bound value. Safe by construction inside Build (see the class remarks). - public TProperty Value => _value; - - /// The failure recorded for this property, or null when it bound successfully. Kept for inspection. - internal Error? Failure => _failure; - -} diff --git a/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs b/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs index 6ee0485..23cb94b 100644 --- a/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs +++ b/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs @@ -39,9 +39,9 @@ internal SimplePropertyConverter(RequestBinder binder, string argument /// /// The type of the value object. /// The value-object converter. - /// The bound property handle. + /// The bound field token. /// Thrown when is null. - public RequiredProperty AsRequired(Func> convert) where TProperty : notnull { + public RequiredField AsRequired(Func> convert) where TProperty : notnull { if (convert is null) { throw new ArgumentNullException(nameof(convert)); } if (_isMissing) { return RequiredMissing(); } @@ -53,16 +53,15 @@ public RequiredProperty AsRequired(Func - /// The bound property handle. - public RequiredProperty AsRequired() { + /// The bound field token. + public RequiredField AsRequired() { if (_isMissing) { - PrimaryPortError error = RequestBindingError.ArgumentRequired(_argumentPath); - _binder.Record(error); + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); - return new RequiredProperty(default!, error); + return new RequiredField(_binder, default!); } - return new RequiredProperty(_value!, failure: null); + return new RequiredField(_binder, _value!); } /// @@ -73,13 +72,13 @@ public RequiredProperty AsRequired() { /// The type of the value object. /// The value-object converter. /// The raw value converted when the argument is absent. - /// The bound property handle. + /// The bound field token. /// Thrown when is null. /// /// Thrown when itself fails to convert: a fallback is developer-supplied /// configuration, so an invalid one is a bug, not a client error. /// - public RequiredProperty AsOptional(Func> convert, TArgument rawFallback) where TProperty : notnull { + public RequiredField AsOptional(Func> convert, TArgument rawFallback) where TProperty : notnull { if (convert is null) { throw new ArgumentNullException(nameof(convert)); } if (!_isMissing) { return RecordIfInvalid(convert(_value!)); } @@ -90,80 +89,76 @@ public RequiredProperty AsOptional(Func(fallback.GetResultOrThrow(), failure: null); + return new RequiredField(_binder, fallback.GetResultOrThrow()); } /// /// Binds an optional reference-type argument without a fallback: absent yields a null - /// and records nothing; a present-but-invalid + /// value and records nothing; a present-but-invalid /// argument records REQUEST_ARGUMENT_INVALID. /// /// The reference type of the value object. /// The value-object converter. - /// The bound property handle. + /// The bound field token. /// Thrown when is null. - public OptionalReferenceProperty AsOptionalReference(Func> convert) where TProperty : class { + public OptionalReferenceField AsOptionalReference(Func> convert) where TProperty : class { if (convert is null) { throw new ArgumentNullException(nameof(convert)); } - if (_isMissing) { return new OptionalReferenceProperty(value: null, failure: null); } + if (_isMissing) { return new OptionalReferenceField(_binder, value: null); } Outcome outcome = convert(_value!); if (outcome.IsFailure) { - PrimaryPortError wrapped = RecordInvalid(outcome.Error!); + RecordInvalid(outcome.Error!); - return new OptionalReferenceProperty(value: null, wrapped); + return new OptionalReferenceField(_binder, value: null); } - return new OptionalReferenceProperty(outcome.GetResultOrThrow(), failure: null); + return new OptionalReferenceField(_binder, outcome.GetResultOrThrow()); } /// /// Binds an optional value-type argument without a fallback: absent yields a null - /// — a real null, - /// never default(TProperty) — and records nothing; a present-but-invalid argument records + /// value — a real null, never + /// default(TProperty) — and records nothing; a present-but-invalid argument records /// REQUEST_ARGUMENT_INVALID. /// /// The value type of the value object. /// The value-object converter. - /// The bound property handle. + /// The bound field token. /// Thrown when is null. - public OptionalValueProperty AsOptionalValue(Func> convert) where TProperty : struct { + public OptionalValueField AsOptionalValue(Func> convert) where TProperty : struct { if (convert is null) { throw new ArgumentNullException(nameof(convert)); } - if (_isMissing) { return new OptionalValueProperty(value: null, failure: null); } + if (_isMissing) { return new OptionalValueField(_binder, value: null); } Outcome outcome = convert(_value!); if (outcome.IsFailure) { - PrimaryPortError wrapped = RecordInvalid(outcome.Error!); + RecordInvalid(outcome.Error!); - return new OptionalValueProperty(value: null, wrapped); + return new OptionalValueField(_binder, value: null); } - return new OptionalValueProperty(outcome.GetResultOrThrow(), failure: null); + return new OptionalValueField(_binder, outcome.GetResultOrThrow()); } - private RequiredProperty RequiredMissing() where TProperty : notnull { - PrimaryPortError error = RequestBindingError.ArgumentRequired(_argumentPath); - _binder.Record(error); + private RequiredField RequiredMissing() { + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); - return new RequiredProperty(default!, error); + return new RequiredField(_binder, default!); } - private RequiredProperty RecordIfInvalid(Outcome outcome) where TProperty : notnull { + private RequiredField RecordIfInvalid(Outcome outcome) where TProperty : notnull { if (outcome.IsFailure) { - PrimaryPortError wrapped = RecordInvalid(outcome.Error!); + RecordInvalid(outcome.Error!); - return new RequiredProperty(default!, wrapped); + return new RequiredField(_binder, default!); } - return new RequiredProperty(outcome.GetResultOrThrow(), failure: null); + return new RequiredField(_binder, outcome.GetResultOrThrow()); } - private PrimaryPortError RecordInvalid(Error cause) { - PrimaryPortError wrapped = RequestBindingError.ArgumentInvalid(_argumentPath, cause); - _binder.Record(wrapped); - - return wrapped; + private void RecordInvalid(Error cause) { + _binder.Record(RequestBindingError.ArgumentInvalid(_argumentPath, cause)); } } From cbf982e2dc30bd177bfe6a7a52900d21ff68eed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:11:51 +0000 Subject: [PATCH 06/14] style(binder): name the Build scope lambda parameter s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shorten the assembler lambda's parameter from `read` to `s` (for "scope") at every call site — the two doc examples (Bind.cs, README.nuget.md) and the unit tests — so the happy path reads `s.Get(x)`. The BindingAssembler delegate's formal parameter is renamed `scope`, which reads better in the signature than a single letter. No behavior change: build clean, 47 binder tests green. Refs: #126 --- .../BindingContractTests.cs | 10 +++++----- .../BookingEndToEndTests.cs | 10 +++++----- .../ComplexPropertyBindingTests.cs | 8 ++++---- .../ListBindingTests.cs | 10 +++++----- .../RequestBinderTests.cs | 8 ++++---- .../SimplePropertyBindingTests.cs | 18 +++++++++--------- FirstClassErrors.RequestBinder/Bind.cs | 2 +- .../BindingAssembler.cs | 4 ++-- FirstClassErrors.RequestBinder/README.nuget.md | 2 +- 9 files changed, 36 insertions(+), 36 deletions(-) diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index df410fe..e6f2e32 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -125,7 +125,7 @@ public void OptionalComplexListPresentCollectsFailures() { .AsOptional(g => { RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); - return g.Build(read => new Guest(read.Get(firstName), null)); + return g.Build(s => new Guest(s.Get(firstName), null)); }); Outcome outcome = bind.Build(_ => "never"); @@ -211,12 +211,12 @@ public void BindingScopeGuardsItsReads() { 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.Build(read => read.Get((RequiredField)null!).Value)).Throws(); - Check.ThatCode(() => bind.Build(read => read.Get((OptionalReferenceField)null!) is null)).Throws(); - Check.ThatCode(() => bind.Build(read => read.Get((OptionalValueField)null!).HasValue)).Throws(); + Check.ThatCode(() => bind.Build(s => s.Get((RequiredField)null!).Value)).Throws(); + Check.ThatCode(() => bind.Build(s => s.Get((OptionalReferenceField)null!) is null)).Throws(); + Check.ThatCode(() => bind.Build(s => s.Get((OptionalValueField)null!).HasValue)).Throws(); // A field owned by a different binder is a cross-binder mix-up — rejected loudly, not read silently. - Check.ThatCode(() => bind.Build(read => read.Get(tokenOfA).Value)).Throws(); + Check.ThatCode(() => bind.Build(s => s.Get(tokenOfA).Value)).Throws(); } #endregion diff --git a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs index aaec6c2..6e02aea 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs @@ -16,14 +16,14 @@ 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.Build(read => new Stay(read.Get(checkIn), read.Get(checkOut))); + return stay.Build(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.Build(read => new Guest(read.Get(firstName), read.Get(email))); + return guest.Build(s => new Guest(s.Get(firstName), s.Get(email))); } private static Outcome BindCommand(BookingRequest request) { @@ -37,9 +37,9 @@ private static Outcome BindCommand(BookingRequest request) { RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - return bind.Build(read => new BookingCommand( - read.Get(email), read.Get(reference), read.Get(currency), read.Get(maxNights), - read.Get(stay), read.Get(tags), read.Get(guests))); + return bind.Build(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.")] diff --git a/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs index e7232a7..c337575 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs @@ -12,7 +12,7 @@ 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.Build(read => new Stay(read.Get(checkIn), read.Get(checkOut))); + return stay.Build(s => new Stay(s.Get(checkIn), s.Get(checkOut))); } private static BookingRequest RequestWith(StayDto? stay) { @@ -25,7 +25,7 @@ public void RequiredComplexBinds() { RequiredField stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); - Outcome outcome = bind.Build(read => read.Get(stay)); + Outcome outcome = bind.Build(s => s.Get(stay)); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow().CheckIn.Value).IsEqualTo(new DateOnly(2026, 8, 10)); } @@ -66,11 +66,11 @@ public void RequiredComplexMissing() { 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.Build(read => read.Get(none) is null).GetResultOrThrow()).IsTrue(); + Check.That(absent.Build(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.Build(read => read.Get(some)!.CheckOut.Value).GetResultOrThrow()).IsEqualTo(new DateOnly(2026, 8, 14)); + Check.That(present.Build(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.")] diff --git a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs index 14ab93c..8415fce 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs @@ -12,7 +12,7 @@ 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.Build(read => new Guest(read.Get(firstName), read.Get(email))); + return guest.Build(s => new Guest(s.Get(firstName), s.Get(email))); } private static BookingRequest RequestWith(IReadOnlyList? tags = null, IReadOnlyList? guests = null) { @@ -25,7 +25,7 @@ public void RequiredSimpleListBinds() { RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); - Outcome> outcome = bind.Build(read => read.Get(tags)); + Outcome> outcome = bind.Build(s => s.Get(tags)); Check.That(outcome.GetResultOrThrow().Select(t => t.Value)).ContainsExactly("vip", "late-checkout"); } @@ -61,7 +61,7 @@ public void OptionalListAbsentBindsEmpty() { RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); - Outcome> outcome = bind.Build(read => read.Get(tags)); + Outcome> outcome = bind.Build(s => s.Get(tags)); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow()).IsEmpty(); } @@ -74,7 +74,7 @@ public void RequiredComplexListBinds() { RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - Outcome> outcome = bind.Build(read => read.Get(guests)); + Outcome> outcome = bind.Build(s => s.Get(guests)); Check.That(outcome.GetResultOrThrow().Select(g => g.FirstName)).ContainsExactly("Alice", "Bob"); Check.That(outcome.GetResultOrThrow()[1].Email).IsNull(); } @@ -118,7 +118,7 @@ public void OptionalComplexListAbsentBindsEmpty() { RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(BindGuest); - Outcome> outcome = bind.Build(read => read.Get(guests)); + Outcome> outcome = bind.Build(s => s.Get(guests)); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow()).IsEmpty(); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs index 64ea711..8a19f18 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs @@ -14,7 +14,7 @@ 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.Build(read => new Stay(read.Get(checkIn), read.Get(checkOut))); + return stay.Build(s => new Stay(s.Get(checkIn), s.Get(checkOut))); } [Fact(DisplayName = "Build assembles the command exactly once when every property bound.")] @@ -24,10 +24,10 @@ public void BuildAssemblesOnceOnSuccess() { RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); int assembled = 0; - Outcome outcome = bind.Build(read => { + Outcome outcome = bind.Build(s => { assembled++; - return read.Get(email).Value; + return s.Get(email).Value; }); Check.That(outcome.IsSuccess).IsTrue(); @@ -83,7 +83,7 @@ public void FullyInvalidRequestThrowsNothing() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(g => { RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); - return g.Build(read => new Guest(read.Get(firstName), null)); + return g.Build(s => new Guest(s.Get(firstName), null)); }); return bind.Build(_ => "never"); diff --git a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs index c17cf47..a385db0 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs @@ -20,7 +20,7 @@ public void RequiredPresentAndValidBinds() { RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - Outcome outcome = bind.Build(read => read.Get(email).Value); + Outcome outcome = bind.Build(s => s.Get(email).Value); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow()).IsEqualTo("alice@example.org"); } @@ -59,7 +59,7 @@ public void RequiredInvalidWrapsTheConverterError() { public void RequiredWithoutConversion() { var present = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); RequiredField reference = present.SimpleProperty(r => r.Reference).AsRequired(); - Check.That(present.Build(read => read.Get(reference)).GetResultOrThrow()).IsEqualTo("REF-1"); + Check.That(present.Build(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(); @@ -72,11 +72,11 @@ public void RequiredWithoutConversion() { 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.Build(read => read.Get(providedCurrency).Code).GetResultOrThrow()).IsEqualTo("USD"); + Check.That(provided.Build(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.Build(read => read.Get(defaulted).Code).GetResultOrThrow()).IsEqualTo("EUR"); + Check.That(absent.Build(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.")] @@ -102,11 +102,11 @@ public void InvalidFallbackIsABug() { 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.Build(read => read.Get(none) is null).GetResultOrThrow()).IsTrue(); + Check.That(absent.Build(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.Build(read => read.Get(some)!.Value).GetResultOrThrow()).IsEqualTo("alice@example.org"); + Check.That(present.Build(s => s.Get(some)!.Value).GetResultOrThrow()).IsEqualTo("alice@example.org"); } [Fact(DisplayName = "An optional reference property that is present but invalid records an error.")] @@ -123,14 +123,14 @@ 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: Build's TCommand cannot itself be int? (Nullable is not `notnull`); - // a real consumer flows read.Get(none) straight into a command constructor argument instead. - Outcome absentOutcome = absent.Build(read => read.Get(none).HasValue); + // a real consumer flows s.Get(none) straight into a command constructor argument instead. + Outcome absentOutcome = absent.Build(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.Build(read => read.Get(some) ?? 0).GetResultOrThrow()).IsEqualTo(5); + Check.That(present.Build(s => s.Get(some) ?? 0).GetResultOrThrow()).IsEqualTo(5); } [Fact(DisplayName = "An optional value property that is present but invalid records an error.")] diff --git a/FirstClassErrors.RequestBinder/Bind.cs b/FirstClassErrors.RequestBinder/Bind.cs index 357d1db..4bb75a6 100644 --- a/FirstClassErrors.RequestBinder/Bind.cs +++ b/FirstClassErrors.RequestBinder/Bind.cs @@ -13,7 +13,7 @@ namespace FirstClassErrors.RequestBinder; /// var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); /// /// Outcome<PlaceBookingCommand> command = -/// bind.Build(read => new PlaceBookingCommand(read.Get(email), read.Get(stay))); +/// bind.Build(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); /// /// public static class Bind { diff --git a/FirstClassErrors.RequestBinder/BindingAssembler.cs b/FirstClassErrors.RequestBinder/BindingAssembler.cs index 41ab06c..64d5daf 100644 --- a/FirstClassErrors.RequestBinder/BindingAssembler.cs +++ b/FirstClassErrors.RequestBinder/BindingAssembler.cs @@ -9,6 +9,6 @@ namespace FirstClassErrors.RequestBinder; /// 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 scope through which bound values are read. /// The assembled command. -public delegate TCommand BindingAssembler(BindingScope read); +public delegate TCommand BindingAssembler(BindingScope scope); diff --git a/FirstClassErrors.RequestBinder/README.nuget.md b/FirstClassErrors.RequestBinder/README.nuget.md index f2610df..74e6fed 100644 --- a/FirstClassErrors.RequestBinder/README.nuget.md +++ b/FirstClassErrors.RequestBinder/README.nuget.md @@ -14,7 +14,7 @@ var email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); Outcome command = - bind.Build(read => new PlaceBookingCommand(read.Get(email), read.Get(stay))); + bind.Build(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); ``` See the [repository documentation](https://github.com/Reefact/first-class-errors) for the full guide. From e8c17c2f61e4e2453a2017526f596de1fd5f29e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:28:08 +0000 Subject: [PATCH 07/14] fix(binder): keep the argument path on a nested PrimaryPortError leaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A complex property (and each element of a complex list) grouped a nested failure by TYPE: `error as PrimaryPortError ?? ArgumentInvalid(path, error)`. The type was a proxy for "is this the nested Build's self-describing envelope?" — but the proxy leaked. A converter is allowed to fail with a bare `PrimaryPortError` leaf; by type it looked like an envelope, so it was recorded as-is and its argument path (`Stay`) or index (`Guests[1]`) was silently dropped. The same failure raised as a `DomainError` was correctly wrapped with the path, and a simple property always wraps — a triple inconsistency, and the one case no test exercised (100% coverage saw the branch run for the legitimate envelope, never asserted the leaf case). Discriminate by INSTANCE instead: `Build` now remembers the envelope it produced (`BuiltEnvelope`), and a nested failure is recorded as-is only when it is that exact instance by `ReferenceEquals`; anything else — including a leaf that merely happens to be a `PrimaryPortError` — is wrapped so the path survives. The shared decision is factored into `NestedFailure.Group`, used by both the complex and the list converters (previously duplicated). The three added tests fail against the old by-type logic (verified) and pass against the fix; binder coverage stays 100% line and branch (50 tests). Refs: #126 --- .../BindingContractTests.cs | 50 +++++++++++++++++++ .../ComplexPropertyConverter.cs | 25 +++------- .../ListOfComplexPropertiesConverter.cs | 3 +- .../NestedFailure.cs | 33 ++++++++++++ .../RequestBinder.cs | 14 +++++- 5 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 FirstClassErrors.RequestBinder/NestedFailure.cs diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index e6f2e32..2e28080 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -84,6 +84,56 @@ public void BareNestedElementFailureIsWrapped() { 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 envelope.", + Transience.NonTransient) + .WithPublicMessage("Rejected."); + } + + [Fact(DisplayName = "A required nested binding that fails with a bare PrimaryPortError leaf (not its Build 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.Build(_ => "never"); + Error wrapped = outcome.Error!.InnerErrors.Single(); + // A leaf PrimaryPortError is NOT the nested Build 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.Build(_ => "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.Build(_ => "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 diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs index b96fd92..67294c8 100644 --- a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs +++ b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs @@ -53,9 +53,10 @@ public RequiredField AsRequired(Func(_binder, default!); } - Outcome outcome = BindNested(bindNested); + RequestBinder nested = NestedBinder(); + Outcome outcome = bindNested(nested); if (outcome.IsFailure) { - _binder.Record(Grouped(outcome.Error!)); + _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath)); return new RequiredField(_binder, default!); } @@ -77,9 +78,10 @@ public OptionalReferenceField AsOptional(Func(_binder, value: null); } - Outcome outcome = BindNested(bindNested); + RequestBinder nested = NestedBinder(); + Outcome outcome = bindNested(nested); if (outcome.IsFailure) { - _binder.Record(Grouped(outcome.Error!)); + _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath)); return new OptionalReferenceField(_binder, value: null); } @@ -87,19 +89,8 @@ public OptionalReferenceField AsOptional(Func(_binder, outcome.GetResultOrThrow()); } - private Outcome BindNested(Func, Outcome> bindNested) where TProperty : notnull { - RequestBinder nested = new(_value!, _envelope, _binder.Options, _argumentPath); - - return bindNested(nested); - } - - /// - /// A nested binding built with Build fails with its own envelope, already a - /// that self-describes the group — record it as-is. Any other failure (a - /// nested function returning a bare conversion failure) is wrapped so the argument path is preserved. - /// - private PrimaryPortError Grouped(Error error) { - return error as PrimaryPortError ?? RequestBindingError.ArgumentInvalid(_argumentPath, error); + private RequestBinder NestedBinder() { + return new RequestBinder(_value!, _envelope, _binder.Options, _argumentPath); } } diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs index f59fa9f..24d58b7 100644 --- a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs @@ -92,8 +92,7 @@ private RequiredField> BindElements(Func nested = new(element!, _envelope, _binder.Options, elementPath); Outcome outcome = bindElement(nested); if (outcome.IsFailure) { - Error error = outcome.Error!; - _binder.Record(error as PrimaryPortError ?? RequestBindingError.ArgumentInvalid(elementPath, error)); + _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath)); continue; } diff --git a/FirstClassErrors.RequestBinder/NestedFailure.cs b/FirstClassErrors.RequestBinder/NestedFailure.cs new file mode 100644 index 0000000..ce0d497 --- /dev/null +++ b/FirstClassErrors.RequestBinder/NestedFailure.cs @@ -0,0 +1,33 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Groups the failure of a nested binding (a complex property, or one element of a complex list) under its +/// argument path — the single decision shared by and +/// . +/// +internal static class NestedFailure { + + #region Statics members declarations + + /// + /// Decides how a nested binding's failure joins the parent envelope. The nested binder's own Build + /// envelope — , self-describing because its inner errors already carry the + /// prefixed paths — is recorded as-is. Anything else, including a bare leaf a + /// converter returned directly, is wrapped in REQUEST_ARGUMENT_INVALID so the argument path survives + /// — exactly as a would be. The test is by reference, not by type: a leaf that + /// merely happens to be a is not this binder's envelope and must not skip the + /// path. + /// + /// The failure the nested binding returned. + /// The envelope the nested binder's Build produced, or null when it built none. + /// The argument path to attach when wrapping. + /// The error to record on the parent binder. + internal static PrimaryPortError Group(Error error, PrimaryPortError? nestedEnvelope, string argumentPath) { + return ReferenceEquals(error, nestedEnvelope) + ? (PrimaryPortError)error + : RequestBindingError.ArgumentInvalid(argumentPath, error); + } + + #endregion + +} diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs index ddefb40..9e822e0 100644 --- a/FirstClassErrors.RequestBinder/RequestBinder.cs +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -51,6 +51,14 @@ internal RequestBinder(TRequest request, FuncThe options this binder (and every binder nested under it) binds with. internal RequestBinderOptions Options { get; private set; } + /// + /// The envelope instance the most recent failing produced, or null when + /// no build has failed. A parent binder compares a nested failure against this by reference to tell this + /// binder's own self-describing envelope (recorded as-is) from a leaf error a nested binding returned directly + /// (wrapped under the argument path). + /// + internal PrimaryPortError? BuiltEnvelope { get; private set; } + /// /// Replaces the binder options (for example to plug a serializer-aware /// ). Call it before binding any property; nested binders inherit the @@ -139,7 +147,11 @@ public Outcome Build(BindingAssembler assemble) wh innerErrors.Add(error); } - return Outcome.Failure(_envelope(innerErrors)); + // Remember the exact envelope instance produced here, so a parent binder can tell this self-describing + // envelope (recorded as-is) from any other failure a nested binding returned (wrapped under the path). + BuiltEnvelope = _envelope(innerErrors); + + return Outcome.Failure(BuiltEnvelope); } /// Records a binding failure; it will surface in the envelope built by . From ef39355ad916a7db0c72666d0be9655806ef36e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:01:28 +0000 Subject: [PATCH 08/14] test(binder): kill the mutation survivors the coverage missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite had 100% line and branch coverage yet a real bug (#3) had slipped through — because coverage proves a line RUNS, not that a behaviour is ASSERTED. A mutation hunt across every binder source file, each candidate verified empirically in an isolated worktree (apply the mutation, build, run the suite: did it still pass?), surfaced ten behaviours that were executed but never asserted. Close every one: - optional reference / optional value, present-but-invalid: pin the code (REQUEST_ARGUMENT_INVALID, not just "a failure") and the wrapped cause; - a null element in the middle of a complex list must not short-circuit collect-all — the failing elements after it are still collected; - the cross-binder ownership guard is now asserted on ALL three Get overloads (only RequiredField was covered; OptionalReference/OptionalValue were not); - the selector resolver unwraps EVERY stacked Convert node, not just the outer; - the binder-owned errors' public/detailed/diagnostic messages are pinned (short summary, and the argument path named in the detailed and diagnostic text). Verified: re-applying all ten mutations makes at least one test fail (10/10 killed); binder coverage stays 100% line and branch (54 tests). Refs: #126 --- .../BindingContractTests.cs | 48 +++++++++++++++++-- .../ListBindingTests.cs | 22 +++++++++ .../SimplePropertyBindingTests.cs | 14 ++++-- 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index 2e28080..a429f61 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -198,6 +198,16 @@ public void SelectorResolverUnwrapsConvertNodes() { 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!)) @@ -253,10 +263,12 @@ public void GuardClauses() { #region The binding scope guards its reads - [Fact(DisplayName = "The binding scope rejects a null field (every overload) and a field owned by a different binder — programming errors both.")] + [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 tokenOfA = binderA.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + 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); @@ -265,8 +277,36 @@ public void BindingScopeGuardsItsReads() { Check.ThatCode(() => bind.Build(s => s.Get((OptionalReferenceField)null!) is null)).Throws(); Check.ThatCode(() => bind.Build(s => s.Get((OptionalValueField)null!).HasValue)).Throws(); - // A field owned by a different binder is a cross-binder mix-up — rejected loudly, not read silently. - Check.ThatCode(() => bind.Build(s => s.Get(tokenOfA).Value)).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.Build(s => s.Get(requiredOfA).Value)).Throws(); + Check.ThatCode(() => bind.Build(s => s.Get(optRefOfA) is null)).Throws(); + Check.ThatCode(() => bind.Build(s => s.Get(optValOfA).HasValue)).Throws(); + } + + #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.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID"); } #endregion diff --git a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs index 8415fce..31a46fa 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs @@ -111,6 +111,28 @@ public void NullComplexElementIsRequired() { 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.Build(_ => "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); diff --git a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs index a385db0..1c39a0e 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs @@ -109,13 +109,16 @@ public void OptionalReference() { Check.That(present.Build(s => s.Get(some)!.Value).GetResultOrThrow()).IsEqualTo("alice@example.org"); } - [Fact(DisplayName = "An optional reference property that is present but invalid records an error.")] + [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); - Check.That(bind.Build(_ => "never").IsFailure).IsTrue(); + Error invalid = bind.Build(_ => "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.")] @@ -133,13 +136,16 @@ public void OptionalValueYieldsNullWhenAbsent() { Check.That(present.Build(s => s.Get(some) ?? 0).GetResultOrThrow()).IsEqualTo(5); } - [Fact(DisplayName = "An optional value property that is present but invalid records an error.")] + [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); - Check.That(bind.Build(_ => "never").IsFailure).IsTrue(); + Error invalid = bind.Build(_ => "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"); } } From 1807e1825c3b076ff9cb535df58d5a58ebd7e51d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:36:15 +0000 Subject: [PATCH 09/14] test(binder): kill the second-round mutation survivors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second mutation audit against the now-hardened suite surfaced six more behaviours that ran but were never asserted. Close each: - an invalid optional fallback names the misconfigured argument in its exception message (not just the exception type); - a complex-list element binder inherits the parent's options — a custom argument-name provider renames the ELEMENT's inner paths, not only the list prefix (the one genuinely behavioural gap: options inheritance was verified for a single ComplexProperty but never for list elements); - a cross-binder field read is rejected with a message that names the cause; - REQUEST_ARGUMENT_INVALID's diagnostic text still states it is "invalid"; - the REQUEST_ARGUMENT_REQUIRED documentation lists both diagnoses with the right origins (client omission = external, name mismatch = internal). Verified end to end: re-applying all sixteen mutants from both rounds makes at least one test fail (16/16 killed); binder coverage stays 100% line and branch (57 tests). Refs: #126 --- .../BindingContractTests.cs | 25 +++++++++++++++++++ .../ListBindingTests.cs | 24 ++++++++++++++++++ .../SimplePropertyBindingTests.cs | 7 +++--- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index a429f61..f32307f 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -283,6 +283,18 @@ public void BindingScopeGuardsItsReads() { Check.ThatCode(() => bind.Build(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.Build(s => s.Get(requiredOfA).Value); }); + Check.That(exception.Message).Contains("different binder"); + } + #endregion #region The binder-owned errors carry their coded messages @@ -306,6 +318,7 @@ public void InvalidArgumentErrorCarriesItsMessages() { 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"); } @@ -334,6 +347,18 @@ public void BinderOwnedErrorsAreDocumented() { } } + [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); + } + #endregion } diff --git a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs index 31a46fa..1196b25 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs @@ -145,4 +145,28 @@ public void OptionalComplexListAbsentBindsEmpty() { 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.Build(_ => "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/SimplePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs index 1c39a0e..fce5f27 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs @@ -90,12 +90,13 @@ public void OptionalPresentButInvalidRecords() { 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.")] + [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); - Check.ThatCode(() => bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "NOT-A-CURRENCY")) - .Throws(); + InvalidOperationException exception = Assert.Throws( + () => { bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "NOT-A-CURRENCY"); }); + Check.That(exception.Message).Contains("Currency"); } [Fact(DisplayName = "An optional reference property yields null when absent — recording nothing — and the value when present.")] From 9ad043fbff45d84752075bc8f4af35905e558603 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:39:15 +0000 Subject: [PATCH 10/14] test(binder): pin the third-round diagnostic-message contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third mutation audit against the twice-hardened suite found four remaining survivors — and, tellingly, ZERO behavioural ones: all four are diagnostic message or documentation CONTENT. The behavioural surface has gone dry (two of five source slices returned no survivor at all). Close the four: - the invalid-fallback exception carries the converter's DIAGNOSTIC detail (the raw offending value), not the sanitized public summary; - the foreign-error-family bug exception names the argument path and the offending family type; - the not-a-direct-property selector exception echoes the offending selector; - the REQUEST_ARGUMENT_INVALID documentation's two diagnoses carry the right origins (client-malformed value = external, over-strict parser = internal). Verified: every mutant found across all three rounds now makes at least one test fail; binder coverage stays 100% line and branch (58 tests). Refs: #126 --- .../BindingContractTests.cs | 18 +++++++++++++++--- .../RequestBinderTests.cs | 7 ++++--- .../SimplePropertyBindingTests.cs | 3 +++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index f32307f..a6ec90c 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -48,9 +48,11 @@ public void ConverterFailingWithAnotherFamilyIsABug() { InteractionDirection.Outgoing, Transience.Unknown) .WithPublicMessage("Foreign."); - Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail) - .AsRequired(_ => Outcome.Failure(foreignFamily))) - .Throws(); + 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 @@ -359,6 +361,16 @@ public void RequiredArgumentDocumentationClassifiesItsDiagnoses() { .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/RequestBinderTests.cs b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs index 8a19f18..f907946 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs @@ -106,9 +106,10 @@ 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. - Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail!.ToUpperInvariant())) - .Throws(); + // 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(); diff --git a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs index fce5f27..18b592a 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs @@ -97,6 +97,9 @@ public void InvalidFallbackIsABug() { 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.")] From 208108c724dbac9dfb8ebd4814a6ea2f49b0b7d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:58:15 +0000 Subject: [PATCH 11/14] fix(binder): reject a non-nullable value-type request property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presence was detected by one test — `value is null` on the reflected property value. That works for a reference or a nullable value type (a missing string or int? is null), but a NON-nullable value type can never be null: a missing field deserializes to default(T) — 0, false — so `value is null` is always false. A missing `int MaxNights` was therefore bound as a legitimately-sent 0, "not sent" became indistinguishable from "sent 0" (silent data corruption at the boundary, the most banal .NET deserialization trap), and `AsRequired()`'s missing branch was dead code for such a property. The information does not exist at runtime, so the only correct answer is to make the DTO declare the property nullable. Guard it at bind time (the binder's programming-error channel, like an invalid selector): a selector pointing at a non-nullable value-type property throws ArgumentException naming the property and telling the developer to declare it as `T?`. A nullable value type, a reference, and a collection are unaffected. (A compile-time analyzer is the natural follow-up; it cannot be a generic constraint because `int` and `int?` both infer TArgument = int.) Verified: the guard's condition is mutation-checked (flipping the nullable test fails the new test); binder coverage stays 100% line and branch (59 tests). Refs: #126 --- .../BindingContractTests.cs | 19 +++++++++++++++++++ .../RequestBinder.cs | 18 +++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index a6ec90c..8e7e5ab 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -231,6 +231,25 @@ public void FieldSelectorIsRejected() { #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.")] diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs index 9e822e0..dc5fa3e 100644 --- a/FirstClassErrors.RequestBinder/RequestBinder.cs +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -82,6 +82,11 @@ public RequestBinder WithOptions(RequestBinderOptions options) { /// The type of the DTO property. /// A direct property access on the request parameter (e.g. r => r.GuestEmail). /// The converter stage offering AsRequired / AsOptional and their variants. + /// + /// Thrown when points at a non-nullable value-type property: a missing value + /// would be indistinguishable from its default (0, false, ...), so such a property must be declared + /// nullable (e.g. int?) for the binder to detect an absent argument. + /// public SimplePropertyConverter SimpleProperty(Expression> selector) { (string path, object? value) = ResolveArgument(selector); @@ -166,7 +171,18 @@ internal string PathOf(string argumentName) { private (string Path, object? Value) ResolveArgument(Expression> selector) { PropertyInfo property = PropertySelectors.GetProperty(selector); - string path = PathOf(Options.ArgumentNameProvider.GetArgumentNameFrom(property)); + + // A non-nullable value-type property can never be null, so a missing argument (deserialized to default(T) — + // 0, false, ...) is indistinguishable from a legitimately-sent default: absence would be silently lost. The + // information does not exist at runtime, so reject the mis-declaration loudly (the binder's programming-error + // channel) — the DTO property must be declared nullable so that an absent argument arrives as null. + if (property.PropertyType.IsValueType && Nullable.GetUnderlyingType(property.PropertyType) is null) { + throw new ArgumentException( + $"The request property '{property.Name}' is a non-nullable value type ({property.PropertyType.Name}); a missing value would be indistinguishable from its default. Declare it as {property.PropertyType.Name}? so the binder can detect an absent argument.", + nameof(selector)); + } + + string path = PathOf(Options.ArgumentNameProvider.GetArgumentNameFrom(property)); return (path, property.GetValue(_request)); } From 12ba7c9f2ed0e4ac8d73fd97c3d358df9f67aebf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:49:17 +0000 Subject: [PATCH 12/14] feat(binder): split the build terminal into New and Create The build terminal accepted only an infallible assembler, so a command produced by a validating factory returning Outcome could not compose (it double-wrapped into Outcome>), and a cross-field rule such as CheckOut > CheckIn had nowhere to run. Replace the single Build with two distinctly-named terminals: - New(BindingAssembler): a total constructor, wrapped in Success. - Create(ValidatingAssembler): a validating factory returning Outcome, flattened. The factory runs only once every field is bound, and its failure is returned as-is; only field-binding failures group under the FailWith envelope. Distinct names, not overloads, dissolve the CS0121 ambiguity a lambda returning Outcome would otherwise trigger, and mirror C#'s constructor-vs-factory convention so the terminal name recopies the shape of the assembler. ADR-0007 (Proposed) records the decision and its alternatives, coherent with ADR-0005's plain-factory-name rule. Cover Create with flatten-success, failure-pass-through, factory-untouched- on-binding-failure and null-guard tests (mutation-verified). Refs: #126 --- .../BindingContractTests.cs | 45 +++--- .../BookingEndToEndTests.cs | 6 +- .../ComplexPropertyBindingTests.cs | 14 +- .../ListBindingTests.cs | 22 +-- .../RequestBinderTests.cs | 83 ++++++++-- .../SimplePropertyBindingTests.cs | 30 ++-- FirstClassErrors.RequestBinder/Bind.cs | 2 +- .../BindingAssembler.cs | 6 +- .../BindingScope.cs | 17 ++- .../ListOfComplexPropertiesConverter.cs | 2 +- .../ListOfSimplePropertiesConverter.cs | 2 +- .../NestedFailure.cs | 4 +- .../OptionalReferenceField.cs | 5 +- .../OptionalValueField.cs | 7 +- .../README.nuget.md | 2 +- .../RequestBinder.cs | 59 ++++++-- .../RequiredField.cs | 9 +- .../ValidatingAssembler.cs | 16 ++ ...ame-the-binder-terminals-new-and-create.md | 142 ++++++++++++++++++ maintainers/adr/README.md | 1 + 20 files changed, 370 insertions(+), 104 deletions(-) create mode 100644 FirstClassErrors.RequestBinder/ValidatingAssembler.cs create mode 100644 maintainers/adr/0007-name-the-binder-terminals-new-and-create.md diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index 8e7e5ab..e5fc3c9 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -33,7 +33,7 @@ public void ConverterMayFailWithAPrimaryPortError() { PrimaryPortError.Create(ErrorCode.Create("TEST_PORT_LEVEL_REJECTION"), "Rejected at the port.", Transience.NonTransient) .WithPublicMessage("Rejected."))); - Outcome outcome = bind.Build(_ => "never"); + 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"); @@ -66,7 +66,7 @@ public void BareNestedFailureIsWrapped() { bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) .AsRequired(_ => Outcome.Failure(BookingDomainError.DateInvalid("raw"))); - Outcome outcome = bind.Build(_ => "never"); + 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"); @@ -80,28 +80,28 @@ public void BareNestedElementFailureIsWrapped() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) .AsRequired(_ => Outcome.Failure(BookingDomainError.EmailInvalid("raw"))); - Outcome outcome = bind.Build(_ => "never"); + 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 envelope.", + 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 envelope) is wrapped, so the path survives.")] + [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.Build(_ => "never"); + Outcome outcome = bind.New(_ => "never"); Error wrapped = outcome.Error!.InnerErrors.Single(); - // A leaf PrimaryPortError is NOT the nested Build envelope, so it is wrapped under the path — exactly like a + // 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"); @@ -115,7 +115,7 @@ public void BareOptionalNestedPrimaryPortErrorLeafIsWrapped() { bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) .AsOptional(_ => Outcome.Failure(LeafPortError())); - Outcome outcome = bind.Build(_ => "never"); + 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"); @@ -129,7 +129,7 @@ public void BareNestedElementPrimaryPortErrorLeafIsWrapped() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) .AsRequired(_ => Outcome.Failure(LeafPortError())); - Outcome outcome = bind.Build(_ => "never"); + 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]"); @@ -146,9 +146,9 @@ public void RequiredComplexListMissing() { .FailWith(BookingEnvelopeError.CommandInvalid); bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) - .AsRequired(g => g.Build(_ => new Guest("never", null))); + .AsRequired(g => g.New(_ => new Guest("never", null))); - Outcome outcome = bind.Build(_ => "never"); + 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"); @@ -161,7 +161,7 @@ public void NullElementAsFirstFailureOfASimpleList() { bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); - Outcome outcome = bind.Build(_ => "never"); + 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)) @@ -177,10 +177,10 @@ public void OptionalComplexListPresentCollectsFailures() { .AsOptional(g => { RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); - return g.Build(s => new Guest(s.Get(firstName), null)); + return g.New(s => new Guest(s.Get(firstName), null)); }); - Outcome outcome = bind.Build(_ => "never"); + Outcome outcome = bind.New(_ => "never"); Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_GUEST_INVALID"); } @@ -259,7 +259,8 @@ public void GuardClauses() { Check.ThatCode(() => Bind.PropertiesOf(null!)).Throws(); Check.ThatCode(() => Bind.PropertiesOf(Request()).FailWith(null!)).Throws(); Check.ThatCode(() => bind.WithOptions(null!)).Throws(); - Check.ThatCode(() => bind.Build(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(); @@ -294,14 +295,14 @@ public void BindingScopeGuardsItsReads() { 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.Build(s => s.Get((RequiredField)null!).Value)).Throws(); - Check.ThatCode(() => bind.Build(s => s.Get((OptionalReferenceField)null!) is null)).Throws(); - Check.ThatCode(() => bind.Build(s => s.Get((OptionalValueField)null!).HasValue)).Throws(); + 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.Build(s => s.Get(requiredOfA).Value)).Throws(); - Check.ThatCode(() => bind.Build(s => s.Get(optRefOfA) is null)).Throws(); - Check.ThatCode(() => bind.Build(s => s.Get(optValOfA).HasValue)).Throws(); + 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.")] @@ -312,7 +313,7 @@ public void CrossBinderReadMessageNamesTheCause() { var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); InvalidOperationException exception = Assert.Throws( - () => { bind.Build(s => s.Get(requiredOfA).Value); }); + () => { bind.New(s => s.Get(requiredOfA).Value); }); Check.That(exception.Message).Contains("different binder"); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs index 6e02aea..fda6d04 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs @@ -16,14 +16,14 @@ 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.Build(s => new Stay(s.Get(checkIn), s.Get(checkOut))); + 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.Build(s => new Guest(s.Get(firstName), s.Get(email))); + return guest.New(s => new Guest(s.Get(firstName), s.Get(email))); } private static Outcome BindCommand(BookingRequest request) { @@ -37,7 +37,7 @@ private static Outcome BindCommand(BookingRequest request) { RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - return bind.Build(s => new BookingCommand( + 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))); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs index c337575..f732883 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs @@ -12,7 +12,7 @@ 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.Build(s => new Stay(s.Get(checkIn), s.Get(checkOut))); + return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); } private static BookingRequest RequestWith(StayDto? stay) { @@ -25,7 +25,7 @@ public void RequiredComplexBinds() { RequiredField stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); - Outcome outcome = bind.Build(s => s.Get(stay)); + 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)); } @@ -36,7 +36,7 @@ public void NestedFailureSurfacesAsItsEnvelopeWithPrefixedPaths() { bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); - Outcome outcome = bind.Build(_ => "never"); + Outcome outcome = bind.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Error stayEnvelope = outcome.Error!.InnerErrors.Single(); @@ -56,7 +56,7 @@ public void RequiredComplexMissing() { bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); - Outcome outcome = bind.Build(_ => "never"); + 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"); @@ -66,11 +66,11 @@ public void RequiredComplexMissing() { 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.Build(s => s.Get(none) is null).GetResultOrThrow()).IsTrue(); + 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.Build(s => s.Get(some)!.CheckOut.Value).GetResultOrThrow()).IsEqualTo(new DateOnly(2026, 8, 14)); + 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.")] @@ -79,7 +79,7 @@ public void OptionalComplexPresentButInvalidRecords() { bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); - Outcome outcome = bind.Build(_ => "never"); + 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/ListBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs index 1196b25..153ff26 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs @@ -12,7 +12,7 @@ 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.Build(s => new Guest(s.Get(firstName), s.Get(email))); + return guest.New(s => new Guest(s.Get(firstName), s.Get(email))); } private static BookingRequest RequestWith(IReadOnlyList? tags = null, IReadOnlyList? guests = null) { @@ -25,7 +25,7 @@ public void RequiredSimpleListBinds() { RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); - Outcome> outcome = bind.Build(s => s.Get(tags)); + Outcome> outcome = bind.New(s => s.Get(tags)); Check.That(outcome.GetResultOrThrow().Select(t => t.Value)).ContainsExactly("vip", "late-checkout"); } @@ -35,7 +35,7 @@ public void RequiredSimpleListMissing() { bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); - Outcome outcome = bind.Build(_ => "never"); + 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"); @@ -47,7 +47,7 @@ public void EveryFailingElementIsCollected() { bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); - Outcome outcome = bind.Build(_ => "never"); + 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]"); @@ -61,7 +61,7 @@ public void OptionalListAbsentBindsEmpty() { RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); - Outcome> outcome = bind.Build(s => s.Get(tags)); + Outcome> outcome = bind.New(s => s.Get(tags)); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow()).IsEmpty(); } @@ -74,7 +74,7 @@ public void RequiredComplexListBinds() { RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - Outcome> outcome = bind.Build(s => s.Get(guests)); + 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(); } @@ -86,7 +86,7 @@ public void FailingComplexElementsRecordTheirEnvelopes() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - Outcome outcome = bind.Build(_ => "never"); + Outcome outcome = bind.New(_ => "never"); Check.That(outcome.Error!.InnerErrors).HasSize(2); Error second = outcome.Error!.InnerErrors[0]; @@ -105,7 +105,7 @@ public void NullComplexElementIsRequired() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - Outcome outcome = bind.Build(_ => "never"); + 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]"); @@ -118,7 +118,7 @@ public void NullComplexElementDoesNotHideLaterFailures() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - Outcome outcome = bind.Build(_ => "never"); + 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); @@ -140,7 +140,7 @@ public void OptionalComplexListAbsentBindsEmpty() { RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(BindGuest); - Outcome> outcome = bind.Build(s => s.Get(guests)); + Outcome> outcome = bind.New(s => s.Get(guests)); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow()).IsEmpty(); } @@ -153,7 +153,7 @@ public void CustomNameProviderRenamesComplexListElementPaths() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); - Outcome outcome = bind.Build(_ => "never"); + 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. diff --git a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs index f907946..ad52e17 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs @@ -14,17 +14,28 @@ 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.Build(s => new Stay(s.Get(checkIn), s.Get(checkOut))); + return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); } - [Fact(DisplayName = "Build assembles the command exactly once when every property bound.")] - public void BuildAssemblesOnceOnSuccess() { + 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.Build(s => { + Outcome outcome = bind.New(s => { assembled++; return s.Get(email).Value; @@ -34,14 +45,14 @@ public void BuildAssemblesOnceOnSuccess() { Check.That(assembled).IsEqualTo(1); } - [Fact(DisplayName = "Build never runs the assembler when a failure was recorded — field reads are safe by construction.")] - public void BuildNeverAssemblesOnFailure() { + [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.Build(_ => { + Outcome outcome = bind.New(_ => { assembled = true; return "never"; @@ -51,6 +62,54 @@ public void BuildNeverAssemblesOnFailure() { 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)) @@ -60,7 +119,7 @@ public void CollectsEveryFailure() { bind.SimpleProperty(r => r.Reference).AsRequired(); bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); - Outcome outcome = bind.Build(_ => "never"); + 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"); @@ -83,10 +142,10 @@ public void FullyInvalidRequestThrowsNothing() { bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(g => { RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); - return g.Build(s => new Guest(s.Get(firstName), null)); + return g.New(s => new Guest(s.Get(firstName), null)); }); - return bind.Build(_ => "never"); + return bind.New(_ => "never"); }) .DoesNotThrow(); } @@ -124,7 +183,7 @@ public void CustomNameProviderRenamesPaths() { bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); - Outcome outcome = bind.Build(_ => "never"); + 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"); @@ -137,7 +196,7 @@ public void MissingArgumentIsNonTransient() { bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - Outcome outcome = bind.Build(_ => "never"); + Outcome outcome = bind.New(_ => "never"); var required = (InfrastructureError)outcome.Error!.InnerErrors.Single(); Check.That(required.Transience).IsEqualTo(Transience.NonTransient); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs index 18b592a..0acb03a 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs @@ -20,7 +20,7 @@ public void RequiredPresentAndValidBinds() { RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - Outcome outcome = bind.Build(s => s.Get(email).Value); + Outcome outcome = bind.New(s => s.Get(email).Value); Check.That(outcome.IsSuccess).IsTrue(); Check.That(outcome.GetResultOrThrow()).IsEqualTo("alice@example.org"); } @@ -31,7 +31,7 @@ public void RequiredMissingFails() { bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - Outcome outcome = bind.Build(_ => "never"); + Outcome outcome = bind.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID"); @@ -46,7 +46,7 @@ public void RequiredInvalidWrapsTheConverterError() { bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - Outcome outcome = bind.Build(_ => "never"); + Outcome outcome = bind.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Error invalid = outcome.Error!.InnerErrors.Single(); @@ -59,11 +59,11 @@ public void RequiredInvalidWrapsTheConverterError() { public void RequiredWithoutConversion() { var present = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); RequiredField reference = present.SimpleProperty(r => r.Reference).AsRequired(); - Check.That(present.Build(s => s.Get(reference)).GetResultOrThrow()).IsEqualTo("REF-1"); + 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.Build(_ => "never"); + Outcome outcome = missing.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); } @@ -72,11 +72,11 @@ public void RequiredWithoutConversion() { 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.Build(s => s.Get(providedCurrency).Code).GetResultOrThrow()).IsEqualTo("USD"); + 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.Build(s => s.Get(defaulted).Code).GetResultOrThrow()).IsEqualTo("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.")] @@ -85,7 +85,7 @@ public void OptionalPresentButInvalidRecords() { bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); - Outcome outcome = bind.Build(_ => "never"); + Outcome outcome = bind.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); } @@ -106,11 +106,11 @@ public void InvalidFallbackIsABug() { 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.Build(s => s.Get(none) is null).GetResultOrThrow()).IsTrue(); + 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.Build(s => s.Get(some)!.Value).GetResultOrThrow()).IsEqualTo("alice@example.org"); + 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.")] @@ -119,7 +119,7 @@ public void OptionalReferencePresentButInvalidRecords() { bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); - Error invalid = bind.Build(_ => "never").Error!.InnerErrors.Single(); + 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"); @@ -129,15 +129,15 @@ public void OptionalReferencePresentButInvalidRecords() { 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: Build's TCommand cannot itself be int? (Nullable is not `notnull`); + // 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.Build(s => s.Get(none).HasValue); + 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.Build(s => s.Get(some) ?? 0).GetResultOrThrow()).IsEqualTo(5); + 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.")] @@ -146,7 +146,7 @@ public void OptionalValuePresentButInvalidRecords() { bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); - Error invalid = bind.Build(_ => "never").Error!.InnerErrors.Single(); + 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/Bind.cs b/FirstClassErrors.RequestBinder/Bind.cs index 4bb75a6..c0a34b8 100644 --- a/FirstClassErrors.RequestBinder/Bind.cs +++ b/FirstClassErrors.RequestBinder/Bind.cs @@ -13,7 +13,7 @@ namespace FirstClassErrors.RequestBinder; /// var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); /// /// Outcome<PlaceBookingCommand> command = -/// bind.Build(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); +/// bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); /// /// public static class Bind { diff --git a/FirstClassErrors.RequestBinder/BindingAssembler.cs b/FirstClassErrors.RequestBinder/BindingAssembler.cs index 64d5daf..a8cead1 100644 --- a/FirstClassErrors.RequestBinder/BindingAssembler.cs +++ b/FirstClassErrors.RequestBinder/BindingAssembler.cs @@ -1,8 +1,10 @@ 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. +/// 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 diff --git a/FirstClassErrors.RequestBinder/BindingScope.cs b/FirstClassErrors.RequestBinder/BindingScope.cs index 226ea2b..18a3ad6 100644 --- a/FirstClassErrors.RequestBinder/BindingScope.cs +++ b/FirstClassErrors.RequestBinder/BindingScope.cs @@ -1,18 +1,19 @@ namespace FirstClassErrors.RequestBinder; /// -/// The reader handed to 's assembler: the only channel -/// through which a bound value can be obtained from a field token ( and its -/// optional siblings). +/// 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 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 Build, or +/// 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. /// /// @@ -79,7 +80,7 @@ public TProperty Get(RequiredField field) { 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 Build of the binder that bound it."); + "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/ListOfComplexPropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs index 24d58b7..94ce653 100644 --- a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs @@ -100,7 +100,7 @@ private RequiredField> BindElements(Func>(_binder, bound); } diff --git a/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs index dfb1ad5..b0c587b 100644 --- a/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs @@ -93,7 +93,7 @@ private RequiredField> ConvertElements(Func< converted.Add(outcome.GetResultOrThrow()); } - // The value is read only through a BindingScope, which Build creates solely when no failure was recorded — + // 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 converted — so `converted` is the complete list exactly when it is observed. return new RequiredField>(_binder, converted); } diff --git a/FirstClassErrors.RequestBinder/NestedFailure.cs b/FirstClassErrors.RequestBinder/NestedFailure.cs index ce0d497..8dd138c 100644 --- a/FirstClassErrors.RequestBinder/NestedFailure.cs +++ b/FirstClassErrors.RequestBinder/NestedFailure.cs @@ -10,7 +10,7 @@ internal static class NestedFailure { #region Statics members declarations /// - /// Decides how a nested binding's failure joins the parent envelope. The nested binder's own Build + /// Decides how a nested binding's failure joins the parent envelope. The nested binder's own failure /// envelope — , self-describing because its inner errors already carry the /// prefixed paths — is recorded as-is. Anything else, including a bare leaf a /// converter returned directly, is wrapped in REQUEST_ARGUMENT_INVALID so the argument path survives @@ -19,7 +19,7 @@ internal static class NestedFailure { /// path. /// /// The failure the nested binding returned. - /// The envelope the nested binder's Build produced, or null when it built none. + /// The envelope the nested binder's build terminal produced, or null when it built none. /// The argument path to attach when wrapping. /// The error to record on the parent binder. internal static PrimaryPortError Group(Error error, PrimaryPortError? nestedEnvelope, string argumentPath) { diff --git a/FirstClassErrors.RequestBinder/OptionalReferenceField.cs b/FirstClassErrors.RequestBinder/OptionalReferenceField.cs index 36a6a81..645cdc6 100644 --- a/FirstClassErrors.RequestBinder/OptionalReferenceField.cs +++ b/FirstClassErrors.RequestBinder/OptionalReferenceField.cs @@ -6,9 +6,10 @@ namespace FirstClassErrors.RequestBinder; /// null when the argument was absent from the request. /// /// -/// Returned by the AsOptionalReference family. Inside +/// Returned by the AsOptionalReference family. Inside a build terminal +/// ( / ) /// a null read means exactly "the argument was absent": a present-but-invalid argument recorded a failure on -/// the binder, so Build never runs the assembler and that state is never observed. +/// the binder, so the assembler never runs and that state is never observed. /// /// The reference type of the bound property. public sealed class OptionalReferenceField where TProperty : class { diff --git a/FirstClassErrors.RequestBinder/OptionalValueField.cs b/FirstClassErrors.RequestBinder/OptionalValueField.cs index 67e207a..1467790 100644 --- a/FirstClassErrors.RequestBinder/OptionalValueField.cs +++ b/FirstClassErrors.RequestBinder/OptionalValueField.cs @@ -7,9 +7,10 @@ namespace FirstClassErrors.RequestBinder; /// count is null, not 0). /// /// -/// Returned by the AsOptionalValue family. Inside a -/// null read means exactly "the argument was absent": a present-but-invalid argument recorded a failure on -/// the binder, so Build never runs the assembler and that state is never observed. +/// Returned by the AsOptionalValue family. Inside a build terminal +/// ( / ) +/// a null read means exactly "the argument was absent": a present-but-invalid argument recorded a failure on +/// the binder, so the assembler never runs and that state is never observed. /// /// The value type of the bound property. public sealed class OptionalValueField where TProperty : struct { diff --git a/FirstClassErrors.RequestBinder/README.nuget.md b/FirstClassErrors.RequestBinder/README.nuget.md index 74e6fed..ece5e14 100644 --- a/FirstClassErrors.RequestBinder/README.nuget.md +++ b/FirstClassErrors.RequestBinder/README.nuget.md @@ -14,7 +14,7 @@ var email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); Outcome command = - bind.Build(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); + bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); ``` See the [repository documentation](https://github.com/Reefact/first-class-errors) for the full guide. diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs index dc5fa3e..b92f386 100644 --- a/FirstClassErrors.RequestBinder/RequestBinder.cs +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -15,7 +15,8 @@ namespace FirstClassErrors.RequestBinder; /// /// /// No throw on the invalid-input path. Converters return ; every failure is -/// recorded as a coded error and surfaces once, as the failure of . A request +/// recorded as a coded error and surfaces once, as the failure of the build terminal +/// ( / ). A request /// whose every field is invalid raises zero exceptions. Exceptions are reserved for programming errors /// (a converter that throws, an invalid selector, a mis-declared fallback): the binder catches nothing, so a /// genuine bug propagates to the host's exception boundary instead of being disguised as a client error. @@ -52,7 +53,8 @@ internal RequestBinder(TRequest request, Func - /// The envelope instance the most recent failing produced, or null when + /// The envelope instance the most recent failing build terminal + /// ( / ) produced, or null when /// no build has failed. A parent binder compares a nested failure against this by reference to tell this /// binder's own self-describing envelope (recorded as-is) from a leaf error a nested binding returned directly /// (wrapped under the argument path). @@ -133,20 +135,59 @@ public ListOfComplexPropertiesEnvelopeStage ListOfComplexPr } /// - /// Terminal: assembles the command when — and only when — no binding failure was recorded; otherwise returns - /// the failure of the envelope grouping every recorded error. The assembler receives a - /// and reads each bound value through it; because that scope is created only on this - /// success branch, every read is valid by construction. + /// Terminal (total assembler): builds the command with a new — when, and only when, no binding failure + /// was recorded; otherwise returns the failure of the envelope grouping every recorded error. The assembler + /// receives a and reads each bound value through it; because that scope is created + /// only on this success branch, every read is valid by construction, and the assembler itself cannot fail. /// + /// + /// Mirror the shape of the assembler at the call site: takes a new — a total + /// constructor, because all validation already happened field by field. When the command is produced by a + /// validating factory returning — one that may still reject an all-valid combination + /// through a cross-field rule (CheckOut > CheckIn) — call instead. + /// /// The type of the bound command or query. /// The assembler, reading the bound values from the supplied . /// The bound command, or the envelope failure. /// Thrown when is null. - public Outcome Build(BindingAssembler assemble) where TCommand : notnull { + public Outcome New(BindingAssembler assemble) where TCommand : notnull { if (assemble is null) { throw new ArgumentNullException(nameof(assemble)); } if (_errors.Count == 0) { return Outcome.Success(assemble(new BindingScope(this))); } + return Outcome.Failure(BuildFailureEnvelope()); + } + + /// + /// Terminal (validating assembler): builds the command through a factory returning — + /// when, and only when, no binding failure was recorded — and flattens the result, so a cross-field rule + /// the factory enforces (CheckOut > CheckIn) surfaces directly instead of nesting a second + /// . When a binding failure was recorded, the factory is never called and the envelope + /// grouping every recorded error is returned. + /// + /// + /// The factory runs only on the zero-error branch — every field is already bound and readable through the + /// supplied — so a cross-field rule can assume all its inputs are present and valid. + /// Its failure is returned as-is: the factory owns that error, and only field-binding failures are grouped + /// under the envelope declared with . For a total + /// constructor that cannot fail, call . + /// + /// The type of the bound command or query. + /// + /// The validating assembler, reading the bound values from the supplied and returning + /// an . + /// + /// The bound command, the factory's own failure, or the envelope failure. + /// Thrown when is null. + public Outcome Create(ValidatingAssembler assemble) where TCommand : notnull { + if (assemble is null) { throw new ArgumentNullException(nameof(assemble)); } + + if (_errors.Count == 0) { return assemble(new BindingScope(this)); } + + return Outcome.Failure(BuildFailureEnvelope()); + } + + private PrimaryPortError BuildFailureEnvelope() { PrimaryPortInnerErrors innerErrors = new(); foreach (PrimaryPortError error in _errors) { innerErrors.Add(error); @@ -156,10 +197,10 @@ public Outcome Build(BindingAssembler assemble) wh // envelope (recorded as-is) from any other failure a nested binding returned (wrapped under the path). BuiltEnvelope = _envelope(innerErrors); - return Outcome.Failure(BuiltEnvelope); + return BuiltEnvelope; } - /// Records a binding failure; it will surface in the envelope built by . + /// Records a binding failure; it will surface in the envelope built by / . internal void Record(PrimaryPortError error) { _errors.Add(error); } diff --git a/FirstClassErrors.RequestBinder/RequiredField.cs b/FirstClassErrors.RequestBinder/RequiredField.cs index 5b10961..ba7577a 100644 --- a/FirstClassErrors.RequestBinder/RequiredField.cs +++ b/FirstClassErrors.RequestBinder/RequiredField.cs @@ -3,13 +3,14 @@ namespace FirstClassErrors.RequestBinder; /// /// A token standing for a bound required property (or an optional property with a fallback). It carries no public /// value: the bound value is reachable only through -/// , inside -/// — where every binding is known to have succeeded. +/// , inside a build terminal +/// ( / ) +/// — where every binding is known to have succeeded. /// /// /// Returned by the AsRequired family. When the binding failed, the failure has already been recorded on the -/// binder (so it surfaces in the envelope built by Build) and this token stands in with no readable value — -/// harmless, because Build never runs the assembler when any failure was recorded. +/// binder (so it surfaces in the envelope built by the terminal) and this token stands in with no readable value — +/// harmless, because the terminal never runs the assembler when any failure was recorded. /// /// The type of the bound property. public sealed class RequiredField { diff --git a/FirstClassErrors.RequestBinder/ValidatingAssembler.cs b/FirstClassErrors.RequestBinder/ValidatingAssembler.cs new file mode 100644 index 0000000..96109a5 --- /dev/null +++ b/FirstClassErrors.RequestBinder/ValidatingAssembler.cs @@ -0,0 +1,16 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Assembles the bound command inside , reading each bound +/// value from the supplied and returning an — so the assembly +/// step itself may still fail, typically a validating factory (Command.Create(...)) enforcing a cross-field +/// rule (CheckOut > CheckIn) that no single field could check on its own. +/// +/// +/// A dedicated delegate — rather than Func<BindingScope, Outcome<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, or the failure of a cross-field rule. +public delegate Outcome ValidatingAssembler(BindingScope scope) where TCommand : notnull; diff --git a/maintainers/adr/0007-name-the-binder-terminals-new-and-create.md b/maintainers/adr/0007-name-the-binder-terminals-new-and-create.md new file mode 100644 index 0000000..7541d69 --- /dev/null +++ b/maintainers/adr/0007-name-the-binder-terminals-new-and-create.md @@ -0,0 +1,142 @@ +# ADR-0007 | Name the binder terminals New and Create + +**Status:** Proposed +**Date:** 2026-07-15 +**Decision Makers:** Reefact + +## Context + +* `FirstClassErrors.RequestBinder` binds each property of a request DTO into a value + object, then a **terminal** assembles the bound values into the command (or query) + and returns `Outcome`. +* Consumers assemble that command in two shapes that differ in whether the assembly + step can itself fail: a **total constructor** (`new Command(...)`) cannot fail, + because every field was already validated one by one during binding; a + **validating factory** (`Command.Create(...)` returning `Outcome`) can + still fail, because it enforces a cross-field rule — such as check-out being after + check-in — that no single field could check on its own. +* C# forbids overloading on return type alone, and its overload resolution does not + traverse the implicit `TCommand` → `Outcome` conversion. Two same-named + terminal overloads — one taking a `TCommand`-returning assembler, one taking an + `Outcome`-returning assembler — are therefore ambiguous for a lambda that + returns `Outcome`: both are applicable with a different inferred + `TCommand`, and the compiler rejects the call. +* A single terminal taking only a `TCommand`-returning assembler forces a consumer + whose factory returns `Outcome` to nest the result as + `Outcome>`, and leaves a cross-field rule no place to run. +* The library already reserves the plain factory name for the `Outcome`-returning + variant (ADR-0005); across the codebase the `Parse` / `Create` factories of value + objects return `Outcome`, so "a plainly-named factory hands back an `Outcome`" + is established vocabulary. +* The assembler receives a `BindingScope`, a `readonly ref struct`; a ref struct + cannot be a generic type argument, so an assembler cannot be a `Func<>` and must be + a named delegate type. +* The library is pre-release, unpublished on NuGet with no external consumers, so a + naming choice on this new API carries no downstream migration cost. + +## Decision + +The request binder exposes two distinctly-named terminals — `New`, taking a total +constructor that returns the command, and `Create`, taking a validating factory that +returns `Outcome` whose result is flattened — instead of a single terminal +or two same-named overloads. + +## Rationale + +* Distinct names give each terminal exactly one signature, so the overload ambiguity + that two same-named terminals would raise for an `Outcome`-returning + lambda cannot arise: the problem is removed at the name rather than worked around at + the call site. +* Keeping two terminals — rather than one `Outcome`-only terminal — lets a total + constructor stay total: the common case constructs the command directly without + wrapping it in a success `Outcome`, while `Create` flattens the validating case so a + factory's `Outcome` is never nested. This addresses both failures the + Context describes (the nested outcome and the cross-field rule with nowhere to run). +* Naming each terminal after the assembler it takes makes the choice self-selecting: a + consumer writing a `new` reaches for `New`, one writing a `.Create` factory reaches + for `Create`. The name reuses the constructor-versus-factory distinction every C# + developer already holds, so it needs no separate lookup. +* The mnemonic is coherent with ADR-0005 rather than in tension with it: ADR-0005 + reserves the plain factory name for the `Outcome`-returning variant, and `Create` + here is precisely the `Outcome`-returning terminal. ADR-0005's axis + (`Outcome`-returning versus throwing) is orthogonal to this one (a bare-value + assembler versus an `Outcome`-returning assembler); neither terminal throws, so no + `OrThrow` marker applies. +* `Create` returns the factory's failure unchanged rather than re-wrapping it, because + a cross-field rule is a domain concern the factory owns, whereas the binder's + envelope groups argument-binding failures. Keeping the two channels separate lets a + caller tell a malformed argument from a rejected valid combination. +* The pre-release status means the naming is settled now, when there are no consumers + to migrate. + +## Alternatives Considered + +### One terminal taking an Outcome-returning assembler + +Considered because it is the smallest surface — a single name, with the flatten +covering both cases once the caller wraps a total constructor in a success `Outcome`. + +Rejected because it forces the common, cannot-fail case to wrap every construction in +a success `Outcome`, surfacing result-plumbing the total case does not otherwise need. + +### Two same-named `Build` overloads + +Considered because overloading is the idiomatic C# way to accept two argument shapes +under one verb. + +Rejected because a lambda returning `Outcome` is applicable to both +overloads with a different inferred `TCommand`, overload resolution does not prefer +one over the other, and the call therefore does not compile. + +### `Build` and `BuildValidated` (one bare name, one suffixed) + +Considered as distinct names that already avoid the ambiguity. + +Rejected as asymmetric: a bare verb beside a suffixed variant reads as "the real one +and a special case", when the two are peers over different assembler shapes. + +### Semantics-first symmetric pairs (`Assemble` / `Validate`, `BuildFrom` / `BuildThrough`) + +Considered because the words state the can-fail / cannot-fail semantics for a reader +discovering the API. + +Rejected in favour of `New` / `Create`, which optimise instead for the developer +writing the call: the name matches the constructor or factory already in hand, reusing +an existing convention rather than teaching new vocabulary. + +## Consequences + +### Positive + +* The overload ambiguity is impossible: one name, one signature each. +* A total constructor stays unwrapped, and a validating factory composes without a + nested `Outcome`. +* The terminal name is a usage mnemonic tied to the constructor or factory the + consumer already writes, and reuses the codebase's `Create`-returns-`Outcome` + convention (ADR-0005). +* Argument-binding failures and a factory's cross-field failure stay distinguishable + at the call site. + +### Negative + +* `New` and `Create` are near-synonyms in English, so "only `Create` can fail" is + carried by the constructor-versus-factory convention rather than by the words; a + reader unfamiliar with the convention must consult the API docs. +* `New` is an unusual method identifier — valid in C# (only the lowercase `new` is the + keyword) but requiring escaping (`[New]`) to call from VB.NET. +* Two public terminals and two public assembler delegate types to document rather than + one. + +### Risks + +* Without tooling that enforces it, a later terminal for a third assembler shape could + drift from the convention and dilute the mnemonic; mitigated for now by this ADR and + by review. + +## References + +* ADR-0005 — reserve the plain factory name for the Outcome-returning variant, the + convention `Create` here reuses. +* ADR-0003 — unify Outcome value mapping under `Then`, context on naming the Outcome + surface for intent rather than mechanics. +* Pull request #126 — the request binder feature this terminal belongs to. diff --git a/maintainers/adr/README.md b/maintainers/adr/README.md index 35e54a0..d1e4ab7 100644 --- a/maintainers/adr/README.md +++ b/maintainers/adr/README.md @@ -175,3 +175,4 @@ Optional supporting material: | [ADR-0004](0004-check-every-pull-request-against-the-adr-base.md) | Check every pull request against the ADR base | Accepted | | [ADR-0005](0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.md) | Reserve the plain factory name for the Outcome-returning variant | Accepted | | [ADR-0006](0006-supply-arbitrary-test-values-from-a-seedable-source.md) | Supply arbitrary test values from a single seedable source | Accepted | +| [ADR-0007](0007-name-the-binder-terminals-new-and-create.md) | Name the binder terminals New and Create | Proposed | From 9f04d479c0e27638987051c2ab5e307d40c285fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:12:52 +0000 Subject: [PATCH 13/14] ci(binder): ship the request binder on the lib release train main replaced the hardcoded per-script train partition with a data-driven tools/trains.sh, so the binder's release wiring is re-expressed there rather than in each script. The request binder is version-coupled to FirstClassErrors (a ProjectReference), so it ships in lockstep on the lib train instead of a train of its own: a separate train would stamp its FirstClassErrors dependency at a version never co-published, making the package unresolvable (NU1102) on an immutable artifact. - trains.sh: add the binder scope to the lib train and name it in the label. - pack.sh: pack FirstClassErrors.RequestBinder on the lib train, and assert every lib-train package pins its FirstClassErrors dependency to the co-published version. The changelog and release-notes scripts source trains.sh, so binder-scoped commits land in the lib changelog with no further edit. Verified: a binder pack pins its FirstClassErrors dependency to the pack version, and the lockstep guard passes on a match and rejects a mismatch. Refs: #126 --- tools/packaging/pack.sh | 39 ++++++++++++++++++++++++++++++++------- tools/trains.sh | 2 +- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/tools/packaging/pack.sh b/tools/packaging/pack.sh index ed5831d..ba7dc00 100755 --- a/tools/packaging/pack.sh +++ b/tools/packaging/pack.sh @@ -13,10 +13,10 @@ # Usage: tools/packaging/pack.sh # is any valid SemVer (a real release passes the tag version; the # dry run passes a throwaway like 0.0.0-dryrun). -# selects which release train to pack, since lib and the fce CLI are -# versioned and released independently: -# lib -> FirstClassErrors + FirstClassErrors.Testing (lockstep) -# cli -> FirstClassErrors.Cli (the `fce` .NET tool) +# selects which release train to pack, since the trains are versioned +# and released independently: +# lib -> FirstClassErrors + FirstClassErrors.Testing + FirstClassErrors.RequestBinder (lockstep) +# cli -> FirstClassErrors.Cli (the `fce` .NET tool) set -eu @@ -32,9 +32,13 @@ scope="$2" # csproj, so local and floor-check packs stay SBOM-free. case "$scope" in lib) - # FirstClassErrors + its testing helpers: intrinsically coupled (Testing has a ProjectReference on - # the library and sees its internals), so they always ship together at the same version. - projects='FirstClassErrors/FirstClassErrors.csproj FirstClassErrors.Testing/FirstClassErrors.Testing.csproj' + # FirstClassErrors, its testing helpers, and the request binder. All three carry a ProjectReference on the + # library, so all three are intrinsically coupled to it and ship together at the same version. Packing the + # binder HERE -- on the library's train rather than one of its own -- is what keeps its `FirstClassErrors` + # package dependency pointing at a version that is co-published in this very release (the lockstep guard + # below proves it): a separate train would stamp the binder's dependency at a version never published for + # FirstClassErrors, making the package unresolvable (NU1102) and, once pushed, irreversibly so. + projects='FirstClassErrors/FirstClassErrors.csproj FirstClassErrors.Testing/FirstClassErrors.Testing.csproj FirstClassErrors.RequestBinder/FirstClassErrors.RequestBinder.csproj' ;; cli) # The `fce` .NET tool (PackAsTool; the GenDoc worker it spawns travels bundled inside the tool @@ -64,6 +68,27 @@ for package in artifacts/*.nupkg; do fi done +# Lockstep guard for the lib train. FirstClassErrors, its testing helpers and the request binder are +# co-published here at the SAME $version, so every intra-train dependency on FirstClassErrors MUST reference +# exactly $version -- the version this release is about to publish. `dotnet pack` turns each ProjectReference +# into ; a package pinning any other version would +# demand a FirstClassErrors that was never published on this train -> NU1102 for the consumer, on an immutable +# artifact. This is the check whose absence made an independent binder train unshippable; assert it every pack. +if [ "$scope" = "lib" ]; then + for package in artifacts/*.nupkg; do + while IFS= read -r dependency; do + if [ -z "$dependency" ]; then continue; fi + case "$dependency" in + *"version=\"${version}\""*) : ;; # pinned to the co-published version -- good + *) echo "error: $package pins an off-train FirstClassErrors dependency (expected version=\"$version\"): $dependency" >&2; exit 1 ;; + esac + done <]*id="FirstClassErrors[^"]*"[^>]*>' || true) +EOF + done + echo "ok: every lib-train package pins its FirstClassErrors dependency to the co-published $version" +fi + # Positive proof that the fce tool ships its GenDoc worker. `fce generate` does not do the whole job # in-process: it spawns FirstClassErrors.GenDoc.Worker in a child process (dotnet exec) and resolves it # next to the installed executable (ResolveWorkerAssemblyPath -> AppContext.BaseDirectory). PackAsTool packs diff --git a/tools/trains.sh b/tools/trains.sh index 4af471a..298da11 100644 --- a/tools/trains.sh +++ b/tools/trains.sh @@ -22,7 +22,7 @@ # tools/commit-lint/lint-commit-message.sh. trains_rows() { cat <<'ROWS' -lib|lib-v|core,analyzers,testing|CHANGELOG.md|FirstClassErrors and FirstClassErrors.Testing +lib|lib-v|core,analyzers,testing,binder|CHANGELOG.md|FirstClassErrors, FirstClassErrors.Testing and FirstClassErrors.RequestBinder cli|cli-v|cli,gendoc|FirstClassErrors.Cli/CHANGELOG.md|FirstClassErrors.Cli (the fce .NET tool) ROWS } From d7c9535ff1affeeb7615a7419b49aa49d1499fea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:46:11 +0000 Subject: [PATCH 14/14] docs(binder): document the request binder for consumers The request binder shipped without user-facing documentation: no repo guide, a dead "full guide" link in its NuGet README, and no French coverage at all -- at odds with the library's documented-errors thesis and its EN/FR sync rule. - Add doc/RequestBinder.en.md and doc/RequestBinder.fr.md: an exhaustive, example-driven guide (fluent API, collect-all, the New/Create terminals, the REQUEST_ARGUMENT_* codes, nested and list binding, options and the no-throw invariant, testing). - Index the guide in the README Documentation section and add an install line, in English (README.md) and French (doc/README.fr.md), in lockstep. - Repoint the NuGet README's "full guide" link to the new guide. Refs: #126 --- .../README.nuget.md | 2 +- README.md | 7 + doc/README.fr.md | 7 + doc/RequestBinder.en.md | 473 +++++++++++++++++ doc/RequestBinder.fr.md | 489 ++++++++++++++++++ 5 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 doc/RequestBinder.en.md create mode 100644 doc/RequestBinder.fr.md diff --git a/FirstClassErrors.RequestBinder/README.nuget.md b/FirstClassErrors.RequestBinder/README.nuget.md index ece5e14..ed368ba 100644 --- a/FirstClassErrors.RequestBinder/README.nuget.md +++ b/FirstClassErrors.RequestBinder/README.nuget.md @@ -17,4 +17,4 @@ Outcome command = bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); ``` -See the [repository documentation](https://github.com/Reefact/first-class-errors) for the full guide. +See the [request-binder guide](https://github.com/Reefact/first-class-errors/blob/main/doc/RequestBinder.en.md) for the full walkthrough, and the [repository documentation](https://github.com/Reefact/first-class-errors) for the rest of FirstClassErrors. diff --git a/README.md b/README.md index 81ba0ef..6bb6c77 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,12 @@ dotnet add package FirstClassErrors The package targets **.NET Standard 2.0**. Its Roslyn analyzers are bundled automatically; no separate analyzer package is required. +To bind incoming requests into typed commands or queries at the primary-adapter boundary, add the optional request binder: + +```bash +dotnet add package FirstClassErrors.RequestBinder +``` + To generate documentation, install the CLI: ```bash @@ -161,6 +167,7 @@ For security vulnerabilities, follow the private process in [SECURITY.md](SECURI - [Writing Errors Guide](doc/WritingErrorsGuide.en.md) - [Usage Patterns](doc/UsagePatterns.en.md) +- [Binding requests at the boundary (RequestBinder)](doc/RequestBinder.en.md) - [Best Practices](doc/BestPractices.en.md) - [Testing Guide](doc/Testing.en.md) - [Deterministic Error Tests](doc/DeterministicTesting.en.md) diff --git a/doc/README.fr.md b/doc/README.fr.md index 44ad3ab..924dbf0 100644 --- a/doc/README.fr.md +++ b/doc/README.fr.md @@ -112,6 +112,12 @@ dotnet add package FirstClassErrors Le package cible **.NET Standard 2.0**. Les analyseurs Roslyn sont inclus automatiquement ; aucun package d’analyse supplémentaire n’est nécessaire. +Pour lier les requêtes entrantes en commandes ou requêtes typées à la frontière de l’adaptateur primaire, ajoutez le binder de requêtes optionnel : + +```bash +dotnet add package FirstClassErrors.RequestBinder +``` + Pour générer la documentation, installez le CLI : ```bash @@ -161,6 +167,7 @@ Pour les vulnérabilités de sécurité, suivez le processus privé décrit dans - [Guide d’écriture des erreurs](WritingErrorsGuide.fr.md) - [Cas d’usage](UsagePatterns.fr.md) +- [Lier les requêtes à la frontière (RequestBinder)](RequestBinder.fr.md) - [Bonnes pratiques](BestPractices.fr.md) - [Guide des tests](Testing.fr.md) - [Tests d’erreur déterministes](DeterministicTesting.fr.md) diff --git a/doc/RequestBinder.en.md b/doc/RequestBinder.en.md new file mode 100644 index 0000000..98ba5c9 --- /dev/null +++ b/doc/RequestBinder.en.md @@ -0,0 +1,473 @@ +# Binding requests at the boundary + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./RequestBinder.fr.md) + +`FirstClassErrors.RequestBinder` converts an incoming request DTO — the loose, +nullable shape a controller, message consumer, CLI, or gRPC handler receives — +into a typed command or query of value objects, at the primary-adapter boundary. +It collects **every** invalid field into one documented `PrimaryPortError` +instead of stopping at the first, and it never throws on the invalid-input path. + +This page is the focused guide to declaring a binder, converting properties, +reading bound values, assembling the command, and handling the errors it +produces. If you are new to `Outcome` and error factories, read +[Getting Started](GettingStarted.en.md) and the [Outcome Guide](OutcomeGuide.en.md) +first — the binder builds directly on both. + +## 🧭 The model in one minute + +```mermaid +flowchart LR + A[Request DTO] --> B[Bind each property into a value object] + B --> C{Every field valid?} + C -->|yes| D[New / Create assembles the command] + C -->|no| E[PrimaryPortError envelope grouping every failure] + D --> F[Outcome of TCommand success] + E --> F2[Outcome of TCommand failure] +``` + +A binder reads each property, converts it through a value-object factory, and +**records** every failure rather than raising it. When the terminal runs, either +every field bound — and the command is assembled — or at least one failed, and +the result is the failure of a single envelope error carrying all of them. + +## Install the package + +```bash +dotnet add package FirstClassErrors.RequestBinder +``` + +It targets **.NET Standard 2.0** and ships on the same release train as +`FirstClassErrors`, at the same version — so the two always resolve together. + +## The shape of a binding + +Every binding has the same three parts: **start** from the request and declare +the envelope, **select and convert** each property, then **assemble** the +command. The running example is a hotel-booking endpoint whose DTO is the loose +wire shape, and whose command is a value-object aggregate. + +```csharp +// The incoming DTO: everything nullable, everything a primitive. +public sealed record BookingRequest( + string? GuestEmail, + string? Reference, + string? Currency, + string? MaxNights, + StayDto? Stay, + IReadOnlyList? Tags, + IReadOnlyList? Guests); + +// The command: value objects, non-null where presence is required. +public sealed record PlaceBookingCommand( + EmailAddress GuestEmail, + string Reference, + Currency Currency, + int? MaxNights, + Stay Stay, + IReadOnlyList Tags, + IReadOnlyList Guests); +``` + +```csharp +public Outcome Bind(BookingRequest request) { + var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid); + + 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"); + + // Assemble the command from the bound fields (the full version is at the end of this guide): + return bind.New(s => new PlaceBookingCommand( + s.Get(email), + s.Get(reference), + s.Get(currency), + /* MaxNights, Stay, Tags, Guests — bound the same way, shown below */)); +} +``` + +- **`Bind.PropertiesOf(request)`** starts a binder over the DTO. +- **`.FailWith(PlaceBookingError.Invalid)`** is mandatory: it declares the single + envelope error — one factory from *your* catalog — under which every failure is + grouped. A binder can never be left without an envelope, exactly as an error can + never be left without a public message. +- Each `SimpleProperty(...)` selects one property and converts it, returning a + **field token**, not a value. +- **`bind.New(...)`** assembles the command, reading each token through the scope + `s`. It returns `Outcome` — success when every field bound, + the envelope failure otherwise. + +The envelope factory is an ordinary aggregate error from your catalog: + +```csharp +[ProvidesErrorsFor("PlaceBooking")] +public static class PlaceBookingError { + + [DocumentedBy(nameof(InvalidDocumentation))] + internal static PrimaryPortError Invalid(PrimaryPortInnerErrors violations) { + return PrimaryPortError.Create(Code.Invalid, "The booking request is invalid.", violations) + .WithPublicMessage("We could not accept your booking request."); + } + + // ... Code, documentation, nested envelopes (StayInvalid, GuestInvalid) omitted for brevity. +} +``` + +## Converting a scalar property + +`SimpleProperty(r => r.X)` selects one property; the converter stage that follows +offers four ways to bind it. All of them take a value-object factory +(`Func>` — typically a method group such as +`EmailAddress.Parse`) and **fail by returning** `Outcome.Failure`, never by +throwing. + +| Method | Absent argument | Present but invalid | Bound value | +| --- | --- | --- | --- | +| `AsRequired(convert)` | records `REQUEST_ARGUMENT_REQUIRED` | records `REQUEST_ARGUMENT_INVALID` | `RequiredField` | +| `AsRequired()` | records `REQUEST_ARGUMENT_REQUIRED` | — (no conversion) | `RequiredField` (raw) | +| `AsOptional(convert, fallback)` | converts `fallback` instead | records `REQUEST_ARGUMENT_INVALID` | `RequiredField` (always present) | +| `AsOptionalReference(convert)` | `null`, records nothing | records `REQUEST_ARGUMENT_INVALID` | `OptionalReferenceField` | +| `AsOptionalValue(convert)` | `null`, records nothing | records `REQUEST_ARGUMENT_INVALID` | `OptionalValueField` | + +```csharp +// Required with conversion: EmailAddress.Parse turns the raw string into a value object. +RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + +// Required without conversion: presence is checked, the raw value is bound as-is. +RequiredField reference = bind.SimpleProperty(r => r.Reference).AsRequired(); + +// Optional with a fallback: absent uses "EUR"; a present-but-invalid value still records an error. +RequiredField currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + +// Optional value type: absent yields a real Nullable null — never default(int). +OptionalValueField maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + +// AsOptionalReference is the reference-type sibling of AsOptionalValue (absent yields a null value +// object, records nothing). It is shown on the guest's optional email under "Lists", below. +``` + +**Optional means "may be absent", never "may be malformed".** A present argument +that fails to convert is always an error — even on an optional binding. Only a +*missing* optional argument is silent (falling back, yielding `null`, or an empty +list). + +**A fallback is developer configuration, not client input.** If the `fallback` +you pass to `AsOptional` does not itself convert, that is a bug in your code, so +it throws `InvalidOperationException` rather than being reported as a client +error. + +## Reading bound values: the binding scope + +A field token exposes **no** public value. The only way to read one is +`s.Get(token)`, inside the assembler passed to `New` or `Create`: + +```csharp +return bind.New(s => new PlaceBookingCommand( + s.Get(email), // EmailAddress — required + s.Get(reference), // string — required, raw + s.Get(currency), // Currency — optional with fallback, always present + s.Get(maxNights), // int? — optional value, null when absent + ...)); +``` + +This is safety **by construction**, not by convention. The scope `s` is a +`readonly ref struct`: it cannot be stored, captured, or returned, so it lives +only for the duration of the assembler. And the terminal creates it **only** on +its success branch — after verifying that not a single failure was recorded. So a +bound value can be read *only* where every binding is known to have succeeded: +reading one before the terminal, or outside its assembler, does not compile. + +The token's type carries the nullability: + +| Token | `s.Get(...)` returns | +| --- | --- | +| `RequiredField` | `T` | +| `OptionalReferenceField` | `T?` (`null` when the argument was absent) | +| `OptionalValueField` | `T?` — a real `Nullable`, `null` when absent, never `default(T)` | + +## Assembling the command: `New` and `Create` + +There are two terminals. Pick the one that matches the shape of your assembler — +the name mirrors what you write inside it. + +| Terminal | Your assembler | Use when | +| --- | --- | --- | +| `New(s => new Command(...))` | returns the command | the constructor is total: every field was already validated one by one | +| `Create(s => Command.Create(...))` | returns `Outcome` | a validating factory enforces a **cross-field** rule that can still reject an all-valid combination | + +`New` wraps the constructed command in a success outcome: + +```csharp +return bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(reference), ...)); +``` + +`Create` runs a factory that may still fail — a cross-field rule such as +"check-out must be after check-in" that no single field could check on its own — +and **flattens** its result, so you never get an `Outcome>`: + +```csharp +return bind.Create(s => PlaceBookingCommand.Create( + s.Get(checkIn), s.Get(checkOut), s.Get(guests))); +// PlaceBookingCommand.Create(...) returns Outcome. +``` + +The factory runs **only** on the zero-error branch — every field is already +bound — so a cross-field rule can assume its inputs are present and valid. Its +failure is returned **as-is**: the factory owns that error (it is a domain rule, +not an argument-binding failure), so it is not re-wrapped in the binder envelope. +A consumer of `Create` therefore sees either the binder's envelope (a field was +missing or malformed) or the factory's own error (all fields were fine, but the +combination was rejected). The decision behind these two names is recorded in +[ADR-0007](../maintainers/adr/0007-name-the-binder-terminals-new-and-create.md). + +## Collect-all, not fail-fast + +The whole point of a binder is that a client fixing one field does not discover +the next only on resubmit. Every failing property is recorded and reported at +once, in declaration order: + +```csharp +var bind = Bind.PropertiesOf(new BookingRequest( + GuestEmail: "not-an-email", // invalid + Reference: null, // missing + Currency: "EURO", // invalid (not 3 letters) + /* ... */)) + .FailWith(PlaceBookingError.Invalid); + +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(s => /* never reached */ null!); + +// outcome.Error is PlaceBookingError.Invalid, with three inner errors: +// REQUEST_ARGUMENT_INVALID (GuestEmail) +// REQUEST_ARGUMENT_REQUIRED (Reference) +// REQUEST_ARGUMENT_INVALID (Currency) +``` + +A request whose every field is invalid raises **zero** exceptions: the binder +never throws on the invalid-input path. + +## The two binder error codes + +The binder manufactures exactly two errors of its own. Everything else in a +failure tree comes from *your* code — the conversion errors your value objects +return, and the envelope errors you declare with `FailWith`. + +| Code | Meaning | Inner error | +| --- | --- | --- | +| `REQUEST_ARGUMENT_REQUIRED` | a required argument was absent from the request | — | +| `REQUEST_ARGUMENT_INVALID` | a present argument failed to convert | the converter's own error | + +Both carry the **full argument path** in their context under the key +`RequestArgument` (for example `Guests[1].FirstName`), so the failing field is +identifiable without parsing messages: + +```csharp +Error required = outcome.Error!.InnerErrors.First(); +required.Context.ToNameDictionary().TryGetValue("RequestArgument", out object? path); +// path == "Reference" +``` + +`REQUEST_ARGUMENT_REQUIRED` is **non-transient**: resubmitting the same request +cannot succeed. `REQUEST_ARGUMENT_INVALID` wraps the converter's `DomainError` or +`PrimaryPortError` as its inner error — read it to learn the precise rule the +value violated. Both codes are documented in the generated catalog like any +other error (see the [documentation pipeline](ArchitectureOfTheDocumentationPipeline.en.md)). + +A converter must fail with a `DomainError` or a `PrimaryPortError` — the two +families a failure tree accepts. Failing with any other family is a converter +bug, reported by throwing, never recorded as a client error. + +## Nested objects + +A complex property is bound by a **nested binder**, declared with its own +envelope. The nested binding typically lives in a dedicated method, passed as a +method group: + +```csharp +RequiredField stay = bind.ComplexProperty(r => r.Stay) + .FailWith(PlaceBookingError.StayInvalid) + .AsRequired(BindStay); + +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))); +} +``` + +The nested binder inherits the parent's options and **prefixes** its argument +paths, so a failure inside `Stay` reports `Stay.CheckIn`, not just `CheckIn`. A +missing complex property records `REQUEST_ARGUMENT_REQUIRED`; a nested binding +that fails contributes its own envelope, whose inner errors already carry the +prefixed paths. Use `AsOptional` instead of `AsRequired` for a nullable nested +object: absent yields `null` and records nothing. + +## Lists + +Two selectors bind list properties, each producing indexed paths so one bad +element never hides the others. + +**A list of scalars** converts each element through a value-object factory: + +```csharp +RequiredField> tags = + bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); +// A failing element records REQUEST_ARGUMENT_INVALID under Tags[2]. +``` + +**A list of complex elements** binds each element with a nested binder, under a +per-element envelope: + +```csharp +RequiredField> guests = + bind.ListOfComplexProperties(r => r.Guests) + .FailWith(PlaceBookingError.GuestInvalid) + .AsRequired(BindGuest); + +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))); +} +// A failure in the second guest's email reports Guests[1].Email. +``` + +For both list selectors, `AsRequired` records `REQUEST_ARGUMENT_REQUIRED` when the +list is absent, while `AsOptional` treats an absent list as an **empty** list +(never `null`) and records nothing. A `null` *element* of a present list is +recorded as a missing argument at its index. + +## Argument names and the wire format + +By default, the argument path uses the **C# property name** (`GuestEmail`). If +your serializer renames keys (snake_case, `JsonPropertyName`, a naming policy), +plug an `IArgumentNameProvider` so the paths reported in errors match the keys the +client actually sent: + +```csharp +public sealed class SnakeCaseNames : IArgumentNameProvider { + public string GetArgumentNameFrom(PropertyInfo property) => + ToSnakeCase(property.Name); // GuestEmail -> guest_email +} + +var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid) + .WithOptions(new RequestBinderOptions(new SnakeCaseNames())); +``` + +Call `WithOptions` **before** binding any property. Options are per-binder, never +global mutable state, and **nested binders inherit** the options in effect when +they are created — so `Stay.check_in` is renamed consistently, top to bottom. +The library deliberately ships only the default (C# property names): which +serializer names the wire keys is the host's knowledge, not the library's. + +## The bug channel: what throws vs what is collected + +The binder draws a hard line between a **client error** (recorded, surfaced once +as the envelope failure) and a **programming error** (thrown, so a genuine bug +reaches your exception boundary undisguised). Nothing on the invalid-input path +throws; these do: + +- a converter that throws instead of returning `Outcome.Failure` (a converter bug); +- a selector that is not a direct property access, e.g. `r => r.Email.Trim()`; +- an `AsOptional` fallback that does not convert (bad developer configuration); +- reading a token through the scope of a *different* binder; +- **a non-nullable value-type DTO property**, e.g. `int` instead of `int?`. + +The last one is worth dwelling on. A non-nullable value type can never be +`null`, so a *missing* argument (deserialized to `default(T)` — `0`, `false`) is +indistinguishable from a legitimately-sent default. The information does not +exist at runtime, so the binder rejects the mis-declaration loudly rather than +silently losing absence: + +```csharp +public sealed record BookingRequest(int MaxNights /* ... */); // ✗ int +// ^ throws ArgumentException: declare it int? + +public sealed record BookingRequest(int? MaxNights /* ... */); // ✓ int? +``` + +Declare every bound value-type property nullable, so an absent argument arrives +as `null` and the binder can tell it apart from a real value. + +## Complete example + +A full endpoint: nested `Stay`, a list of `Guests`, an optional `MaxNights`, and +a cross-field rule enforced through `Create`. + +```csharp +public Outcome BindBooking(BookingRequest request) { + var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid); + + 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); + RequiredField stay = bind.ComplexProperty(r => r.Stay).FailWith(PlaceBookingError.StayInvalid).AsRequired(BindStay); + RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(PlaceBookingError.GuestInvalid).AsRequired(BindGuest); + + // Create: PlaceBookingCommand.Create enforces "check-out after check-in" and may still reject. + return bind.Create(s => PlaceBookingCommand.Create( + s.Get(email), + s.Get(reference), + s.Get(currency), + s.Get(maxNights), + s.Get(stay), + s.Get(tags), + s.Get(guests))); +} +``` + +One structured error model runs from the wire to the command: a malformed field +surfaces as the binder's envelope with indexed paths; a rejected all-valid +combination surfaces as the command factory's own error. No exception is raised +unless the code itself is wrong. + +## Testing a binding + +The binder returns an `Outcome`, so the [testing helpers](Testing.en.md) apply +directly. Assert on codes and argument paths rather than on messages: + +```csharp +Outcome outcome = BindBooking(InvalidRequest()); + +Check.That(outcome.IsFailure).IsTrue(); +Check.That(outcome.Error!.Code.ToString()).IsEqualTo("PLACE_BOOKING_INVALID"); +Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())) + .ContainsExactly("REQUEST_ARGUMENT_INVALID", "REQUEST_ARGUMENT_REQUIRED"); +``` + +Assert the *whole* set of collected failures, in order — that is what proves the +collect-all behavior, not just that binding failed. + +## 📌 Review checklist + +Before approving a binding, verify that: + +- every DTO property is nullable, including value types (`int?`, not `int`); +- each property is bound with the variant matching its contract — required, + optional-with-fallback, or optional; +- converters fail by **returning** `Outcome.Failure`, never by throwing; +- the terminal matches the assembler: `New` for a total constructor, `Create` for + a validating factory returning `Outcome`; +- every complex property and list declares its own envelope with `FailWith`; +- an `IArgumentNameProvider` is configured when the wire keys differ from the C# + property names; +- tests assert the full, ordered set of collected errors and their argument paths. + +--- + + + +--- diff --git a/doc/RequestBinder.fr.md b/doc/RequestBinder.fr.md new file mode 100644 index 0000000..707f079 --- /dev/null +++ b/doc/RequestBinder.fr.md @@ -0,0 +1,489 @@ +# Lier les requêtes à la frontière + +🌍 **Langues :** +🇬🇧 [English](./RequestBinder.en.md) | 🇫🇷 Français (ce fichier) + +`FirstClassErrors.RequestBinder` convertit un DTO de requête entrant — la forme +lâche et nullable que reçoit un contrôleur, un consommateur de messages, une CLI +ou un handler gRPC — en une commande ou une requête typée, faite d’objets valeur, +à la frontière de l’adaptateur primaire. Il collecte **tous** les champs invalides +dans un seul `PrimaryPortError` documenté au lieu de s’arrêter au premier, et il ne +lève jamais d’exception sur le chemin des entrées invalides. + +Cette page est le guide dédié : déclarer un binder, convertir les propriétés, lire +les valeurs liées, assembler la commande, et traiter les erreurs produites. Si +`Outcome` et les fabriques d’erreurs sont nouveaux pour vous, lisez d’abord +[Bien démarrer](GettingStarted.fr.md) et le [Guide d’Outcome](OutcomeGuide.fr.md) — +le binder s’appuie directement sur les deux. + +## 🧭 Le modèle en une minute + +```mermaid +flowchart LR + A[DTO de requête] --> B[Lier chaque propriété en objet valeur] + B --> C{Tous les champs valides ?} + C -->|oui| D[New / Create assemble la commande] + C -->|non| E[Enveloppe PrimaryPortError groupant chaque échec] + D --> F[Outcome de TCommand succès] + E --> F2[Outcome de TCommand échec] +``` + +Un binder lit chaque propriété, la convertit via une fabrique d’objet valeur, et +**enregistre** chaque échec au lieu de le lever. Quand le terminal s’exécute, soit +tous les champs se sont liés — et la commande est assemblée —, soit au moins un a +échoué, et le résultat est l’échec d’une enveloppe unique qui les porte tous. + +## Installer le paquet + +```bash +dotnet add package FirstClassErrors.RequestBinder +``` + +Il cible **.NET Standard 2.0** et voyage sur le même train de release que +`FirstClassErrors`, à la même version — les deux se résolvent donc toujours +ensemble. + +## La forme d’une liaison + +Toute liaison a les trois mêmes parties : **démarrer** depuis la requête et +déclarer l’enveloppe, **sélectionner et convertir** chaque propriété, puis +**assembler** la commande. L’exemple fil rouge est un endpoint de réservation +d’hôtel dont le DTO est la forme lâche du fil, et dont la commande est un agrégat +d’objets valeur. + +```csharp +// Le DTO entrant : tout est nullable, tout est primitif. +public sealed record BookingRequest( + string? GuestEmail, + string? Reference, + string? Currency, + string? MaxNights, + StayDto? Stay, + IReadOnlyList? Tags, + IReadOnlyList? Guests); + +// La commande : des objets valeur, non-null là où la présence est requise. +public sealed record PlaceBookingCommand( + EmailAddress GuestEmail, + string Reference, + Currency Currency, + int? MaxNights, + Stay Stay, + IReadOnlyList Tags, + IReadOnlyList Guests); +``` + +```csharp +public Outcome Bind(BookingRequest request) { + var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid); + + 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"); + + // Assembler la commande à partir des champs liés (la version complète est en fin de guide) : + return bind.New(s => new PlaceBookingCommand( + s.Get(email), + s.Get(reference), + s.Get(currency), + /* MaxNights, Stay, Tags, Guests — liés de la même façon, montrés plus bas */)); +} +``` + +- **`Bind.PropertiesOf(request)`** démarre un binder sur le DTO. +- **`.FailWith(PlaceBookingError.Invalid)`** est obligatoire : il déclare l’unique + erreur enveloppe — une fabrique de *votre* catalogue — sous laquelle chaque échec + est groupé. Un binder ne peut jamais rester sans enveloppe, exactement comme une + erreur ne peut jamais rester sans message public. +- Chaque `SimpleProperty(...)` sélectionne une propriété et la convertit, en + renvoyant un **jeton de champ**, pas une valeur. +- **`bind.New(...)`** assemble la commande, en lisant chaque jeton via la portée + `s`. Il renvoie `Outcome` — un succès quand tous les champs + se sont liés, l’échec de l’enveloppe sinon. + +La fabrique d’enveloppe est une erreur d’agrégat ordinaire de votre catalogue : + +```csharp +[ProvidesErrorsFor("PlaceBooking")] +public static class PlaceBookingError { + + [DocumentedBy(nameof(InvalidDocumentation))] + internal static PrimaryPortError Invalid(PrimaryPortInnerErrors violations) { + return PrimaryPortError.Create(Code.Invalid, "The booking request is invalid.", violations) + .WithPublicMessage("We could not accept your booking request."); + } + + // ... Code, documentation, enveloppes imbriquées (StayInvalid, GuestInvalid) omis pour la concision. +} +``` + +## Convertir une propriété scalaire + +`SimpleProperty(r => r.X)` sélectionne une propriété ; l’étape de conversion qui +suit offre quatre façons de la lier. Toutes prennent une fabrique d’objet valeur +(`Func>` — typiquement un groupe de méthodes comme +`EmailAddress.Parse`) et **échouent en renvoyant** `Outcome.Failure`, jamais en +levant une exception. + +| Méthode | Argument absent | Présent mais invalide | Valeur liée | +| --- | --- | --- | --- | +| `AsRequired(convert)` | enregistre `REQUEST_ARGUMENT_REQUIRED` | enregistre `REQUEST_ARGUMENT_INVALID` | `RequiredField` | +| `AsRequired()` | enregistre `REQUEST_ARGUMENT_REQUIRED` | — (pas de conversion) | `RequiredField` (brut) | +| `AsOptional(convert, fallback)` | convertit `fallback` à la place | enregistre `REQUEST_ARGUMENT_INVALID` | `RequiredField` (toujours présent) | +| `AsOptionalReference(convert)` | `null`, n’enregistre rien | enregistre `REQUEST_ARGUMENT_INVALID` | `OptionalReferenceField` | +| `AsOptionalValue(convert)` | `null`, n’enregistre rien | enregistre `REQUEST_ARGUMENT_INVALID` | `OptionalValueField` | + +```csharp +// Requis avec conversion : EmailAddress.Parse transforme la chaîne brute en objet valeur. +RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + +// Requis sans conversion : la présence est vérifiée, la valeur brute est liée telle quelle. +RequiredField reference = bind.SimpleProperty(r => r.Reference).AsRequired(); + +// Optionnel avec repli : l'absence utilise « EUR » ; une valeur présente mais invalide enregistre quand même une erreur. +RequiredField currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + +// Type valeur optionnel : l'absence donne un vrai Nullable null — jamais default(int). +OptionalValueField maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + +// AsOptionalReference est le pendant « type référence » d'AsOptionalValue (l'absence donne un objet +// valeur null, n'enregistre rien). Il est montré sur l'email optionnel de l'invité dans « Listes », plus bas. +``` + +**Optionnel signifie « peut être absent », jamais « peut être malformé ».** Un +argument présent qui échoue à se convertir est toujours une erreur — même sur une +liaison optionnelle. Seul un argument optionnel *manquant* est silencieux (repli, +`null`, ou liste vide). + +**Un repli est une configuration développeur, pas une entrée client.** Si le +`fallback` passé à `AsOptional` ne se convertit pas lui-même, c’est un bug de +votre code : il lève donc `InvalidOperationException` au lieu d’être rapporté comme +une erreur client. + +## Lire les valeurs liées : la portée de liaison + +Un jeton de champ n’expose **aucune** valeur publique. La seule façon d’en lire un +est `s.Get(token)`, à l’intérieur de l’assembleur passé à `New` ou `Create` : + +```csharp +return bind.New(s => new PlaceBookingCommand( + s.Get(email), // EmailAddress — requis + s.Get(reference), // string — requis, brut + s.Get(currency), // Currency — optionnel avec repli, toujours présent + s.Get(maxNights), // int? — valeur optionnelle, null si absent + ...)); +``` + +C’est une sûreté **par construction**, pas par convention. La portée `s` est un +`readonly ref struct` : elle ne peut être ni stockée, ni capturée, ni retournée, +donc elle ne vit que le temps de l’assembleur. Et le terminal ne la crée **que** +sur sa branche de succès — après avoir vérifié qu’aucun échec n’a été enregistré. +Une valeur liée ne peut donc être lue *que* là où chaque liaison est connue pour +avoir réussi : lire une valeur avant le terminal, ou hors de son assembleur, **ne +compile pas**. + +Le type du jeton porte la nullabilité : + +| Jeton | `s.Get(...)` renvoie | +| --- | --- | +| `RequiredField` | `T` | +| `OptionalReferenceField` | `T?` (`null` si l’argument était absent) | +| `OptionalValueField` | `T?` — un vrai `Nullable`, `null` si absent, jamais `default(T)` | + +## Assembler la commande : `New` et `Create` + +Il y a deux terminaux. Choisissez celui qui correspond à la forme de votre +assembleur — le nom recopie ce que vous écrivez à l’intérieur. + +| Terminal | Votre assembleur | À utiliser quand | +| --- | --- | --- | +| `New(s => new Command(...))` | renvoie la commande | le constructeur est total : chaque champ a déjà été validé un par un | +| `Create(s => Command.Create(...))` | renvoie `Outcome` | une fabrique validante applique une règle **inter-champs** qui peut encore rejeter une combinaison pourtant valide | + +`New` emballe la commande construite dans un outcome de succès : + +```csharp +return bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(reference), ...)); +``` + +`Create` exécute une fabrique qui peut encore échouer — une règle inter-champs +telle que « la sortie doit être après l’entrée » qu’aucun champ seul ne pourrait +vérifier — et **aplatit** son résultat, pour que vous n’obteniez jamais un +`Outcome>` : + +```csharp +return bind.Create(s => PlaceBookingCommand.Create( + s.Get(checkIn), s.Get(checkOut), s.Get(guests))); +// PlaceBookingCommand.Create(...) renvoie Outcome. +``` + +La fabrique ne s’exécute **que** sur la branche zéro-erreur — chaque champ est +déjà lié — donc une règle inter-champs peut supposer ses entrées présentes et +valides. Son échec est renvoyé **tel quel** : la fabrique possède cette erreur +(c’est une règle métier, pas un échec de liaison d’argument), elle n’est donc pas +ré-emballée dans l’enveloppe du binder. Un consommateur de `Create` voit donc soit +l’enveloppe du binder (un champ manquant ou malformé), soit l’erreur propre de la +fabrique (tous les champs allaient bien, mais la combinaison a été rejetée). La +décision derrière ces deux noms est consignée dans +[ADR-0007](../maintainers/adr/0007-name-the-binder-terminals-new-and-create.md). + +## Tout collecter, pas au premier échec + +Tout l’intérêt d’un binder est qu’un client qui corrige un champ ne découvre pas +le suivant seulement à la resoumission. Chaque propriété en échec est enregistrée +et rapportée d’un coup, dans l’ordre de déclaration : + +```csharp +var bind = Bind.PropertiesOf(new BookingRequest( + GuestEmail: "not-an-email", // invalide + Reference: null, // manquant + Currency: "EURO", // invalide (pas 3 lettres) + /* ... */)) + .FailWith(PlaceBookingError.Invalid); + +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(s => /* jamais atteint */ null!); + +// outcome.Error est PlaceBookingError.Invalid, avec trois erreurs internes : +// REQUEST_ARGUMENT_INVALID (GuestEmail) +// REQUEST_ARGUMENT_REQUIRED (Reference) +// REQUEST_ARGUMENT_INVALID (Currency) +``` + +Une requête dont chaque champ est invalide ne lève **aucune** exception : le binder +ne lève jamais sur le chemin des entrées invalides. + +## Les deux codes d’erreur du binder + +Le binder fabrique exactement deux erreurs qui lui sont propres. Tout le reste +d’un arbre d’échec vient de *votre* code — les erreurs de conversion que renvoient +vos objets valeur, et les erreurs enveloppes que vous déclarez avec `FailWith`. + +| Code | Signification | Erreur interne | +| --- | --- | --- | +| `REQUEST_ARGUMENT_REQUIRED` | un argument requis était absent de la requête | — | +| `REQUEST_ARGUMENT_INVALID` | un argument présent a échoué à se convertir | l’erreur propre du convertisseur | + +Les deux portent le **chemin d’argument complet** dans leur contexte sous la clé +`RequestArgument` (par exemple `Guests[1].FirstName`), pour que le champ fautif +soit identifiable sans parser les messages : + +```csharp +Error required = outcome.Error!.InnerErrors.First(); +required.Context.ToNameDictionary().TryGetValue("RequestArgument", out object? path); +// path == "Reference" +``` + +`REQUEST_ARGUMENT_REQUIRED` est **non transitoire** : resoumettre la même requête +ne peut pas réussir. `REQUEST_ARGUMENT_INVALID` emballe comme erreur interne le +`DomainError` ou le `PrimaryPortError` du convertisseur — lisez-le pour connaître +la règle précise que la valeur a violée. Les deux codes sont documentés dans le +catalogue généré comme toute autre erreur (voir le +[pipeline de documentation](ArchitectureOfTheDocumentationPipeline.fr.md)). + +Un convertisseur doit échouer avec un `DomainError` ou un `PrimaryPortError` — les +deux familles qu’un arbre d’échec accepte. Échouer avec une autre famille est un +bug du convertisseur, rapporté en levant, jamais enregistré comme erreur client. + +## Objets imbriqués + +Une propriété complexe est liée par un **binder imbriqué**, déclaré avec sa propre +enveloppe. La liaison imbriquée vit typiquement dans une méthode dédiée, passée en +groupe de méthodes : + +```csharp +RequiredField stay = bind.ComplexProperty(r => r.Stay) + .FailWith(PlaceBookingError.StayInvalid) + .AsRequired(BindStay); + +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))); +} +``` + +Le binder imbriqué hérite des options du parent et **préfixe** ses chemins +d’argument : un échec dans `Stay` rapporte `Stay.CheckIn`, pas seulement +`CheckIn`. Une propriété complexe manquante enregistre `REQUEST_ARGUMENT_REQUIRED` ; +une liaison imbriquée qui échoue contribue sa propre enveloppe, dont les erreurs +internes portent déjà les chemins préfixés. Utilisez `AsOptional` plutôt +qu’`AsRequired` pour un objet imbriqué nullable : l’absence donne `null` et +n’enregistre rien. + +## Listes + +Deux sélecteurs lient les propriétés de type liste, chacun produisant des chemins +indexés pour qu’un mauvais élément n’en cache jamais un autre. + +**Une liste de scalaires** convertit chaque élément via une fabrique d’objet +valeur : + +```csharp +RequiredField> tags = + bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); +// Un élément en échec enregistre REQUEST_ARGUMENT_INVALID sous Tags[2]. +``` + +**Une liste d’éléments complexes** lie chaque élément avec un binder imbriqué, sous +une enveloppe par élément : + +```csharp +RequiredField> guests = + bind.ListOfComplexProperties(r => r.Guests) + .FailWith(PlaceBookingError.GuestInvalid) + .AsRequired(BindGuest); + +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))); +} +// Un échec sur l'email du deuxième invité rapporte Guests[1].Email. +``` + +Pour les deux sélecteurs de liste, `AsRequired` enregistre +`REQUEST_ARGUMENT_REQUIRED` quand la liste est absente, tandis qu’`AsOptional` +traite une liste absente comme une liste **vide** (jamais `null`) et n’enregistre +rien. Un *élément* `null` d’une liste présente est enregistré comme un argument +manquant à son index. + +## Noms d’arguments et format du fil + +Par défaut, le chemin d’argument utilise le **nom de propriété C#** +(`GuestEmail`). Si votre sérialiseur renomme les clés (snake_case, +`JsonPropertyName`, une politique de nommage), branchez un `IArgumentNameProvider` +pour que les chemins rapportés dans les erreurs correspondent aux clés réellement +envoyées par le client : + +```csharp +public sealed class SnakeCaseNames : IArgumentNameProvider { + public string GetArgumentNameFrom(PropertyInfo property) => + ToSnakeCase(property.Name); // GuestEmail -> guest_email +} + +var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid) + .WithOptions(new RequestBinderOptions(new SnakeCaseNames())); +``` + +Appelez `WithOptions` **avant** de lier la moindre propriété. Les options sont par +binder, jamais un état global mutable, et **les binders imbriqués héritent** des +options en vigueur à leur création — donc `Stay.check_in` est renommé de manière +cohérente, de haut en bas. La bibliothèque ne fournit délibérément que le défaut +(noms de 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. + +## Le canal des bugs : ce qui lève vs ce qui est collecté + +Le binder trace une frontière nette entre une **erreur client** (enregistrée, +surfacée une seule fois comme l’échec de l’enveloppe) et une **erreur de +programmation** (levée, pour qu’un vrai bug atteigne votre frontière d’exceptions +sans déguisement). Rien sur le chemin des entrées invalides ne lève ; ceci, si : + +- un convertisseur lève au lieu de renvoyer `Outcome.Failure` (bug de convertisseur) ; +- un sélecteur qui n’est pas un accès direct à une propriété, p. ex. `r => r.Email.Trim()` ; +- un repli `AsOptional` qui ne se convertit pas (mauvaise configuration développeur) ; +- la lecture d’un jeton via la portée d’un *autre* binder ; +- **une propriété DTO de type valeur non-nullable**, p. ex. `int` au lieu de `int?`. + +Le dernier cas mérite qu’on s’y arrête. Un type valeur non-nullable ne peut jamais +être `null`, donc un argument *manquant* (désérialisé en `default(T)` — `0`, +`false`) est indistinguable d’un défaut légitimement envoyé. L’information n’existe +pas à l’exécution : le binder rejette donc la mauvaise déclaration bruyamment +plutôt que de perdre silencieusement l’absence : + +```csharp +public sealed record BookingRequest(int MaxNights /* ... */); // ✗ int +// ^ lève ArgumentException : déclarez-le int? + +public sealed record BookingRequest(int? MaxNights /* ... */); // ✓ int? +``` + +Déclarez nullable chaque propriété de type valeur liée, pour qu’un argument absent +arrive comme `null` et que le binder puisse le distinguer d’une vraie valeur. + +## Exemple complet + +Un endpoint complet : un `Stay` imbriqué, une liste de `Guests`, un `MaxNights` +optionnel, et une règle inter-champs appliquée via `Create`. + +```csharp +public Outcome BindBooking(BookingRequest request) { + var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid); + + 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); + RequiredField stay = bind.ComplexProperty(r => r.Stay).FailWith(PlaceBookingError.StayInvalid).AsRequired(BindStay); + RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(PlaceBookingError.GuestInvalid).AsRequired(BindGuest); + + // Create : PlaceBookingCommand.Create applique « sortie après entrée » et peut encore rejeter. + return bind.Create(s => PlaceBookingCommand.Create( + s.Get(email), + s.Get(reference), + s.Get(currency), + s.Get(maxNights), + s.Get(stay), + s.Get(tags), + s.Get(guests))); +} +``` + +Un seul modèle d’erreur structuré court du fil jusqu’à la commande : un champ +malformé surface comme l’enveloppe du binder avec ses chemins indexés ; une +combinaison pourtant valide mais rejetée surface comme l’erreur propre de la +fabrique de commande. Aucune exception n’est levée, sauf si le code lui-même est +faux. + +## Tester une liaison + +Le binder renvoie un `Outcome`, donc les [aides de test](Testing.fr.md) +s’appliquent directement. Assertez sur les codes et les chemins d’argument plutôt +que sur les messages : + +```csharp +Outcome outcome = BindBooking(InvalidRequest()); + +Check.That(outcome.IsFailure).IsTrue(); +Check.That(outcome.Error!.Code.ToString()).IsEqualTo("PLACE_BOOKING_INVALID"); +Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())) + .ContainsExactly("REQUEST_ARGUMENT_INVALID", "REQUEST_ARGUMENT_REQUIRED"); +``` + +Assertez l’ensemble *complet* des échecs collectés, dans l’ordre — c’est ce qui +prouve le comportement de collecte exhaustive, pas seulement que la liaison a +échoué. + +## 📌 Checklist de revue + +Avant d’approuver une liaison, vérifiez que : + +- chaque propriété du DTO est nullable, y compris les types valeur (`int?`, pas `int`) ; +- chaque propriété est liée avec la variante correspondant à son contrat — requise, + optionnelle-avec-repli, ou optionnelle ; +- les convertisseurs échouent en **renvoyant** `Outcome.Failure`, jamais en levant ; +- le terminal correspond à l’assembleur : `New` pour un constructeur total, + `Create` pour une fabrique validante renvoyant `Outcome` ; +- chaque propriété complexe et chaque liste déclare sa propre enveloppe avec `FailWith` ; +- un `IArgumentNameProvider` est configuré quand les clés du fil diffèrent des noms + de propriété C# ; +- les tests assertent l’ensemble complet et ordonné des erreurs collectées et leurs + chemins d’argument. + +--- + + + +---