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
106 changes: 106 additions & 0 deletions FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#region Usings declarations

using NFluent;

#endregion

namespace FirstClassErrors.RequestBinder.UnitTests;

/// <summary>
/// The application-wide <see cref="RequestBinderOptions.Default" />: it is what <see cref="Bind.PropertiesOf{TRequest}" />
/// binds with, configurable once at startup and frozen on first use. These tests inject it through the scoped,
/// parallel-safe test seam (<c>OverrideDefaultForTests</c>) so they never mutate the process default — the binder
/// suite keeps seeing the built-in default.
/// </summary>
public sealed class DefaultOptionsTests {

private static BookingRequest MissingEmail() {
return new BookingRequest(null, "R", null, null, null, null, null);
}

private static Error BindMissingEmail(RequestBinderEnvelopeStage<BookingRequest> start) {
var bind = start.FailWith(BookingEnvelopeError.CommandInvalid);
bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);

return bind.New(_ => "x").Error!.InnerErrors.Single();
}

// ── Bind.PropertiesOf binds with the configured default ───────────────────────────────────────────────

[Fact(DisplayName = "Bind.PropertiesOf binds with the configured default options — naming and structural codes — without WithOptions.")]
public void BindPropertiesOfUsesTheConfiguredDefault() {
var configured = new RequestBinderOptions(new SnakeCaseNameProvider(),
ErrorCode.Create("ACME_ARGUMENT_REQUIRED"),
ErrorCode.Create("ACME_ARGUMENT_INVALID"));

using (RequestBinderOptions.OverrideDefaultForTests(configured)) {
Error error = BindMissingEmail(Bind.PropertiesOf(MissingEmail()));

Check.That(error.Code.ToString()).IsEqualTo("ACME_ARGUMENT_REQUIRED");
Check.That(BindingAssertions.ArgumentPathOf(error)).IsEqualTo("guest_email");
}
}

[Fact(DisplayName = "Outside the configured scope, Bind.PropertiesOf falls back to the built-in default.")]
public void OutsideScopeFallsBackToBuiltIn() {
using (RequestBinderOptions.OverrideDefaultForTests(new RequestBinderOptions(new SnakeCaseNameProvider()))) { }

Error error = BindMissingEmail(Bind.PropertiesOf(MissingEmail()));

Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(BindingAssertions.ArgumentPathOf(error)).IsEqualTo("GuestEmail");
}

[Fact(DisplayName = "A per-call Bind.WithOptions overrides the configured default.")]
public void WithOptionsWinsOverTheConfiguredDefault() {
var appDefault = new RequestBinderOptions(new SnakeCaseNameProvider(),
ErrorCode.Create("DEFAULT_REQUIRED"),
ErrorCode.Create("DEFAULT_INVALID"));
var perCall = new RequestBinderOptions(new SnakeCaseNameProvider(),
ErrorCode.Create("PERCALL_REQUIRED"),
ErrorCode.Create("PERCALL_INVALID"));

using (RequestBinderOptions.OverrideDefaultForTests(appDefault)) {
Error error = BindMissingEmail(Bind.WithOptions(perCall).PropertiesOf(MissingEmail()));

Check.That(error.Code.ToString()).IsEqualTo("PERCALL_REQUIRED");
}
}

// ── The default resolves to the built-in when unconfigured ────────────────────────────────────────────

[Fact(DisplayName = "Unconfigured, RequestBinderOptions.Default is the built-in default (default structural codes).")]
public void DefaultIsBuiltInWhenUnconfigured() {
Check.That(RequestBinderOptions.Default.ArgumentRequiredCode == RequestBindingError.DefaultArgumentRequiredCode).IsTrue();
Check.That(RequestBinderOptions.Default.ArgumentInvalidCode == RequestBindingError.DefaultArgumentInvalidCode).IsTrue();
}

// ── Setter contract: null-rejecting and frozen-after-first-use ────────────────────────────────────────

[Fact(DisplayName = "Setting RequestBinderOptions.Default to null throws ArgumentNullException.")]
public void SettingNullThrows() {
Check.ThatCode(() => RequestBinderOptions.Default = null!).Throws<ArgumentNullException>();
}

