From 2a79a3139ff01a170dc43ce3636d441dcfcab201 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 12:03:21 +0000 Subject: [PATCH 1/4] feat(binder): configure a host-agnostic default for Bind.PropertiesOf Bind.PropertiesOf binds with RequestBinderOptions.Default, which was a fixed built-in. Make it a settable application default, configured once at startup (no DI container needed, so a CLI or worker can set it) and frozen on first use: the first bind reads it, and a later assignment throws, so it cannot drift once requests are flowing (the JsonSerializerOptions discipline). Bind gains no configuration surface; a per-call Bind.WithOptions still overrides it. A scoped, test-only override (AsyncLocal) keeps the suite parallel-safe. Refs: #181 --- .../DefaultOptionsTests.cs | 106 ++++++++++++++++++ .../RequestBinderOptions.cs | 83 +++++++++++++- doc/handwritten/for-users/RequestBinder.en.md | 37 ++++-- doc/handwritten/for-users/RequestBinder.fr.md | 26 ++++- 4 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs diff --git a/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs b/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs new file mode 100644 index 00000000..cdf0d666 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs @@ -0,0 +1,106 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +/// +/// The application-wide : it is what +/// binds with, configurable once at startup and frozen on first use. These tests inject it through the scoped, +/// parallel-safe test seam (OverrideDefaultForTests) so they never mutate the process default — the binder +/// suite keeps seeing the built-in default. +/// +public sealed class DefaultOptionsTests { + + private static BookingRequest MissingEmail() { + return new BookingRequest(null, "R", null, null, null, null, null); + } + + private static Error BindMissingEmail(RequestBinderEnvelopeStage 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(); + } + + [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(); + } + + [Fact(DisplayName = "The test seam rejects a null options.")] + public void OverrideForTestsRejectsNull() { + Check.ThatCode(() => RequestBinderOptions.OverrideDefaultForTests(null!)).Throws(); + } + + 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/RequestBinderOptions.cs b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs index a983a17f..2a6b1df0 100644 --- a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs +++ b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs @@ -1,16 +1,67 @@ namespace FirstClassErrors.RequestBinder; /// -/// 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. +/// The binding options of a . A binder's options are fixed once, before +/// binding begins — through or the application-wide — and +/// inherited by nested binders; they never change while a binder is binding. The process-wide +/// may be configured once at application startup and is frozen on first use. /// public sealed class RequestBinderOptions { #region Statics members declarations - /// The default options: argument names are the C# property names, structural codes are the defaults. - public static RequestBinderOptions Default { get; } = new(new DefaultArgumentNameProvider()); + private static readonly RequestBinderOptions BuiltIn = new(new DefaultArgumentNameProvider()); + private static readonly AsyncLocal TestOverride = new(); + private static RequestBinderOptions _default = BuiltIn; + private static bool _frozen; + + /// + /// The application-wide default options that 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 still overrides it. The first bind + /// reads it and thereby freezes it, so the default cannot drift once binding has begun. Defaults to the + /// built-in options (C# property names and the default structural codes). + /// + /// Thrown when set to null. + /// Thrown when set after the first bind has already read it. + public static RequestBinderOptions Default { + get { + RequestBinderOptions? overridden = TestOverride.Value; + if (overridden is not null) { return overridden; } + + _frozen = true; + + return _default; + } + set { + if (value is null) { throw new ArgumentNullException(nameof(value)); } + 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; + } + } + + /// + /// Test-only seam: overrides for the current execution context until the returned scope + /// is disposed. Backed by an , 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. + /// + /// The options to bind with while the scope is active. + /// A scope that restores the previous override when disposed. + /// Thrown when is null. + 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 @@ -47,4 +98,26 @@ public RequestBinderOptions(IArgumentNameProvider argumentNameProvider, ErrorCod /// The code the binder raises when an argument is present but fails to convert (defaults to REQUEST_ARGUMENT_INVALID). 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 + } diff --git a/doc/handwritten/for-users/RequestBinder.en.md b/doc/handwritten/for-users/RequestBinder.en.md index 2e9af29f..197f3d05 100644 --- a/doc/handwritten/for-users/RequestBinder.en.md +++ b/doc/handwritten/for-users/RequestBinder.en.md @@ -389,13 +389,13 @@ var bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNames())) ``` 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. +so a binder's naming policy can never change mid-binding. They are fixed for the life +of a binding, 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. ## Structural error codes @@ -428,6 +428,29 @@ or, when you keep the defaults, the ones the binder exposes. if (error.Code == RequestBindingError.DefaultArgumentRequiredCode) { return 422; } ``` +## Configuring the default for the whole application + +`Bind.PropertiesOf(request)` binds with `RequestBinderOptions.Default`. That default is +configurable **once, at application startup** — so a whole host (ASP.NET, a CLI, a +worker) shares one naming policy and one set of structural codes without threading +options through every call, and without a DI container: + +```csharp +// Program.cs, before the first bind: +RequestBinderOptions.Default = new RequestBinderOptions( + new SnakeCaseNames(), + argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"), + argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID")); + +// anywhere after that — no options threaded through: +var bind = Bind.PropertiesOf(request).FailWith(PlaceBookingError.Invalid); +``` + +The default is **frozen on first use**: the first bind reads it, and any later +assignment throws — so it is set at composition time and can never drift once requests +are flowing (the same discipline as `JsonSerializerOptions`). A per-call +`Bind.WithOptions(...)` still overrides it for a single binding. + ## The bug channel: what throws vs what is collected The binder draws a hard line between a **client error** (recorded, surfaced once diff --git a/doc/handwritten/for-users/RequestBinder.fr.md b/doc/handwritten/for-users/RequestBinder.fr.md index 6cae81e2..8408b2a9 100644 --- a/doc/handwritten/for-users/RequestBinder.fr.md +++ b/doc/handwritten/for-users/RequestBinder.fr.md @@ -402,7 +402,7 @@ var bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNames())) 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 +en cours de liaison. Elles sont fixées pour toute la durée d’une liaison, 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 @@ -443,6 +443,30 @@ expose. if (error.Code == RequestBindingError.DefaultArgumentRequiredCode) { return 422; } ``` +## Configurer le défaut pour toute l’application + +`Bind.PropertiesOf(request)` lie avec `RequestBinderOptions.Default`. Ce défaut est +configurable **une seule fois, au démarrage de l’application** — un hôte entier +(ASP.NET, une CLI, un worker) partage ainsi une politique de nommage et un jeu de codes +structurels sans faire transiter d’options par chaque appel, et sans conteneur DI : + +```csharp +// Program.cs, avant la première liaison : +RequestBinderOptions.Default = new RequestBinderOptions( + new SnakeCaseNames(), + argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"), + argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID")); + +// n’importe où ensuite — aucune option à faire transiter : +var bind = Bind.PropertiesOf(request).FailWith(PlaceBookingError.Invalid); +``` + +Le défaut est **gelé à la première utilisation** : la première liaison le lit, et toute +affectation ultérieure lève — il est donc défini au moment de la composition et ne peut +jamais dériver une fois les requêtes en vol (la même discipline que +`JsonSerializerOptions`). Un `Bind.WithOptions(...)` par appel le surcharge quand même +pour une liaison donnée. + ## 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, From 378f6ecc238dafb5f399fee7efc35b43015b028f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 12:03:21 +0000 Subject: [PATCH 2/4] docs(binder): draft ADR-0017 for the app-wide default options Record the decision behind #181: RequestBinderOptions.Default is a settable application default, configured once at startup and frozen on first use. It revisits the process-wide ambient default that ADR-0012 had weighed and rejected as an alternative, adopting it with mitigations; ADR-0012's own entry-point-fix decision is unchanged, so ADR-0012 is not superseded. Drafted as Proposed and indexed (EN + FR); the maintainer decides its status. Refs: #181 --- ...-wide-default-for-the-binder-options.fr.md | 135 ++++++++++++++++++ ...ion-wide-default-for-the-binder-options.md | 130 +++++++++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 3 files changed, 266 insertions(+) create mode 100644 doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.md diff --git a/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.fr.md b/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.fr.md new file mode 100644 index 00000000..384bd3b7 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.fr.md @@ -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 :** Proposé +**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. diff --git a/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.md b/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.md new file mode 100644 index 00000000..59eecff6 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.md @@ -0,0 +1,130 @@ +# ADR-0017 | Provide a configurable application-wide default for the binder options + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0017-provide-a-configurable-application-wide-default-for-the-binder-options.fr.md) + +**Status:** Proposed +**Date:** 2026-07-18 +**Decision Makers:** Reefact + +## Context + +* `Bind.PropertiesOf(request)` binds with `RequestBinderOptions.Default`. Binding with + custom options otherwise requires `Bind.WithOptions(options).PropertiesOf(...)` — + threading the configured entry point through each call, or resolving it from a DI + container. +* A host without a DI container — a CLI, a worker, a small tool — has no host-agnostic + way to set an application-wide default that the bare `Bind.PropertiesOf` picks up. +* ADR-0012 fixed a binder's options at its entry point (no change once binding has + begun) and, among its rejected alternatives, rejected "a process-wide ambient default + configured once (a static `Configure`)" on the grounds that it would introduce global + mutable state, leak across tests running in parallel, and could be configured at the + wrong time. +* `RequestBinderOptions` is an immutable value: a shared instance carries no mutable + settings state, so the classic hazard — mutating a shared settings object while it is + in use — does not apply to it. +* The .NET convention is split: `Newtonsoft.Json`'s `JsonConvert.DefaultSettings` is a + freely re-settable global; `System.Text.Json`'s `JsonSerializerOptions` becomes + immutable on first use (frozen) and is configured per-instance or through DI. +* The library exposes no other ambient mutable state: the clock, instance-id and + arbitrary-value seams are immutable defaults with an `AsyncLocal`, scoped, test-only + override (ADR-0006). +* The library is pre-release, unpublished on NuGet with no external consumers. + +## Decision + +`RequestBinderOptions.Default` — the options `Bind.PropertiesOf` binds with — is a +settable application default, configured once at application startup and frozen on the +first bind that reads it. + +## Rationale + +* A settable process default is the only host-agnostic way for the bare + `Bind.PropertiesOf` to pick up an application-wide policy without a DI container, which + a CLI or worker needs; the DI-friendly entry point (`Bind.WithOptions`) stays available + where a container exists. +* The hazard ADR-0012 guarded against — an ambient default that drifts at runtime — is + removed by freezing on first use: once the first bind reads it, reassignment throws, so + it is a composition-time choice that cannot change while requests are flowing. This is + the `System.Text.Json` discipline (immutable-once-used), not the freely-mutable + `JsonConvert.DefaultSettings` one. +* Because `RequestBinderOptions` is immutable, a shared default carries no mutable + settings state, so the classic global-settings footgun does not apply; the only global + state is which immutable options the default points at, fixed once. +* Keeping the configuration on `RequestBinderOptions.Default` rather than on a method of + `Bind` leaves the binding entry point free of configuration surface: a developer binding + requests sees only binding verbs. +* The parallel-test-isolation concern ADR-0012 raised is confined to the library's own + tests, and is met by a scoped, test-only override (an `AsyncLocal`) — the same seam + pattern the clock uses (ADR-0006) — which never touches or freezes the production + default. +* The pre-release status means the surface is settled now, when there are no consumers to + migrate. + +## Alternatives Considered + +### Keep options entry-point-only (the status quo of ADR-0012) + +Considered because it is already shipped and has no global state at all. + +Rejected because it offers no host-agnostic application-wide default: every call site must +thread the configured entry point, or a DI container must supply it — unavailable to a CLI +or worker that wants to configure the binder once. + +### A freely re-settable global default (the JsonConvert.DefaultSettings model) + +Considered because it is the simplest settable global and a widely-used convention. + +Rejected because a default that can be reassigned while requests are flowing can drift, +reintroducing the runtime-configuration hazard ADR-0012 warned about. Freezing on first +use keeps the ergonomic while removing the drift. + +### Dependency injection only (the System.Text.Json / ASP.NET model) + +Considered because it is the modern idiom where a container exists, and is fully +test-safe. + +Rejected as the sole mechanism because it is not host-agnostic: a CLI, a worker, or any +host without a DI container cannot use it to make the bare `Bind.PropertiesOf` pick up an +application default. The injected entry point stays available; this decision adds the +container-free path. + +## Consequences + +### Positive + +* Any host — with or without DI — configures the binder's naming policy and structural + codes once at startup, and the bare `Bind.PropertiesOf` uses them. +* Freezing on first use prevents runtime drift; the only mutable global is an + immutable-options reference set once. +* `Bind`'s surface stays free of configuration; a per-call `Bind.WithOptions` still + overrides the default. + +### Negative + +* The library gains one piece of process-global state (the settable default) — the first + such state in the library, accepted deliberately for the host-agnostic ergonomic. +* The library's own tests need a scoped, test-only override seam to stay parallel-safe; + the production default is not directly settable within a parallel suite. + +### Risks + +* A consumer that reads `RequestBinderOptions.Default` before configuring it freezes it + and can then no longer configure it; mitigated by the throwing setter's diagnostic + ("configure at startup, before the first bind") and by documentation. + +## Follow-up Actions + +* Surface the test-override seam to consumers through a dedicated testing package if + consumer demand appears (it is currently internal, for the library's own tests). + +## References + +* ADR-0012 — fix the binder options before binding begins; this decision revisits the + process-wide ambient default that ADR-0012 weighed and rejected as an alternative, + adopting it with mitigations. ADR-0012's own decision — a binder's options are fixed at + its entry point — is unchanged, so ADR-0012 is not superseded. +* ADR-0006 — supply arbitrary test values from a single seedable source; the `AsyncLocal` + test-seam pattern this decision reuses for its own tests. +* Issue #181 — the request this decision resolves. +* `JsonConvert.DefaultSettings` (Newtonsoft.Json) and `JsonSerializerOptions` + (System.Text.Json) — the two conventions weighed. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 6ce8c625..48288e32 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -190,3 +190,4 @@ Optional supporting material: | [ADR-0014](0014-bind-a-required-list-by-presence-not-cardinality.md) | Bind a required list by presence, not cardinality | Accepted | | [ADR-0015](0015-cap-any-combine-at-arity-eight.md) | Cap Any.Combine at arity eight | Proposed | | [ADR-0016](0016-make-the-binders-structural-error-codes-configurable.md) | Make the binder's structural error codes configurable | Proposed | +| [ADR-0017](0017-provide-a-configurable-application-wide-default-for-the-binder-options.md) | Provide a configurable application-wide default for the binder options | Proposed | From a74f38418353b1ebc9826ae4cc0f6e439a2a3b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 12:57:13 +0000 Subject: [PATCH 3/4] docs(binder): accept ADR-0017 for the app-wide default options The maintainer accepts the decision recorded in ADR-0017: the binder's options default is a settable application default, configured once at startup and frozen on first use. Flip its status from Proposed to Accepted (EN + FR) and update the index. Refs: #181 --- ...urable-application-wide-default-for-the-binder-options.fr.md | 2 +- ...figurable-application-wide-default-for-the-binder-options.md | 2 +- doc/handwritten/for-maintainers/adr/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.fr.md b/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.fr.md index 384bd3b7..6ffd2b5f 100644 --- a/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.fr.md +++ b/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.fr.md @@ -2,7 +2,7 @@ 🌍 🇬🇧 [English](0017-provide-a-configurable-application-wide-default-for-the-binder-options.md) · 🇫🇷 Français (ce fichier) -**Statut :** Proposé +**Statut :** Accepté **Date :** 2026-07-18 **Décideurs :** Reefact diff --git a/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.md b/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.md index 59eecff6..571fc433 100644 --- a/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.md +++ b/doc/handwritten/for-maintainers/adr/0017-provide-a-configurable-application-wide-default-for-the-binder-options.md @@ -2,7 +2,7 @@ 🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0017-provide-a-configurable-application-wide-default-for-the-binder-options.fr.md) -**Status:** Proposed +**Status:** Accepted **Date:** 2026-07-18 **Decision Makers:** Reefact diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 48288e32..48f9c20f 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -190,4 +190,4 @@ Optional supporting material: | [ADR-0014](0014-bind-a-required-list-by-presence-not-cardinality.md) | Bind a required list by presence, not cardinality | Accepted | | [ADR-0015](0015-cap-any-combine-at-arity-eight.md) | Cap Any.Combine at arity eight | Proposed | | [ADR-0016](0016-make-the-binders-structural-error-codes-configurable.md) | Make the binder's structural error codes configurable | Proposed | -| [ADR-0017](0017-provide-a-configurable-application-wide-default-for-the-binder-options.md) | Provide a configurable application-wide default for the binder options | Proposed | +| [ADR-0017](0017-provide-a-configurable-application-wide-default-for-the-binder-options.md) | Provide a configurable application-wide default for the binder options | Accepted | From 21bf130fe63fb6bb9aa56e2c429513349c05a733 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 13:03:17 +0000 Subject: [PATCH 4/4] fix(binder): make the RequestBinderOptions.Default freeze atomic A late Default assignment could race the first bind's read: the setter observed _frozen == false, the getter then set _frozen = true and returned the old default, and the setter still assigned -- leaving the first binding on the old options and later bindings on the new, violating the frozen-on-first-use guarantee. Guard the freeze/check/assign with a lock so a concurrent set either wins before any read froze the default, or observes the freeze and throws. Refs: #181 --- .../RequestBinderOptions.cs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs index 2a6b1df0..08e1b7cd 100644 --- a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs +++ b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs @@ -12,6 +12,7 @@ public sealed class RequestBinderOptions { private static readonly RequestBinderOptions BuiltIn = new(new DefaultArgumentNameProvider()); private static readonly AsyncLocal TestOverride = new(); + private static readonly object Gate = new(); private static RequestBinderOptions _default = BuiltIn; private static bool _frozen; @@ -29,18 +30,26 @@ public static RequestBinderOptions Default { RequestBinderOptions? overridden = TestOverride.Value; if (overridden is not null) { return overridden; } - _frozen = true; + // 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; + return _default; + } } set { if (value is null) { throw new ArgumentNullException(nameof(value)); } - 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; + 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; + } } }