diff --git a/Dummies.UnitTests/CompositionTests.cs b/Dummies.UnitTests/CompositionTests.cs index e651ea51..2f6c0025 100644 --- a/Dummies.UnitTests/CompositionTests.cs +++ b/Dummies.UnitTests/CompositionTests.cs @@ -104,6 +104,43 @@ public void CombineWrapsComposerFailures() { Check.That(caught.InnerException).IsInstanceOf(); } + [Fact(DisplayName = "Combine composes four through eight parts, passing every constrained part to the lambda.")] + public void CombineSupportsHigherArities() { + IAny part = Any.Int32().Between(1, 9); + + for (int i = 0; i < 50; i++) { + int[] four = Any.Combine(part, part, part, part, (a, b, c, d) => new[] { a, b, c, d }).Generate(); + int[] five = Any.Combine(part, part, part, part, part, (a, b, c, d, e) => new[] { a, b, c, d, e }).Generate(); + int[] six = Any.Combine(part, part, part, part, part, part, (a, b, c, d, e, f) => new[] { a, b, c, d, e, f }).Generate(); + int[] seven = Any.Combine(part, part, part, part, part, part, part, (a, b, c, d, e, f, g) => new[] { a, b, c, d, e, f, g }).Generate(); + int[] eight = Any.Combine(part, part, part, part, part, part, part, part, (a, b, c, d, e, f, g, h) => new[] { a, b, c, d, e, f, g, h }).Generate(); + + Check.That(four.Length).IsEqualTo(4); + Check.That(five.Length).IsEqualTo(5); + Check.That(six.Length).IsEqualTo(6); + Check.That(seven.Length).IsEqualTo(7); + Check.That(eight.Length).IsEqualTo(8); + foreach (int[] parts in new[] { four, five, six, seven, eight }) { + Check.That(parts).ContainsOnlyElementsThatMatch(value => value is >= 1 and <= 9); + } + } + } + + [Fact(DisplayName = "A higher-arity Combine validates its arguments and wraps composer failures.")] + public void HigherArityCombineValidatesAndWraps() { + Check.ThatCode(() => Any.Combine(Any.Int32(), Any.Int32(), Any.Int32(), Any.Int32(), (Func)null!)).Throws(); + Check.ThatCode(() => Any.Combine(Any.Int32(), Any.Int32(), Any.Int32(), null!, (a, b, c, d) => a)).Throws(); + + IAny failing = Any.Combine( + Any.Int32().Between(1, 2), Any.Int32().Between(1, 2), Any.Int32().Between(1, 2), Any.Int32().Between(1, 2), + Any.Int32().Between(1, 2), Any.Int32().Between(1, 2), Any.Int32().Between(1, 2), Any.Int32().Between(1, 2), + (a, b, c, d, e, f, g, h) => throw new InvalidOperationException("rejected")); + + AnyGenerationException caught = Assert.Throws(() => failing.Generate()); + Check.That(caught.Message).Contains("Combine(...)"); + Check.That(caught.InnerException).IsInstanceOf(); + } + [Fact(DisplayName = "Generic inference flows through IAny without relying on implicit conversions.")] public void GenericInferenceFlowsThroughIAny() { string text = Materialize(Any.String().NonEmpty().WithMaxLength(50)); diff --git a/Dummies/Any.cs b/Dummies/Any.cs index 86c1fb0f..91dff493 100644 --- a/Dummies/Any.cs +++ b/Dummies/Any.cs @@ -430,6 +430,221 @@ public static IAny Combine(IAny first, IAny + /// Composes four generators into one through a constructor lambda — see . + /// + /// The generator of the first part. + /// The generator of the second part. + /// The generator of the third part. + /// The generator of the fourth part. + /// The constructor lambda assembling the parts. + /// The type of the first part. + /// The type of the second part. + /// The type of the third part. + /// The type of the fourth part. + /// The type of the composed value. + /// A generator of the composed value. + /// Thrown when any argument is null. + public static IAny Combine(IAny first, IAny second, IAny third, IAny fourth, Func compose) { + if (first is null) { throw new ArgumentNullException(nameof(first)); } + if (second is null) { throw new ArgumentNullException(nameof(second)); } + if (third is null) { throw new ArgumentNullException(nameof(third)); } + if (fourth is null) { throw new ArgumentNullException(nameof(fourth)); } + if (compose is null) { throw new ArgumentNullException(nameof(compose)); } + + RandomSource? source = AnyDerivation.SourceOf(first) ?? AnyDerivation.SourceOf(second) ?? AnyDerivation.SourceOf(third) ?? AnyDerivation.SourceOf(fourth); + + return new DerivedAny(source, () => { + T1 firstValue = first.Generate(); + T2 secondValue = second.Generate(); + T3 thirdValue = third.Generate(); + T4 fourthValue = fourth.Generate(); + + return AnyDerivation.Invoke(() => compose(firstValue, secondValue, thirdValue, fourthValue), source, $"the composer passed to Combine(...) threw for the generated values ({AnyDerivation.Display(firstValue)}, {AnyDerivation.Display(secondValue)}, {AnyDerivation.Display(thirdValue)}, {AnyDerivation.Display(fourthValue)})"); + }); + } + + /// + /// Composes five generators into one through a constructor lambda — see . + /// + /// The generator of the first part. + /// The generator of the second part. + /// The generator of the third part. + /// The generator of the fourth part. + /// The generator of the fifth part. + /// The constructor lambda assembling the parts. + /// The type of the first part. + /// The type of the second part. + /// The type of the third part. + /// The type of the fourth part. + /// The type of the fifth part. + /// The type of the composed value. + /// A generator of the composed value. + /// Thrown when any argument is null. + public static IAny Combine(IAny first, IAny second, IAny third, IAny fourth, IAny fifth, Func compose) { + if (first is null) { throw new ArgumentNullException(nameof(first)); } + if (second is null) { throw new ArgumentNullException(nameof(second)); } + if (third is null) { throw new ArgumentNullException(nameof(third)); } + if (fourth is null) { throw new ArgumentNullException(nameof(fourth)); } + if (fifth is null) { throw new ArgumentNullException(nameof(fifth)); } + if (compose is null) { throw new ArgumentNullException(nameof(compose)); } + + RandomSource? source = AnyDerivation.SourceOf(first) ?? AnyDerivation.SourceOf(second) ?? AnyDerivation.SourceOf(third) ?? AnyDerivation.SourceOf(fourth) ?? AnyDerivation.SourceOf(fifth); + + return new DerivedAny(source, () => { + T1 firstValue = first.Generate(); + T2 secondValue = second.Generate(); + T3 thirdValue = third.Generate(); + T4 fourthValue = fourth.Generate(); + T5 fifthValue = fifth.Generate(); + + return AnyDerivation.Invoke(() => compose(firstValue, secondValue, thirdValue, fourthValue, fifthValue), source, $"the composer passed to Combine(...) threw for the generated values ({AnyDerivation.Display(firstValue)}, {AnyDerivation.Display(secondValue)}, {AnyDerivation.Display(thirdValue)}, {AnyDerivation.Display(fourthValue)}, {AnyDerivation.Display(fifthValue)})"); + }); + } + + /// + /// Composes six generators into one through a constructor lambda — see . + /// + /// The generator of the first part. + /// The generator of the second part. + /// The generator of the third part. + /// The generator of the fourth part. + /// The generator of the fifth part. + /// The generator of the sixth part. + /// The constructor lambda assembling the parts. + /// The type of the first part. + /// The type of the second part. + /// The type of the third part. + /// The type of the fourth part. + /// The type of the fifth part. + /// The type of the sixth part. + /// The type of the composed value. + /// A generator of the composed value. + /// Thrown when any argument is null. + public static IAny Combine(IAny first, IAny second, IAny third, IAny fourth, IAny fifth, IAny sixth, Func compose) { + if (first is null) { throw new ArgumentNullException(nameof(first)); } + if (second is null) { throw new ArgumentNullException(nameof(second)); } + if (third is null) { throw new ArgumentNullException(nameof(third)); } + if (fourth is null) { throw new ArgumentNullException(nameof(fourth)); } + if (fifth is null) { throw new ArgumentNullException(nameof(fifth)); } + if (sixth is null) { throw new ArgumentNullException(nameof(sixth)); } + if (compose is null) { throw new ArgumentNullException(nameof(compose)); } + + RandomSource? source = AnyDerivation.SourceOf(first) ?? AnyDerivation.SourceOf(second) ?? AnyDerivation.SourceOf(third) ?? AnyDerivation.SourceOf(fourth) ?? AnyDerivation.SourceOf(fifth) ?? AnyDerivation.SourceOf(sixth); + + return new DerivedAny(source, () => { + T1 firstValue = first.Generate(); + T2 secondValue = second.Generate(); + T3 thirdValue = third.Generate(); + T4 fourthValue = fourth.Generate(); + T5 fifthValue = fifth.Generate(); + T6 sixthValue = sixth.Generate(); + + return AnyDerivation.Invoke(() => compose(firstValue, secondValue, thirdValue, fourthValue, fifthValue, sixthValue), source, $"the composer passed to Combine(...) threw for the generated values ({AnyDerivation.Display(firstValue)}, {AnyDerivation.Display(secondValue)}, {AnyDerivation.Display(thirdValue)}, {AnyDerivation.Display(fourthValue)}, {AnyDerivation.Display(fifthValue)}, {AnyDerivation.Display(sixthValue)})"); + }); + } + + /// + /// Composes seven generators into one through a constructor lambda — see . + /// + /// The generator of the first part. + /// The generator of the second part. + /// The generator of the third part. + /// The generator of the fourth part. + /// The generator of the fifth part. + /// The generator of the sixth part. + /// The generator of the seventh part. + /// The constructor lambda assembling the parts. + /// The type of the first part. + /// The type of the second part. + /// The type of the third part. + /// The type of the fourth part. + /// The type of the fifth part. + /// The type of the sixth part. + /// The type of the seventh part. + /// The type of the composed value. + /// A generator of the composed value. + /// Thrown when any argument is null. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", + Justification = "Heterogeneous composition needs one generator parameter per part; the arity-8 ceiling is a deliberate ergonomic decision (ADR-0015), and a flat parameter list reads better at the call site than nested Combine calls.")] + public static IAny Combine(IAny first, IAny second, IAny third, IAny fourth, IAny fifth, IAny sixth, IAny seventh, Func compose) { + if (first is null) { throw new ArgumentNullException(nameof(first)); } + if (second is null) { throw new ArgumentNullException(nameof(second)); } + if (third is null) { throw new ArgumentNullException(nameof(third)); } + if (fourth is null) { throw new ArgumentNullException(nameof(fourth)); } + if (fifth is null) { throw new ArgumentNullException(nameof(fifth)); } + if (sixth is null) { throw new ArgumentNullException(nameof(sixth)); } + if (seventh is null) { throw new ArgumentNullException(nameof(seventh)); } + if (compose is null) { throw new ArgumentNullException(nameof(compose)); } + + RandomSource? source = AnyDerivation.SourceOf(first) ?? AnyDerivation.SourceOf(second) ?? AnyDerivation.SourceOf(third) ?? AnyDerivation.SourceOf(fourth) ?? AnyDerivation.SourceOf(fifth) ?? AnyDerivation.SourceOf(sixth) ?? AnyDerivation.SourceOf(seventh); + + return new DerivedAny(source, () => { + T1 firstValue = first.Generate(); + T2 secondValue = second.Generate(); + T3 thirdValue = third.Generate(); + T4 fourthValue = fourth.Generate(); + T5 fifthValue = fifth.Generate(); + T6 sixthValue = sixth.Generate(); + T7 seventhValue = seventh.Generate(); + + return AnyDerivation.Invoke(() => compose(firstValue, secondValue, thirdValue, fourthValue, fifthValue, sixthValue, seventhValue), source, $"the composer passed to Combine(...) threw for the generated values ({AnyDerivation.Display(firstValue)}, {AnyDerivation.Display(secondValue)}, {AnyDerivation.Display(thirdValue)}, {AnyDerivation.Display(fourthValue)}, {AnyDerivation.Display(fifthValue)}, {AnyDerivation.Display(sixthValue)}, {AnyDerivation.Display(seventhValue)})"); + }); + } + + /// + /// Composes eight generators into one through a constructor lambda — see . + /// Eight is the ceiling; a constructor needing more parts is better assembled from intermediate value objects. + /// + /// The generator of the first part. + /// The generator of the second part. + /// The generator of the third part. + /// The generator of the fourth part. + /// The generator of the fifth part. + /// The generator of the sixth part. + /// The generator of the seventh part. + /// The generator of the eighth part. + /// The constructor lambda assembling the parts. + /// The type of the first part. + /// The type of the second part. + /// The type of the third part. + /// The type of the fourth part. + /// The type of the fifth part. + /// The type of the sixth part. + /// The type of the seventh part. + /// The type of the eighth part. + /// The type of the composed value. + /// A generator of the composed value. + /// Thrown when any argument is null. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", + Justification = "Heterogeneous composition needs one generator parameter per part; the arity-8 ceiling is a deliberate ergonomic decision (ADR-0015), and a flat parameter list reads better at the call site than nested Combine calls.")] + public static IAny Combine(IAny first, IAny second, IAny third, IAny fourth, IAny fifth, IAny sixth, IAny seventh, IAny eighth, Func compose) { + if (first is null) { throw new ArgumentNullException(nameof(first)); } + if (second is null) { throw new ArgumentNullException(nameof(second)); } + if (third is null) { throw new ArgumentNullException(nameof(third)); } + if (fourth is null) { throw new ArgumentNullException(nameof(fourth)); } + if (fifth is null) { throw new ArgumentNullException(nameof(fifth)); } + if (sixth is null) { throw new ArgumentNullException(nameof(sixth)); } + if (seventh is null) { throw new ArgumentNullException(nameof(seventh)); } + if (eighth is null) { throw new ArgumentNullException(nameof(eighth)); } + if (compose is null) { throw new ArgumentNullException(nameof(compose)); } + + RandomSource? source = AnyDerivation.SourceOf(first) ?? AnyDerivation.SourceOf(second) ?? AnyDerivation.SourceOf(third) ?? AnyDerivation.SourceOf(fourth) ?? AnyDerivation.SourceOf(fifth) ?? AnyDerivation.SourceOf(sixth) ?? AnyDerivation.SourceOf(seventh) ?? AnyDerivation.SourceOf(eighth); + + return new DerivedAny(source, () => { + T1 firstValue = first.Generate(); + T2 secondValue = second.Generate(); + T3 thirdValue = third.Generate(); + T4 fourthValue = fourth.Generate(); + T5 fifthValue = fifth.Generate(); + T6 sixthValue = sixth.Generate(); + T7 seventhValue = seventh.Generate(); + T8 eighthValue = eighth.Generate(); + + return AnyDerivation.Invoke(() => compose(firstValue, secondValue, thirdValue, fourthValue, fifthValue, sixthValue, seventhValue, eighthValue), source, $"the composer passed to Combine(...) threw for the generated values ({AnyDerivation.Display(firstValue)}, {AnyDerivation.Display(secondValue)}, {AnyDerivation.Display(thirdValue)}, {AnyDerivation.Display(fourthValue)}, {AnyDerivation.Display(fifthValue)}, {AnyDerivation.Display(sixthValue)}, {AnyDerivation.Display(seventhValue)}, {AnyDerivation.Display(eighthValue)})"); + }); + } + /// /// Starts an arbitrary generator over . Unconstrained, it yields /// 0 to 8 elements; chain constraints to express what the surrounding code requires (NonEmpty(), diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index 66ae3d47..73bda8bc 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -40,7 +40,7 @@ matter — and that is the point. declared — for example `Any.String().WithLength(3).StartingWith("ORD-")`. - **Composition without reflection**: `.As(factory)` turns a constrained primitive into a domain value object; `Any.Combine(...)` assembles larger objects through - constructor lambdas. + constructor lambdas — from two up to eight constrained parts. - **Collections over any element generator**: `Any.ListOf(item)`, `ArrayOf`, `SequenceOf`, `SetOf` and `DictionaryOf`, constrained with `WithCount`/`NonEmpty`/`Distinct`/`Containing`. A distinct collection asked for more diff --git a/doc/handwritten/for-maintainers/adr/0015-cap-any-combine-at-arity-eight.fr.md b/doc/handwritten/for-maintainers/adr/0015-cap-any-combine-at-arity-eight.fr.md new file mode 100644 index 00000000..9a4bae0d --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0015-cap-any-combine-at-arity-eight.fr.md @@ -0,0 +1,129 @@ +# ADR-0015 | Plafonner Any.Combine à l'arité huit + +🌍 🇬🇧 [English](0015-cap-any-combine-at-arity-eight.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Proposé +**Date :** 2026-07-18 +**Décideurs :** Reefact + +## Contexte + +`Dummies` compose des générateurs contraints en objets plus gros via +`Any.Combine(parties..., compose)` : les parties sont générées, puis remises à une +lambda constructeur fournie par l'appelant, de sorte qu'un objet-valeur ou un +agrégat est assemblé sans réflexion et que le constructeur du domaine reste le seul +gardien. Construire facilement de gros objets est un objectif explicite de la +bibliothèque. + +C# n'a pas de générique variadique hétérogène : passer *N* parties de types +différents en un seul appel exige une surcharge distincte par arité, chacune avec +*N*+1 paramètres de type générique et *N*+1 paramètres de valeur. Jusqu'ici, +`Combine` n'existait que pour deux et trois parties ; en composer davantage +imposait l'imbrication (`Combine(Combine(a, b, …), c, …)`) ou le passage par des +tuples, qui exposent tous deux un accès positionnel `Item1..ItemN` ou des lambdas +imbriquées au site d'appel. + +Le dépôt exécute une analyse SonarCloud dont la règle S107 signale une méthode à +plus de sept paramètres. Un `Combine` de sept parties a huit paramètres (sept +générateurs plus le composeur) et de huit parties, neuf — les deux plus grandes +surcharges franchissent donc ce seuil. La pratique établie du dépôt est de garder +l'analyse propre, en supprimant une règle en ligne avec une justification là où une +exception délibérée est faite. + +Un constructeur qui a besoin de beaucoup de parties est lui-même souvent un signal +de conception — un objet-valeur intermédiaire manquant. + +## Décision + +`Any.Combine` offre des surcharges de deux jusqu'à huit parties et s'arrête là, en +acceptant le *code smell* de nombre de paramètres et de génériques des deux plus +grandes surcharges comme un compromis délibéré en faveur d'un site d'appel de +composition plat et sans réflexion. + +## Justification + +* **Cela sert directement l'objectif « construire de gros objets ».** Un + `Combine(a, b, c, d, e, (…) => new Thing(…))` plat, avec des paramètres de lambda + nommés par l'appelant, se lit bien mieux que des `Combine` imbriqués ou un accès + tuple `Item1..ItemN`, et garde la composition sans réflexion — la raison d'être + même de `Combine`. +* **Huit est le bon endroit pour le plafond.** Huit parties couvrent la quasi- + totalité des constructeurs DDD écrits à la main ; au-delà, l'objet est assez + complexe pour que des objets-valeurs intermédiaires soient la conception plus + saine, donc un plafond à huit pousse vers cette structure plutôt que de lisser des + constructeurs arbitrairement larges. +* **Le smell est inhérent, pas accidentel.** Il n'existe pas de façon moins + « smell » de passer *N* parties de types différents en un seul appel ; le nombre + de paramètres et de génériques est le coût irréductible de la composition + hétérogène. Supprimer S107 avec une justification sur les deux plus grandes + surcharges enregistre ce compromis au niveau du code, et cet ADR enregistre + pourquoi il est acceptable. +* **S'arrêter avant seize garde la surface bornée.** Égaler le plafond de seize + arguments de `Func` ajouterait des surcharges maintenues à la main et entièrement + documentées dont la valeur marginale est faible et dont le coût en boilerplate est + réel ; la demande au-delà de huit ne le justifie pas. + +## Alternatives considérées + +### Ne garder que les arités deux et trois ; composer davantage par imbrication + +Considérée parce qu'elle n'ajoute aucune surface. Rejetée parce que l'imbrication +force un accès tuple positionnel (`Item1..ItemN`) ou des lambdas imbriquées au site +d'appel — illisible précisément là où la bibliothèque promet une construction facile +de gros objets. + +### Un builder fluide accumulant un tuple (`Combine(a).And(b).And(c)…`) + +Considérée comme moyen d'éviter une surcharge par arité. Rejetée parce que `.And` +aurait lui-même besoin d'une surcharge par arité source, et que le tuple accumulé +réexpose l'accès positionnel — elle échange une forme de boilerplate contre un site +d'appel pire. + +### Étendre jusqu'à l'arité seize + +Considérée par souci d'exhaustivité, pour égaler `Func`. Rejetée parce que les +constructeurs de neuf à seize parties sont un *code smell* que la bibliothèque ne +devrait pas lisser, et que chaque surcharge est une surface documentée et maintenue +à la main pour une demande réelle négligeable. + +### Un tableau `params` de générateurs de même type + +Considérée pour le cas homogène. Rejetée parce qu'elle ne fonctionne que si toutes +les parties partagent un même type et qu'elle perd le typage par partie — elle ne +sert pas le cas du constructeur hétérogène pour lequel `Combine` existe. Elle reste +une option orthogonale, ajoutable plus tard sans toucher à cette décision. + +## Conséquences + +### Positives + +* Les gros objets-valeurs et agrégats se composent en un seul appel plat, lisible et + sans réflexion, avec des paramètres nommés par l'appelant. +* Le plafond oriente doucement les constructeurs très larges vers des objets-valeurs + intermédiaires. + +### Négatives + +* Cinq surcharges de plus sur la façade, maintenues à la main et entièrement + documentées. +* Les deux plus grandes surcharges portent une suppression S107 en ligne — une + exception documentée et localisée à la règle du nombre de paramètres. + +### Risques + +* **Pression sur le plafond** — un besoin réel de neuf parties ou plus pourrait + réapparaître. Atténué parce que c'est en soi un signal de conception ; le plafond + n'est réexaminé que si le besoin se révèle courant, et ajouter des arités + supérieures plus tard reste non-breaking. + +## Actions de suivi + +* Si un besoin de composition homogène (même type) apparaît, envisager un `Combine` + à base de `params` séparément — c'est orthogonal à cette décision. +* Refléter le plafond d'arité dans la documentation utilisateur une fois la surface + stabilisée. + +## Références + +* ADR-0011 — Héberger Dummies comme un paquet autonome dans ce dépôt. +* Les suppressions S107 sur les surcharges `Combine` d'arité sept et huit. diff --git a/doc/handwritten/for-maintainers/adr/0015-cap-any-combine-at-arity-eight.md b/doc/handwritten/for-maintainers/adr/0015-cap-any-combine-at-arity-eight.md new file mode 100644 index 00000000..366b8e59 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0015-cap-any-combine-at-arity-eight.md @@ -0,0 +1,117 @@ +# ADR-0015 | Cap Any.Combine at arity eight + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0015-cap-any-combine-at-arity-eight.fr.md) + +**Status:** Proposed +**Date:** 2026-07-18 +**Decision Makers:** Reefact + +## Context + +`Dummies` composes constrained generators into larger objects through +`Any.Combine(parts..., compose)`: the parts are generated, then handed to a +caller-supplied constructor lambda, so a value object or aggregate is assembled +without reflection and the domain's own constructor stays the single gatekeeper. +Building large objects easily is an explicit goal of the library. + +C# has no heterogeneous variadic generics: passing *N* differently-typed parts in +one call requires a distinct overload per arity, each with *N*+1 generic type +parameters and *N*+1 value parameters. Until now `Combine` existed only for two +and three parts; composing more forced nesting (`Combine(Combine(a, b, …), c, …)`) +or routing through tuples, both of which surface positional `Item1..ItemN` access +or nested lambdas at the call site. + +The repository runs a SonarCloud analysis whose rule S107 flags a method with more +than seven parameters. A `Combine` of seven parts has eight parameters (seven +generators plus the composer) and of eight parts, nine — so the two largest +overloads cross that threshold. The repository's standing practice is to keep the +analysis clean, suppressing a rule inline with a justification where a deliberate +exception is made. + +A constructor that needs many parts is itself often a design signal — a missing +intermediate value object. + +## Decision + +`Any.Combine` offers overloads from two up to eight parts and stops there, +accepting the parameter- and generic-count code smell of the two largest overloads +as a deliberate trade-off for a flat, reflection-free composition call site. + +## Rationale + +* **It serves the "build large objects" goal directly.** A flat + `Combine(a, b, c, d, e, (…) => new Thing(…))` with caller-named lambda parameters + reads far better than nested `Combine` calls or tuple `Item1..ItemN` access, and + keeps the composition reflection-free — the whole point of `Combine`. +* **Eight is where the ceiling belongs.** Eight parts cover essentially every + hand-written DDD constructor; beyond that, the object is complex enough that + intermediate value objects are the healthier design, so a ceiling at eight nudges + toward that structure instead of smoothing over arbitrarily wide constructors. +* **The smell is inherent, not accidental.** There is no lower-smell way to pass + *N* differently-typed parts in a single call; the parameter and generic counts + are the irreducible cost of heterogeneous composition. Suppressing S107 with a + justification on the two largest overloads records that trade-off at the code, + and this ADR records why it is acceptable. +* **Stopping short of sixteen keeps the surface bounded.** Matching `Func`'s + sixteen-argument ceiling would add hand-maintained, fully-documented overloads + whose marginal value is low and whose boilerplate cost is real; the demand past + eight does not justify it. + +## Alternatives Considered + +### Keep only arity two and three; compose more by nesting + +Considered because it adds no new surface. Rejected because nesting forces +positional tuple access (`Item1..ItemN`) or nested lambdas at the call site — +unreadable precisely where the library promises easy large-object construction. + +### A fluent tuple-accumulating builder (`Combine(a).And(b).And(c)…`) + +Considered as a way to avoid one overload per arity. Rejected because `.And` would +itself need an overload per source arity, and the accumulated tuple exposes +positional access again — it trades one form of boilerplate for a worse call site. + +### Extend all the way to arity sixteen + +Considered for completeness, matching `Func`. Rejected because nine-to-sixteen-part +constructors are a design smell the library should not smooth over, and each +overload is fully-documented, hand-maintained surface with negligible real demand. + +### A `params` array of same-typed generators + +Considered for the homogeneous case. Rejected because it only works when every part +shares one type and it loses per-part typing — it does not serve the +heterogeneous-constructor case `Combine` exists for. It remains an orthogonal option +that could be added later without touching this decision. + +## Consequences + +### Positive + +* Large value objects and aggregates compose in one flat, readable, reflection-free + call, with caller-named parameters. +* The ceiling gently steers very wide constructors toward intermediate value + objects. + +### Negative + +* Five more hand-maintained, fully-documented overloads on the facade. +* The two largest overloads carry an inline S107 suppression — a documented, + localized exception to the parameter-count guideline. + +### Risks + +* **Ceiling pressure** — a genuine nine-or-more-part need could recur. Mitigated + because that is itself a design signal; the ceiling is revisited only if the need + proves common, and adding higher arities later stays non-breaking. + +## Follow-up Actions + +* If a homogeneous, same-type composition need appears, consider a `params`-based + `Combine` separately — it is orthogonal to this decision. +* Reflect the arity ceiling in the user documentation once the surface stabilizes. + +## References + +* ADR-0011 — Host Dummies as a standalone package in this repository. +* The S107 suppressions on the arity-seven and arity-eight `Combine` overloads. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 09f504dd..9608bb59 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -188,3 +188,4 @@ Optional supporting material: | [ADR-0012](0012-fix-the-binder-options-before-binding-begins.md) | Fix the binder options before binding begins | Accepted | | [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) | Gate distinct collections by cardinality, otherwise by a bounded draw | Proposed | | [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 |