Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ public void GuardClauses() {

Check.ThatCode(() => Bind.PropertiesOf<BookingRequest>(null!)).Throws<ArgumentNullException>();
Check.ThatCode(() => Bind.PropertiesOf(Request()).FailWith(null!)).Throws<ArgumentNullException>();
Check.ThatCode(() => bind.WithOptions(null!)).Throws<ArgumentNullException>();
Check.ThatCode(() => Bind.WithOptions(null!)).Throws<ArgumentNullException>();
Check.ThatCode(() => bind.New<string>(null!)).Throws<ArgumentNullException>();
Check.ThatCode(() => bind.Create<string>(null!)).Throws<ArgumentNullException>();

Expand Down
6 changes: 3 additions & 3 deletions FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
23 changes: 20 additions & 3 deletions FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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))
Expand Down
21 changes: 19 additions & 2 deletions FirstClassErrors.RequestBinder/Bind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public static class Bind {
#region Statics members declarations

/// <summary>
/// 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
/// <see cref="RequestBinderEnvelopeStage{TRequest}.FailWith" />.
/// </summary>
/// <typeparam name="TRequest">The type of the request DTO.</typeparam>
Expand All @@ -31,7 +32,23 @@ public static class Bind {
public static RequestBinderEnvelopeStage<TRequest> PropertiesOf<TRequest>(TRequest request) {
if (request is null) { throw new ArgumentNullException(nameof(request)); }

return new RequestBinderEnvelopeStage<TRequest>(request);
return new RequestBinderEnvelopeStage<TRequest>(request, RequestBinderOptions.Default);
}

/// <summary>
/// Fixes the binding options — for example a serializer-aware <see cref="IArgumentNameProvider" /> — before
/// any property is bound, then starts binding with <see cref="ConfiguredBind.PropertiesOf{TRequest}" />. 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.
/// </summary>
/// <param name="options">The options every binding started from the returned entry point (and its nested binders) binds with.</param>
/// <returns>An options-configured entry point offering <see cref="ConfiguredBind.PropertiesOf{TRequest}" />.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options" /> is <c>null</c>.</exception>
public static ConfiguredBind WithOptions(RequestBinderOptions options) {
if (options is null) { throw new ArgumentNullException(nameof(options)); }

return new ConfiguredBind(options);
}

#endregion
Expand Down
39 changes: 39 additions & 0 deletions FirstClassErrors.RequestBinder/ConfiguredBind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace FirstClassErrors.RequestBinder;

/// <summary>
/// An options-configured request-binding entry point, produced by <see cref="Bind.WithOptions" />. It fixes the
/// <see cref="RequestBinderOptions" /> 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.
/// </summary>
public sealed class ConfiguredBind {

#region Fields declarations

private readonly RequestBinderOptions _options;

#endregion

#region Constructors declarations

internal ConfiguredBind(RequestBinderOptions options) {
_options = options;
}

#endregion

/// <summary>
/// Starts binding the properties of a request DTO with the configured options. Declare the failure envelope
/// next, with <see cref="RequestBinderEnvelopeStage{TRequest}.FailWith" />.
/// </summary>
/// <typeparam name="TRequest">The type of the request DTO.</typeparam>
/// <param name="request">The request DTO to bind.</param>
/// <returns>The stage on which the failure envelope is declared.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="request" /> is <c>null</c>.</exception>
public RequestBinderEnvelopeStage<TRequest> PropertiesOf<TRequest>(TRequest request) {
if (request is null) { throw new ArgumentNullException(nameof(request)); }

return new RequestBinderEnvelopeStage<TRequest>(request, _options);
}

}
20 changes: 2 additions & 18 deletions FirstClassErrors.RequestBinder/RequestBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ internal RequestBinder(TRequest request, Func<PrimaryPortInnerErrors, PrimaryPor

#endregion

/// <summary>The options this binder (and every binder nested under it) binds with.</summary>
internal RequestBinderOptions Options { get; private set; }
/// <summary>The options this binder (and every binder nested under it) binds with; fixed before binding begins.</summary>
internal RequestBinderOptions Options { get; }

/// <summary>
/// The envelope instance the most recent failing build terminal
Expand All @@ -61,22 +61,6 @@ internal RequestBinder(TRequest request, Func<PrimaryPortInnerErrors, PrimaryPor
/// </summary>
internal PrimaryPortError? BuiltEnvelope { get; private set; }

/// <summary>
/// Replaces the binder options (for example to plug a serializer-aware
/// <see cref="IArgumentNameProvider" />). Call it before binding any property; nested binders inherit the
/// options in effect when they are created.
/// </summary>
/// <param name="options">The options to bind with.</param>
/// <returns>This binder, for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options" /> is <c>null</c>.</exception>
public RequestBinder<TRequest> WithOptions(RequestBinderOptions options) {
if (options is null) { throw new ArgumentNullException(nameof(options)); }

Options = options;

return this;
}

/// <summary>
/// Selects a scalar property, converted by a plain value-object converter
/// (<c>Func&lt;TArgument, Outcome&lt;T&gt;&gt;</c>).
Expand Down
8 changes: 5 additions & 3 deletions FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ public sealed class RequestBinderEnvelopeStage<TRequest> {

#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
Expand All @@ -33,7 +35,7 @@ internal RequestBinderEnvelopeStage(TRequest request) {
public RequestBinder<TRequest> FailWith(Func<PrimaryPortInnerErrors, PrimaryPortError> envelope) {
if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); }

return new RequestBinder<TRequest>(_request, envelope, RequestBinderOptions.Default, argumentPrefix: null);
return new RequestBinder<TRequest>(_request, envelope, _options, argumentPrefix: null);
}

}
6 changes: 3 additions & 3 deletions FirstClassErrors.RequestBinder/RequestBinderOptions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
namespace FirstClassErrors.RequestBinder;

/// <summary>
/// The binding options of a <see cref="RequestBinder{TRequest}" />. Options are per-binder — passed through
/// <see cref="RequestBinder{TRequest}.WithOptions" /> and inherited by nested bindersnever global mutable
/// state.
/// The binding options of a <see cref="RequestBinder{TRequest}" />. Options are fixed once, before binding
/// begins — through <see cref="Bind.WithOptions" /> and inherited by nested binders; they are never global
/// mutable state and can never change while a binder is binding.
/// </summary>
public sealed class RequestBinderOptions {

Expand Down
Original file line number Diff line number Diff line change
@@ -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<TRequest>`, 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.
Loading