C#.NETDesign PatternsArchitectureAPI Design

Le Fluent Builder Pattern peut clarifier la création d'objets complexes en C#, mais il devient vite une abstraction inutile s'il est appliqué sans discipline.

Yva Hajatiana
11 mars 2026
8 min de lecture
Partager :X / TwitterLinkedIn
Maquette modulaire représentant l’architecture logicielle

Fluent Builder en C# : usages, limites et exemple

Le Fluent Builder Pattern est souvent présenté comme une solution élégante dès qu'un objet possède plusieurs propriétés. Cette vision est trop simpliste. En pratique, le pattern est utile dans un nombre limité de cas : quand la construction est progressive, quand certaines combinaisons doivent être validées, ou quand on veut exposer une API expressive sans empiler les constructeurs.

Le sujet n'est donc pas "comment faire un builder ?", mais "dans quel contexte un builder améliore-t-il vraiment la conception ?".

Le vrai problème à résoudre

Dans une application .NET, la création d'un objet devient difficile lorsque plusieurs signaux se cumulent :

  • trop de paramètres dans le constructeur
  • plusieurs options facultatives
  • ordre des paramètres difficile à relire
  • validations conditionnelles
  • étapes de construction dépendantes les unes des autres

Un code comme celui-ci reste correct techniquement, mais il devient vite pénible à maintenir :

var invoice = new Invoice(
    customerId,
    currency,
    issueDate,
    dueDate,
    lines,
    discountRate,
    notes,
    paymentTerms,
    sendReminder,
    createdBy);

Le problème n'est pas seulement le nombre de paramètres. C'est aussi l'absence d'intention visible.

Ce que le Fluent Builder apporte réellement

Un bon builder rend la construction plus explicite :

var invoice = InvoiceBuilder
    .Create(customerId, currency)
    .WithIssueDate(issueDate)
    .WithDueDate(dueDate)
    .AddLines(lines)
    .WithDiscount(discountRate)
    .WithNotes(notes)
    .Build();

Le gain principal est la lisibilité. On comprend immédiatement ce qui est obligatoire, ce qui est optionnel et dans quel ordre conceptuel l'objet est préparé.

Les autres bénéfices sont réels, mais secondaires :

  • validation centralisée
  • API plus difficile à mal utiliser
  • tests plus lisibles
  • possibilité de réutiliser un schéma de construction

Le builder n'est pas une réponse universelle

Le Fluent Builder Pattern devient contre-productif quand il est appliqué à des objets simples.

Si une classe a trois ou quatre propriétés claires, un constructeur explicite ou un record suffit souvent largement :

public sealed record MoneyTransfer(
    Guid SourceAccountId,
    Guid DestinationAccountId,
    decimal Amount,
    string Currency);

Ajouter un builder ici ne simplifie rien. Il augmente seulement le nombre de types, le code boilerplate et la surface de maintenance.

Un bon signal : si le builder ne porte ni validation utile, ni progression logique, ni contrainte d'usage, il ne sert probablement à rien.

Les cas où il est pertinent

Le pattern devient défendable dans plusieurs situations.

1. Objet complexe avec étapes progressives

Quand la création suit un enchaînement métier, le builder clarifie le flux.

Exemple :

  • création d'une commande
  • ajout des lignes
  • application d'une remise
  • configuration de la livraison
  • validation finale

2. Construction avec combinaisons invalides possibles

Si certaines options sont incompatibles, le builder peut imposer des gardes explicites.

public InvoiceBuilder WithRecurringSchedule(RecurringSchedule schedule)
{
    if (_isOneShot)
    {
        throw new InvalidOperationException("A one-shot invoice cannot be recurring.");
    }

    _recurringSchedule = schedule;
    return this;
}

Ici, le builder joue un rôle de protection d'API, pas seulement de confort syntaxique.

3. Besoin d'une API lisible pour les appels fréquents

Certaines API internes sont appelées partout dans la base de code. Un style fluent peut réduire les erreurs et aider la lecture :

var query = SearchRequestBuilder
    .Create("distributed systems")
    .InLanguage("fr")
    .OnlyPublished()
    .PageSize(20)
    .Build();

Quand l'objet est souvent construit dans des variantes légèrement différentes, cette approche peut être plus claire qu'une accumulation de surcharges de constructeurs.

Builder mutable ou objet final immutable

La meilleure combinaison reste généralement :

  • builder mutable
  • objet final immutable

Le builder accumule l'état pendant la phase de construction. L'objet créé par Build() devient ensuite stable et sûr.

public sealed class DeploymentConfiguration
{
    public string Environment { get; }
    public string Region { get; }
    public int Replicas { get; }

    internal DeploymentConfiguration(string environment, string region, int replicas)
    {
        Environment = environment;
        Region = region;
        Replicas = replicas;
    }
}

Cette séparation est importante. Le builder gère la flexibilité. L'objet final porte la stabilité.

Où placer les validations

Un builder sérieux doit distinguer deux niveaux de validation :

  • validation immédiate sur un paramètre invalide
  • validation globale au moment du Build()

Exemple :

public DeploymentConfiguration Build()
{
    if (string.IsNullOrWhiteSpace(_environment))
        throw new InvalidOperationException("Environment is required.");

    if (string.IsNullOrWhiteSpace(_region))
        throw new InvalidOperationException("Region is required.");

    if (_replicas <= 0)
        throw new InvalidOperationException("Replicas must be greater than zero.");

    if (_environment == "production" && _replicas < 2)
        throw new InvalidOperationException("Production requires at least two replicas.");

    return new DeploymentConfiguration(_environment, _region, _replicas);
}

