diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index aa4a89e5..9c401dbf 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -260,7 +260,7 @@ 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.WithOptions(null!)).Throws(); Check.ThatCode(() => bind.New(null!)).Throws(); Check.ThatCode(() => bind.Create(null!)).Throws(); diff --git a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs index 153ff266..f56b2f04 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs @@ -147,9 +147,9 @@ public void OptionalComplexListAbsentBindsEmpty() { [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())); + var bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider())) + .PropertiesOf(new BookingRequest("a@b.c", "REF-1", null, null, null, null, Guests: [new GuestDto(null, "nope")])) + .FailWith(BookingEnvelopeError.CommandInvalid); bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); diff --git a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs index 58267665..014106a9 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs @@ -178,9 +178,9 @@ public void InvalidSelectorThrows() { [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())); + var bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider())) + .PropertiesOf(new BookingRequest("a@b.c", "REF-1", null, null, new StayDto(null, null), null, null)) + .FailWith(BookingEnvelopeError.CommandInvalid); bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); @@ -191,6 +191,23 @@ public void CustomNameProviderRenamesPaths() { .ContainsExactly("stay.check_in", "stay.check_out"); } + [Fact(DisplayName = "A configured entry point is reusable: Bind.WithOptions(...) binds many requests under the same naming policy.")] + public void ConfiguredEntryPointIsReusableAcrossRequests() { + // Bind.WithOptions holds no per-request state, so one instance configures every request the same way — the + // pattern an application sets once at startup and reuses per request, instead of reconfiguring each binder. + ConfiguredBind bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider())); + + string? PathOfMissingEmail(BookingRequest request) { + var b = bind.PropertiesOf(request).FailWith(BookingEnvelopeError.CommandInvalid); + b.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + return BindingAssertions.ArgumentPathOf(b.New(_ => "never").Error!.InnerErrors.Single()); + } + + Check.That(PathOfMissingEmail(new BookingRequest(null, "REF-1", null, null, null, null, null))).IsEqualTo("guest_email"); + Check.That(PathOfMissingEmail(new BookingRequest(null, "REF-2", null, null, null, null, null))).IsEqualTo("guest_email"); + } + [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)) diff --git a/FirstClassErrors.RequestBinder/Bind.cs b/FirstClassErrors.RequestBinder/Bind.cs index c0a34b87..6d70e676 100644 --- a/FirstClassErrors.RequestBinder/Bind.cs +++ b/FirstClassErrors.RequestBinder/Bind.cs @@ -21,7 +21,8 @@ public static class Bind { #region Statics members declarations /// - /// Starts binding the properties of a request DTO. Declare the failure envelope next, with + /// Starts binding the properties of a request DTO with the default options (argument names are the C# + /// property names). Declare the failure envelope next, with /// . /// /// The type of the request DTO. @@ -31,7 +32,23 @@ public static class Bind { public static RequestBinderEnvelopeStage PropertiesOf(TRequest request) { if (request is null) { throw new ArgumentNullException(nameof(request)); } - return new RequestBinderEnvelopeStage(request); + return new RequestBinderEnvelopeStage(request, RequestBinderOptions.Default); + } + + /// + /// Fixes the binding options — for example a serializer-aware — before + /// any property is bound, then starts binding with . The + /// options are set once here, so a binder's naming policy can never change mid-binding. The returned entry + /// point holds no per-request state: create it once (for example at application setup) and reuse it for every + /// request. + /// + /// The options every binding started from the returned entry point (and its nested binders) binds with. + /// An options-configured entry point offering . + /// Thrown when is null. + public static ConfiguredBind WithOptions(RequestBinderOptions options) { + if (options is null) { throw new ArgumentNullException(nameof(options)); } + + return new ConfiguredBind(options); } #endregion diff --git a/FirstClassErrors.RequestBinder/ConfiguredBind.cs b/FirstClassErrors.RequestBinder/ConfiguredBind.cs new file mode 100644 index 00000000..720d27bf --- /dev/null +++ b/FirstClassErrors.RequestBinder/ConfiguredBind.cs @@ -0,0 +1,39 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// An options-configured request-binding entry point, produced by . It fixes the +/// once, before any binding begins, so a binder's naming policy can never +/// change mid-binding. It carries no per-request state, so a single instance can be created once (for example at +/// application setup) and reused for every request. +/// +public sealed class ConfiguredBind { + + #region Fields declarations + + private readonly RequestBinderOptions _options; + + #endregion + + #region Constructors declarations + + internal ConfiguredBind(RequestBinderOptions options) { + _options = options; + } + + #endregion + + /// + /// Starts binding the properties of a request DTO with the configured options. 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 RequestBinderEnvelopeStage PropertiesOf(TRequest request) { + if (request is null) { throw new ArgumentNullException(nameof(request)); } + + return new RequestBinderEnvelopeStage(request, _options); + } + +} diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs index 12285fac..6e83a449 100644 --- a/FirstClassErrors.RequestBinder/RequestBinder.cs +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -49,8 +49,8 @@ internal RequestBinder(TRequest request, FuncThe options this binder (and every binder nested under it) binds with. - internal RequestBinderOptions Options { get; private set; } + /// The options this binder (and every binder nested under it) binds with; fixed before binding begins. + internal RequestBinderOptions Options { get; } /// /// The envelope instance the most recent failing build terminal @@ -61,22 +61,6 @@ internal RequestBinder(TRequest request, Func 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 - /// 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>>). diff --git a/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs b/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs index 5ef071e9..c8d96a1b 100644 --- a/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs +++ b/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs @@ -10,14 +10,16 @@ public sealed class RequestBinderEnvelopeStage { #region Fields declarations - private readonly TRequest _request; + private readonly TRequest _request; + private readonly RequestBinderOptions _options; #endregion #region Constructors declarations - internal RequestBinderEnvelopeStage(TRequest request) { + internal RequestBinderEnvelopeStage(TRequest request, RequestBinderOptions options) { _request = request; + _options = options; } #endregion @@ -33,7 +35,7 @@ internal RequestBinderEnvelopeStage(TRequest request) { public RequestBinder FailWith(Func envelope) { if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); } - return new RequestBinder(_request, envelope, RequestBinderOptions.Default, argumentPrefix: null); + return new RequestBinder(_request, envelope, _options, argumentPrefix: null); } } diff --git a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs index ec8074e6..e25a7dc3 100644 --- a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs +++ b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs @@ -1,9 +1,9 @@ namespace FirstClassErrors.RequestBinder; /// -/// The binding options of a . Options are per-binder — passed through -/// and inherited by nested binders — never global mutable -/// state. +/// The binding options of a . Options are fixed once, before binding +/// begins — through — and inherited by nested binders; they are never global +/// mutable state and can never change while a binder is binding. /// public sealed class RequestBinderOptions { diff --git a/doc/handwritten/for-maintainers/adr/0012-fix-the-binder-options-before-binding-begins.fr.md b/doc/handwritten/for-maintainers/adr/0012-fix-the-binder-options-before-binding-begins.fr.md new file mode 100644 index 00000000..0f038211 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0012-fix-the-binder-options-before-binding-begins.fr.md @@ -0,0 +1,143 @@ +# ADR-0012 | Fixer les options du binder avant le début de la liaison + +🌍 🇬🇧 [English](0012-fix-the-binder-options-before-binding-begins.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-18 +**Décideurs :** Reefact + +## Contexte + +* Le binder résout le chemin d'argument de chaque propriété liée — la clé rapportée dans + les chemins d'erreur, comme `GuestEmail` ou `Stay.CheckIn` — via l'`IArgumentNameProvider` + porté par `RequestBinderOptions`. +* Avant cette décision, les options étaient posées par une méthode d'instance `WithOptions` + sur `RequestBinder`, appelable à n'importe quel point de la chaîne fluide, y + compris après que certaines propriétés aient déjà été liées. +* Chaque liaison de propriété lit les options en vigueur au moment où elle est liée : changer + le provider entre deux liaisons produit donc des chemins d'argument sous deux politiques de + nommage différentes dans une même enveloppe d'échec — par exemple `GuestEmail` à côté de + `guest_email`. +* Le binder collecte chaque échec dans une seule enveloppe ; un client lit les chemins + d'argument pour les remapper vers les clés qu'il a envoyées, et compte sur une politique de + nommage cohérente sur toute l'enveloppe. +* Le binder trace déjà une frontière nette entre une erreur client (enregistrée, surfacée une + fois) et une erreur de programmation (levée), et traite un mauvais usage de son API comme + une erreur de programmation surfacée bruyamment plutôt qu'une incohérence silencieuse. +* La `WithOptions` d'instance était documentée « appelez-la avant de lier la moindre + propriété », mais rien ne l'imposait : l'ordre était une convention en prose, pas une + garantie à la compilation ni à l'exécution. +* La bibliothèque n'expose aucun état ambiant mutable ailleurs : les points d'extension de + l'horloge, de l'identifiant d'instance et des valeurs arbitraires sont tous un défaut + immuable plus un override `AsyncLocal`, scoped, réservé aux tests (ADR-0006), et le cœur + n'expose délibérément aucun état global mutable. +* `RequestBinderOptions` ne porte aucun état par requête : le provider mappe un `PropertyInfo` + vers un nom et ne dépend de rien de l'instance de requête. +* La bibliothèque est en pré-version, non publiée sur NuGet et sans consommateur externe : + déplacer l'endroit où les options sont posées n'entraîne donc aucun coût de migration en + aval. + +## Décision + +Les options du binder sont fixées une seule fois, au point d'entrée — +`Bind.WithOptions(options).PropertiesOf(request)` — avant que la moindre propriété ne soit +liée, et la possibilité de les changer une fois la liaison commencée est retirée. + +## Justification + +* Fixer les options avant que le binder n'existe rend une enveloppe incohérente impossible à + écrire plutôt que simplement déconseillée : une fois la liaison commencée, il n'existe aucun + point de la chaîne fluide où la politique de nommage puisse être échangée, donc l'enveloppe + à deux politiques décrite dans le Contexte ne peut pas survenir. Cela ferme le défaut au + niveau de la forme de l'API, dans l'esprit du canal d'erreur de programmation déjà présent + mais d'un cran plus fort — l'erreur est non-compilable, pas levée. +* Placer les options avant `PropertiesOf` plutôt qu'entre `PropertiesOf` et `FailWith` les + garde indépendantes du type de requête : une politique de nommage concerne comment une + propriété est nommée, pas quelle requête est liée, donc elle n'a pas à — et désormais ne — + dépend du `TRequest`, ce qui permet aussi de réutiliser le point d'entrée configuré d'une + requête à l'autre. +* Garder les options comme un argument explicite passé au point d'entrée, plutôt qu'un défaut + ambiant que le binder lit, est cohérent avec la position établie de la bibliothèque : une + vraie dépendance de production se passe explicitement et le cœur n'expose aucun état global + mutable (ADR-0006). Une politique de nommage est un vrai choix de production à variation + légitime : c'est donc une dépendance explicite, pas une configuration ambiante. +* Parce que les options ne portent aucun état par requête, les fixer à un point d'entrée + réutilisable laisse une application configurer la politique une fois — par exemple au + démarrage — et la réutiliser pour chaque requête sans la faire transiter par chaque liaison : + l'ergonomie que visait le setter d'instance retiré, désormais sans le piège de l'ordre. +* Le statut de pré-version signifie que la forme de l'API est arrêtée maintenant, quand il n'y + a aucun consommateur à migrer. + +## Alternatives considérées + +### Verrouiller les options à l'exécution dès la première liaison + +Considérée parce qu'elle garde la `WithOptions` d'instance et n'ajoute qu'une garde : un appel +tardif, une fois une propriété liée, lève — cohérent avec le canal d'erreur de programmation +du binder. + +Rejetée parce qu'elle détecte le mauvais usage au lieu de l'empêcher : le code de l'enveloppe +incohérente compile toujours et n'échoue qu'à l'exécution, et les options dépendent encore +inutilement du type de requête. Déplacer le setter avant `PropertiesOf` rend la même erreur +non-représentable sans coût supplémentaire. + +### Un défaut ambiant à l'échelle du processus, configuré une fois (un `Configure` statique) + +Considérée parce qu'elle laisserait `Bind.PropertiesOf(request)` ramasser une politique à +l'échelle de l'application sans rien faire transiter par les points d'appel. + +Rejetée parce qu'elle introduirait le premier état global mutable de la bibliothèque, +contredisant la position établie « aucun état ambiant mutable » (ADR-0006) : il fuirait entre +les tests exécutés en parallèle et réintroduirait un piège « configuré au mauvais moment » qui +lui est propre. L'ergonomie de configuration applicative appartient à la future intégration +ASP.NET Core, via l'injection de dépendances, où elle est sûre vis-à-vis des tests par +construction. + +### Garder le setter d'instance et documenter l'ordre plus fermement + +Considérée parce que c'est le plus petit changement. + +Rejetée parce qu'un « appelez avant de lier » en prose est exactement la convention non imposée +qui a produit le défaut ; la documentation ne peut pas rendre l'enveloppe incohérente +non-représentable. + +## Conséquences + +### Positives + +* Une seule enveloppe d'échec rapporte toujours les chemins d'argument sous une seule politique + de nommage ; l'incohérence à deux politiques est impossible à écrire. +* Les options ne dépendent plus du type de requête, et le point d'entrée configuré est + réutilisable d'une requête à l'autre — une politique configurée une fois, réutilisée par + requête. +* Le binder préserve intacte la propriété « aucun état global mutable » de la bibliothèque. + +### Négatives + +* La chaîne fluide gagne un point d'entrée distinct + (`Bind.WithOptions(...).PropertiesOf(...)`) à côté du défaut `Bind.PropertiesOf(...)`, et un + nouveau type public `ConfiguredBind` à documenter. +* Un consommateur qui posait les options après `PropertiesOf` / `FailWith` doit déplacer + l'appel avant `PropertiesOf` — un changement de source, atténué par le statut de pré-version + (aucun consommateur externe). + +### Risques + +* Un besoin futur de faire varier les options par binder imbriqué ne cadrerait pas avec le + modèle « fixé au point d'entrée » ; atténué par le fait que les binders imbriqués héritent + des options du parent par conception, et que cela est hors des exigences actuelles. + +## Actions de suivi + +* Documenter l'ergonomie de configuration applicative (injection de dépendances) lors de la + construction de l'intégration ASP.NET Core, plutôt que d'ajouter une configuration ambiante + au cœur. + +## Références + +* ADR-0006 — fournir les valeurs de test arbitraires depuis une source unique réamorçable ; la + position « aucun état ambiant mutable » que cette décision préserve. +* ADR-0007 — nommer les terminaux du binder New et Create ; une décision d'API publique sœur + sur le même binder. +* Issue #145 — le constat que cette décision résout. +* Pull request #126 — la fonctionnalité de request binder à laquelle ces options appartiennent. diff --git a/doc/handwritten/for-maintainers/adr/0012-fix-the-binder-options-before-binding-begins.md b/doc/handwritten/for-maintainers/adr/0012-fix-the-binder-options-before-binding-begins.md new file mode 100644 index 00000000..6a5a5bdf --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0012-fix-the-binder-options-before-binding-begins.md @@ -0,0 +1,139 @@ +# ADR-0012 | Fix the binder options before binding begins + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0012-fix-the-binder-options-before-binding-begins.fr.md) + +**Status:** Accepted +**Date:** 2026-07-18 +**Decision Makers:** Reefact + +## Context + +* The binder resolves each bound property's argument path — the key reported in error + paths, such as `GuestEmail` or `Stay.CheckIn` — through the `IArgumentNameProvider` + carried by `RequestBinderOptions`. +* Before this decision, options were set through an instance method `WithOptions` on + `RequestBinder`, callable at any point in the fluent chain, including after + some properties had already been bound. +* Each property binding reads the options in effect at the moment it is bound, so + changing the provider between two bindings produces argument paths under two different + naming policies within one failure envelope — for example `GuestEmail` beside + `guest_email`. +* The binder collects every failure into a single envelope; a client reads the argument + paths to map them back to the keys it sent, and relies on one consistent naming policy + across that envelope. +* The binder already draws a hard line between a client error (recorded, surfaced once) + and a programming error (thrown), and treats a misuse of its API as a programming + error surfaced loudly rather than a silent inconsistency. +* The instance `WithOptions` was documented "call before binding any property", but + nothing enforced it: the ordering was a prose convention, not a compile-time or + runtime guarantee. +* The library exposes no ambient mutable state elsewhere: the clock, instance-id and + arbitrary-value seams are all an immutable default plus an `AsyncLocal`, scoped, + test-only override (ADR-0006), and the core deliberately exposes zero global mutable + state. +* `RequestBinderOptions` carries no per-request state: the provider maps a + `PropertyInfo` to a name and depends on nothing about the request instance. +* The library is pre-release, unpublished on NuGet with no external consumers, so moving + where options are set carries no downstream migration cost. + +## Decision + +The request binder's options are fixed once, at the entry point — +`Bind.WithOptions(options).PropertiesOf(request)` — before any property is bound, and +the ability to change them after binding has started is removed. + +## Rationale + +* Fixing the options before the binder exists makes an inconsistent envelope impossible + to write rather than merely discouraged: once binding has begun there is no point in + the fluent chain at which the naming policy can be swapped, so the two-policy envelope + the Context describes cannot arise. This closes the defect at the API shape, in the + spirit of the binder's existing programming-error channel but one step stronger — the + mistake is uncompilable, not thrown. +* Placing the options before `PropertiesOf` rather than between `PropertiesOf` and + `FailWith` keeps them independent of the request type: a naming policy is about how a + property is named, not which request is bound, so it need not — and now does not — + depend on `TRequest`, which is also what lets the configured entry point be reused + across requests. +* Keeping options an explicit argument passed at the entry point, rather than an ambient + default the binder reads, is consistent with the library's settled stance that a + genuine production dependency is passed explicitly and the core exposes no global + mutable state (ADR-0006): a naming policy is a real production choice with legitimate + variation, so it is an explicit dependency, not ambient configuration. +* Because the options carry no per-request state, fixing them at a reusable entry point + lets an application configure the policy once — for example at startup — and reuse it + for every request without threading it through each binding: the ergonomic the removed + instance setter reached for, now without the ordering hazard. +* The pre-release status means the API shape is settled now, when there are no consumers + to migrate. + +## Alternatives Considered + +### Lock the options at runtime on the first binding + +Considered because it keeps the existing instance `WithOptions` and only adds a guard: a +late call, once a property is bound, throws — consistent with the binder's +programming-error channel. + +Rejected because it detects the misuse instead of preventing it: the inconsistent-envelope +code still compiles and only fails at runtime, and the options still needlessly depend on +the request type. Moving the setter before `PropertiesOf` makes the same mistake +unrepresentable at no extra cost. + +### A process-wide ambient default configured once (a static `Configure`) + +Considered because it would let `Bind.PropertiesOf(request)` pick up an application-wide +policy with nothing threaded through call sites at all. + +Rejected because it would introduce the first piece of global mutable state in the +library, contradicting the settled no-ambient-mutable-state stance (ADR-0006): it would +leak across tests running in parallel and reintroduce a "configured at the wrong time" +hazard of its own. The application-wide configuration ergonomic belongs in the future +ASP.NET Core integration, through dependency injection, where it is test-safe by +construction. + +### Keep the instance setter and document the ordering more firmly + +Considered because it is the smallest change. + +Rejected because a prose "call before binding" is exactly the unenforced convention that +produced the defect; documentation cannot make the inconsistent envelope unrepresentable. + +## Consequences + +### Positive + +* A single failure envelope always reports argument paths under one naming policy; the + two-policy inconsistency is impossible to write. +* Options no longer depend on the request type, and the configured entry point is + reusable across requests — one policy configured once, reused per request. +* The binder keeps the library's no-global-mutable-state property intact. + +### Negative + +* The fluent chain gains a distinct entry point (`Bind.WithOptions(...).PropertiesOf(...)`) + beside the default `Bind.PropertiesOf(...)`, and a new public `ConfiguredBind` type to + document. +* A consumer who set options after `PropertiesOf` / `FailWith` must move the call before + `PropertiesOf` — a source change, mitigated by the pre-release status (no external + consumers). + +### Risks + +* A future need to vary options per nested binder would not fit the "fixed at the entry + point" model; mitigated by nested binders inheriting the parent options by design, and + by this being outside the current requirements. + +## Follow-up Actions + +* Document the application-level configuration ergonomic (dependency injection) when the + ASP.NET Core integration is built, rather than adding ambient configuration to the core. + +## References + +* ADR-0006 — supply arbitrary test values from a single seedable source; the + no-ambient-mutable-state stance this decision keeps. +* ADR-0007 — name the binder terminals New and Create; a sibling public-API decision on + the same binder. +* Issue #145 — the finding this decision resolves. +* Pull request #126 — the request binder feature these options belong to. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index f4ebb8cc..f1ad0add 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -185,3 +185,4 @@ Optional supporting material: | [ADR-0009](0009-report-the-toolings-failures-as-first-class-errors.md) | Report the tooling's failures as first-class errors | Accepted | | [ADR-0010](0010-treat-gendocs-error-catalog-as-a-versioned-contract.md) | Treat GenDoc's error catalog as a versioned contract | Accepted | | [ADR-0011](0011-host-dummies-as-a-standalone-package.md) | Host Dummies as a standalone package in this repository | Proposed | +| [ADR-0012](0012-fix-the-binder-options-before-binding-begins.md) | Fix the binder options before binding begins | Accepted | diff --git a/doc/handwritten/for-users/RequestBinder.en.md b/doc/handwritten/for-users/RequestBinder.en.md index 9323ecdf..b0dd0826 100644 --- a/doc/handwritten/for-users/RequestBinder.en.md +++ b/doc/handwritten/for-users/RequestBinder.en.md @@ -379,16 +379,19 @@ public sealed class SnakeCaseNames : IArgumentNameProvider { ToSnakeCase(property.Name); // GuestEmail -> guest_email } -var bind = Bind.PropertiesOf(request) - .FailWith(PlaceBookingError.Invalid) - .WithOptions(new RequestBinderOptions(new SnakeCaseNames())); +var bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNames())) + .PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid); ``` -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. +Options are chosen **once**, on `Bind.WithOptions`, before the binder even exists — +so a binder's naming policy can never change mid-binding. They 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 entry +point `Bind.WithOptions(...)` returns carries no per-request state, so you can build +it once (for example at application startup) and reuse it for every request. 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 diff --git a/doc/handwritten/for-users/RequestBinder.fr.md b/doc/handwritten/for-users/RequestBinder.fr.md index 2daf292b..f86dac01 100644 --- a/doc/handwritten/for-users/RequestBinder.fr.md +++ b/doc/handwritten/for-users/RequestBinder.fr.md @@ -391,17 +391,21 @@ public sealed class SnakeCaseNames : IArgumentNameProvider { ToSnakeCase(property.Name); // GuestEmail -> guest_email } -var bind = Bind.PropertiesOf(request) - .FailWith(PlaceBookingError.Invalid) - .WithOptions(new RequestBinderOptions(new SnakeCaseNames())); +var bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNames())) + .PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid); ``` -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. +Les options sont choisies **une seule fois**, sur `Bind.WithOptions`, avant même que +le binder n’existe — la politique de nommage d’un binder ne peut donc jamais changer +en cours de liaison. Elles 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. Le point d’entrée +renvoyé par `Bind.WithOptions(...)` ne porte aucun état par requête : vous pouvez le +construire une fois (par exemple au démarrage de l’application) et le réutiliser pour +chaque requête. 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é