[Fact(DisplayName = "Configuring RequestBinderOptions.Default after the first bind has read it throws — the default is frozen.")]
public void ConfiguringAfterFirstUseThrows() {
_ = RequestBinderOptions.Default; // reading it (as the first bind does) freezes it; idempotent

Check.ThatCode(() => RequestBinderOptions.Default = new RequestBinderOptions(new SnakeCaseNameProvider()))
.Throws<InvalidOperationException>();
}

[Fact(DisplayName = "The test seam rejects a null options.")]
public void OverrideForTestsRejectsNull() {
Check.ThatCode(() => RequestBinderOptions.OverrideDefaultForTests(null!)).Throws<ArgumentNullException>();
}

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()));
}

}

}
92 changes: 87 additions & 5 deletions FirstClassErrors.RequestBinder/RequestBinderOptions.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,76 @@
namespace FirstClassErrors.RequestBinder;

/// <summary>
/// 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.
/// The binding options of a <see cref="RequestBinder{TRequest}" />. A binder's options are fixed once, before
/// binding begins — through <see cref="Bind.WithOptions" /> or the application-wide <see cref="Default" /> — and
/// inherited by nested binders; they never change while a binder is binding. The process-wide
/// <see cref="Default" /> may be configured once at application startup and is frozen on first use.
/// </summary>
public sealed class RequestBinderOptions {

#region Statics members declarations

/// <summary>The default options: argument names are the C# property names, structural codes are the defaults.</summary>
public static RequestBinderOptions Default { get; } = new(new DefaultArgumentNameProvider());
private static readonly RequestBinderOptions BuiltIn = new(new DefaultArgumentNameProvider());
private static readonly AsyncLocal<RequestBinderOptions?> TestOverride = new();
private static readonly object Gate = new();
private static RequestBinderOptions _default = BuiltIn;
private static bool _frozen;

/// <summary>
/// The application-wide default options that <see cref="Bind.PropertiesOf{TRequest}" /> binds with. Assign it
/// once at application startup — before any binding — to configure the binder host-wide without threading
/// options through every call; a per-call <see cref="Bind.WithOptions" /> still overrides it. The first bind
/// reads it and thereby <b>freezes</b> it, so the default cannot drift once binding has begun. Defaults to the
/// built-in options (C# property names and the default structural codes).
/// </summary>
/// <exception cref="ArgumentNullException">Thrown when set to <c>null</c>.</exception>
/// <exception cref="InvalidOperationException">Thrown when set after the first bind has already read it.</exception>
public static RequestBinderOptions Default {
get {
RequestBinderOptions? overridden = TestOverride.Value;
if (overridden is not null) { return overridden; }

// Freeze and read under the gate: a concurrent set() either wins entirely (before any read froze the
// default) or observes the freeze and throws — never a torn "first bind on the old default, later binds
// on the new one". The gate is uncontended once the default is configured at startup.
lock (Gate) {
_frozen = true;

return _default;
}
}
set {
if (value is null) { throw new ArgumentNullException(nameof(value)); }

lock (Gate) {
if (_frozen) {
throw new InvalidOperationException(
"RequestBinderOptions.Default was already read by a binding; configure it once at application startup, before the first bind.");
}

_default = value;
}
}
}

/// <summary>
/// Test-only seam: overrides <see cref="Default" /> for the current execution context until the returned scope
/// is disposed. Backed by an <see cref="AsyncLocal{T}" />, so it flows with the test's context and never leaks
/// across tests running in parallel — the same pattern as the ambient clock. It does not touch the production
/// default and never freezes it. Internal, for the library's own tests; a consumer-facing seam belongs in a
/// dedicated testing package.
/// </summary>
/// <param name="options">The options to bind with while the scope is active.</param>
/// <returns>A scope that restores the previous override when disposed.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options" /> is <c>null</c>.</exception>
internal static IDisposable OverrideDefaultForTests(RequestBinderOptions options) {
if (options is null) { throw new ArgumentNullException(nameof(options)); }

RequestBinderOptions? previous = TestOverride.Value;
TestOverride.Value = options;

return new TestOverrideScope(previous);
}

#endregion

Expand Down Expand Up @@ -47,4 +107,26 @@ public RequestBinderOptions(IArgumentNameProvider argumentNameProvider, ErrorCod
/// <summary>The code the binder raises when an argument is present but fails to convert (defaults to <c>REQUEST_ARGUMENT_INVALID</c>).</summary>
public ErrorCode ArgumentInvalidCode { get; }

#region Nested types

private sealed class TestOverrideScope : IDisposable {

private readonly RequestBinderOptions? _previous;
private bool _disposed;

internal TestOverrideScope(RequestBinderOptions? previous) {
_previous = previous;
}

public void Dispose() {
if (_disposed) { return; }

_disposed = true;
TestOverride.Value = _previous;
}

}

#endregion

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# ADR-0017 | Fournir un défaut d'options configurable à l'échelle de l'application

🌍 🇬🇧 [English](0017-provide-a-configurable-application-wide-default-for-the-binder-options.md) · 🇫🇷 Français (ce fichier)

**Statut :** Accepté
**Date :** 2026-07-18
**Décideurs :** Reefact

## Contexte

* `Bind.PropertiesOf(request)` lie avec `RequestBinderOptions.Default`. Lier avec des
options personnalisées exige sinon `Bind.WithOptions(options).PropertiesOf(...)` — faire
transiter le point d'entrée configuré par chaque appel, ou le résoudre depuis un
conteneur DI.
* Un hôte sans conteneur DI — une CLI, un worker, un petit outil — n'a aucun moyen
host-agnostic de poser un défaut applicatif que le simple `Bind.PropertiesOf` ramasse.
* L'ADR-0012 a fixé les options d'un binder à son point d'entrée (aucun changement une
fois la liaison commencée) et, parmi ses alternatives rejetées, a écarté « un défaut
ambiant à l'échelle du processus, configuré une fois (un `Configure` statique) » au
motif qu'il introduirait un état global mutable, fuirait entre les tests exécutés en
parallèle, et pourrait être configuré au mauvais moment.
* `RequestBinderOptions` est une valeur immuable : une instance partagée ne porte aucun
état de settings mutable, donc le hasard classique — muter un objet de settings partagé
pendant qu'il est utilisé — ne s'y applique pas.
* La convention .NET est partagée : `JsonConvert.DefaultSettings` de `Newtonsoft.Json` est
un global librement re-posable ; `JsonSerializerOptions` de `System.Text.Json` devient
immuable à la première utilisation (gelé) et se configure par-instance ou via DI.
* La bibliothèque n'expose aucun autre état ambiant mutable : les points d'extension de
l'horloge, de l'identifiant d'instance et des valeurs arbitraires sont des défauts
immuables avec un override `AsyncLocal`, scoped, réservé aux tests (ADR-0006).
* La bibliothèque est en pré-version, non publiée sur NuGet et sans consommateur externe.

## Décision

`RequestBinderOptions.Default` — les options avec lesquelles `Bind.PropertiesOf` lie —
est un défaut applicatif posable, configuré une fois au démarrage de l'application et gelé
à la première liaison qui le lit.

## Justification

* Un défaut processus posable est le seul moyen host-agnostic pour que le simple
`Bind.PropertiesOf` ramasse une politique applicative sans conteneur DI, ce dont une CLI
ou un worker a besoin ; le point d'entrée favorable à la DI (`Bind.WithOptions`) reste
disponible là où un conteneur existe.
* Le hasard contre lequel l'ADR-0012 se prémunissait — un défaut ambiant qui dérive à
l'exécution — est supprimé par le gel à la première utilisation : dès que la première
liaison le lit, une réaffectation lève, donc c'est un choix au moment de la composition
qui ne peut pas changer une fois les requêtes en vol. C'est la discipline de
`System.Text.Json` (immuable une fois utilisé), pas celle, librement mutable, de
`JsonConvert.DefaultSettings`.
* Parce que `RequestBinderOptions` est immuable, un défaut partagé ne porte aucun état de
settings mutable, donc le piège classique des settings globaux ne s'applique pas ; le
seul état global est vers quelles options immuables pointe le défaut, fixé une fois.
* Garder la configuration sur `RequestBinderOptions.Default` plutôt que sur une méthode de
`Bind` laisse le point d'entrée de liaison sans surface de configuration : un
développeur qui lie des requêtes ne voit que des verbes de liaison.
* La préoccupation d'isolation des tests parallèles soulevée par l'ADR-0012 se limite aux
tests de la bibliothèque elle-même, et est satisfaite par un override scoped réservé aux
tests (un `AsyncLocal`) — le même patron que l'horloge (ADR-0006) — qui ne touche ni ne
gèle jamais le défaut de production.
* Le statut de pré-version signifie que la surface est arrêtée maintenant, quand il n'y a
aucun consommateur à migrer.

## Alternatives considérées

### Garder les options au seul point d'entrée (le statu quo de l'ADR-0012)

Considérée parce qu'elle est déjà livrée et n'a aucun état global.

Rejetée parce qu'elle n'offre aucun défaut applicatif host-agnostic : chaque point d'appel
doit faire transiter le point d'entrée configuré, ou un conteneur DI doit le fournir —
indisponible pour une CLI ou un worker qui veut configurer le binder une fois.

### Un défaut global librement re-posable (le modèle JsonConvert.DefaultSettings)

Considérée parce que c'est le global posable le plus simple et une convention répandue.

Rejetée parce qu'un défaut réaffectable pendant que les requêtes sont en vol peut dériver,
réintroduisant le hasard de configuration à l'exécution contre lequel l'ADR-0012
prévenait. Le gel à la première utilisation garde l'ergonomie tout en supprimant la
dérive.

### L'injection de dépendances seule (le modèle System.Text.Json / ASP.NET)

Considérée parce que c'est l'idiome moderne là où un conteneur existe, et pleinement
sûre vis-à-vis des tests.

Rejetée comme unique mécanisme parce qu'elle n'est pas host-agnostic : une CLI, un worker,
ou tout hôte sans conteneur DI ne peut pas s'en servir pour que le simple
`Bind.PropertiesOf` ramasse un défaut applicatif. Le point d'entrée injecté reste
disponible ; cette décision ajoute le chemin sans conteneur.

## Conséquences

### Positives

* N'importe quel hôte — avec ou sans DI — configure la politique de nommage et les codes
structurels du binder une fois au démarrage, et le simple `Bind.PropertiesOf` les
utilise.
* Le gel à la première utilisation empêche la dérive à l'exécution ; le seul global mutable
est une référence d'options immuables posée une fois.
* La surface de `Bind` reste sans configuration ; un `Bind.WithOptions` par appel surcharge
quand même le défaut.

### Négatives

* La bibliothèque gagne un état global de processus (le défaut posable) — le premier de la
bibliothèque, accepté délibérément pour l'ergonomie host-agnostic.
* Les tests de la bibliothèque ont besoin d'un seam d'override scoped réservé aux tests pour
rester parallèle-safe ; le défaut de production n'est pas directement posable dans une
suite parallèle.

### Risques

* Un consommateur qui lit `RequestBinderOptions.Default` avant de le configurer le gèle et
ne peut alors plus le configurer ; atténué par le diagnostic du setter qui lève
(« configurez au démarrage, avant la première liaison ») et par la documentation.

## Actions de suivi

* Faire apparaître le seam d'override de test aux consommateurs via un paquet de test dédié
si une demande apparaît (il est actuellement interne, pour les tests de la bibliothèque).

## Références

* ADR-0012 — fixer les options du binder avant le début de la liaison ; cette décision
revisite le défaut ambiant à l'échelle du processus que l'ADR-0012 avait pesé puis
rejeté comme alternative, en l'adoptant avec des garde-fous. La décision propre à
l'ADR-0012 — les options d'un binder sont fixées à son point d'entrée — est inchangée,
donc l'ADR-0012 n'est pas supersédé.
* ADR-0006 — fournir les valeurs de test arbitraires depuis une source unique réamorçable ;
le patron de seam de test `AsyncLocal` que cette décision réutilise pour ses tests.
* Issue #181 — la demande que cette décision résout.
* `JsonConvert.DefaultSettings` (Newtonsoft.Json) et `JsonSerializerOptions`
(System.Text.Json) — les deux conventions pesées.
Loading