La validation locale évite les erreurs grossières. La validation globale protège les règles transverses.

Faut-il rendre l'API totalement fluide

Pas nécessairement. Beaucoup de builders deviennent caricaturaux parce qu'ils chaînent tout, même ce qui devrait rester simple.

Il vaut mieux garder ces principes :

  • méthodes au nom explicite
  • peu d'effets implicites
  • pas de magie cachée
  • Build() comme point de validation final

Une API fluide doit rester prévisible. Si elle devient "trop intelligente", elle devient aussi plus difficile à raisonner.

L'alternative moderne : records, object initializers et factories

En C# moderne, le builder partage l'espace avec d'autres outils :

  • record pour les objets de données simples
  • init pour les propriétés configurées une seule fois
  • méthodes de factory pour les cas de création standards

Exemple simple :

public sealed record NotificationOptions
{
    public required string Channel { get; init; }
    public required string Recipient { get; init; }
    public bool Urgent { get; init; }
    public string? Template { get; init; }
}

Si ce niveau de modélisation suffit, un builder n'apporte pas assez de valeur pour justifier son coût.

Une règle pratique pour choisir

Avant d'introduire un Fluent Builder, posez ces questions :

  • l'objet est-il vraiment complexe à construire ?
  • existe-t-il une progression logique de configuration ?
  • le constructeur actuel nuit-il à la lisibilité ?
  • y a-t-il des invariants difficiles à exprimer autrement ?
  • l'API sera-t-elle appelée souvent par d'autres développeurs ?

Si plusieurs réponses sont "non", il vaut mieux rester sur une solution plus simple.

Exemple complet

Voici un builder raisonnable, orienté intention :

public sealed class ApiClientOptionsBuilder
{
    private string? _baseUrl;
    private TimeSpan _timeout = TimeSpan.FromSeconds(30);
    private bool _enableRetries = true;

    public ApiClientOptionsBuilder WithBaseUrl(string baseUrl)
    {
        if (string.IsNullOrWhiteSpace(baseUrl))
            throw new ArgumentException("Base URL is required.", nameof(baseUrl));

        _baseUrl = baseUrl;
        return this;
    }

    public ApiClientOptionsBuilder WithTimeout(TimeSpan timeout)
    {
        if (timeout <= TimeSpan.Zero)
            throw new ArgumentOutOfRangeException(nameof(timeout));

        _timeout = timeout;
        return this;
    }

    public ApiClientOptionsBuilder EnableRetries(bool enabled = true)
    {
        _enableRetries = enabled;
        return this;
    }

    public ApiClientOptions Build()
    {
        if (_baseUrl is null)
            throw new InvalidOperationException("Base URL must be configured.");

        return new ApiClientOptions(_baseUrl, _timeout, _enableRetries);
    }
}

Ce builder reste utile parce qu'il :

  • encapsule une validation réelle
  • expose des choix explicites
  • produit un objet final simple

Le cas d'usage le plus rentable : les tests

Le contexte où un builder paie presque toujours est celui des tests. Un Test Data Builder fournit des valeurs par défaut valides et ne laisse au test que ce qui compte pour son scénario :

public sealed class InvoiceTestBuilder
{
    private Guid _customerId = Guid.NewGuid();
    private string _currency = "EUR";
    private readonly List<InvoiceLine> _lines = [new("Abonnement", 1, 49.90m)];
    private decimal _discountRate;

    public InvoiceTestBuilder InCurrency(string currency)
    {
        _currency = currency;
        return this;
    }

    public InvoiceTestBuilder WithDiscount(decimal rate)
    {
        _discountRate = rate;
        return this;
    }

    public Invoice Build() => Invoice.Create(_customerId, _currency, _lines, _discountRate);
}

[Fact]
public void Discount_cannot_exceed_fifty_percent()
{
    var act = () => new InvoiceTestBuilder().WithDiscount(0.8m).Build();

    Assert.Throws<InvalidOperationException>(act);
}

Le test ne mentionne que la remise : le client, la devise et les lignes sont des détails sans rapport avec la règle vérifiée. Quand le constructeur d'Invoice évolue, seul le builder change, pas les centaines de tests qui l'utilisent. Notez que le builder appelle la factory du domaine : il ne contourne pas ses invariants.

Pour aller plus loin

Un builder d'options HTTP comme celui de l'exemple complet se combine bien avec un client typé : voir Refit en .NET : concevoir des clients HTTP fiables. Pour la validation des données entrantes plutôt que des invariants de construction, FluentValidation en ASP.NET Core est plus adapté qu'un builder. Enfin, la place de ces objets dans une solution est discutée dans la Clean Architecture en .NET sans dogmatisme.

Conclusion

Le Fluent Builder Pattern est un bon outil, mais c'est un outil de précision. Il est efficace quand il clarifie une construction complexe, impose des règles et rend une API plus sûre à utiliser. Il est mauvais quand il remplace mécaniquement des constructeurs déjà suffisants.

La meilleure décision de design n'est pas d'utiliser plus de patterns. C'est d'utiliser uniquement ceux qui réduisent la complexité réelle du code.

Mots-clés :C#.NETDesign PatternsArchitectureAPI Design
Y

Yva Hajatiana

Lead développeur .NET à Paris et architecte logiciel, 10 ans d'expérience sur des SI critiques bancaires (.NET, Angular, systèmes distribués, IAM, DevOps).