Aller au contenu

Builder

Design Pattern Builder

Construire progressivement des objets complexes

Introduction

Pour des objets simples ne nécessitant aucune logique particulière lors de leur construction, un constructeur est suffisant. Cependant, lorsque des objets sont plus complexes à créer, le design pattern Builder, modèle de conception de création, permet d'organiser leur construction de manière progressive. Il consiste à séparer le processus de construction de l'objet du code qui l'utilise, afin que les différentes étapes nécessaires à sa création puissent être réalisées indépendamment.

Pour ce faire, le design pattern Builder déplace la logique de création et d'initialisation vers un objet dédié à la construction. Le code client configure progressivement l'objet à créer, puis demande au Builder de produire l'instance finale.

Problématique

Nous développons une application générant des rapports. Un rapport possède un titre, un contenu, et peut également avoir un auteur, un en-tête, un pied de page, ainsi qu'une table des matières. Une première approche pourrait consister à définir un constructeur contenant l'ensemble de ces informations :

public class Report
{
    public string Title { get; init; }
    public string Content { get; init; }
    public string? Author { get; init; }
    public string? Header { get; init; }
    public string? Footer { get; init; }
    public bool HasTableOfContents { get; init; }

    public Report(string title, string content, string? author, string? header, string? footer, bool hasTableOfContents)
    {
        Title = title;
        Content = content;
        Author = author;
        Header = header;
        Footer = footer;
        HasTableOfContents = hasTableOfContents;
    }
}

Le code client doit alors fournir tous les paramètres lors de l'instanciation de cette classe :

var report = new Report("Design Pattern Builder", "Contenu du rapport...", "James", null, "Page 1", true);

Cette instruction est correcte, mais plusieurs difficultés apparaissent lorsque le nombre de paramètres augmente :

  • L'appel du constructeur devient difficile à lire.

  • La signification de certaines valeurs n'est pas immédiatement identifiable.

  • La logique nécessaire à la création de l'objet peut progressivement se retrouver dans le code client.

  • La multiplication des paramètres optionnels peut conduire à multiplier les constructeurs ou à transmettre des valeurs sans intérêt pour certaines configurations.

Le problème est amplifié lorsque la création de l'objet nécessite plusieurs étapes, des traitements ou des validations. Ces opérations doivent alors être réalisées par le code client ou intégrées au constructeur, ce qui peut accroître la complexité du code.

Conception

Voici le diagramme de classes utilisant le design pattern Builder pour générer des rapports :

Conception avec le design pattern Builder

Le design pattern Builder peut faire intervenir plusieurs participants :

  • Le Product, l'objet devant être construit. Il s'agit de la classe Report.

  • Le Builder, qui définit les opérations permettant de construire progressivement l'objet. Il s'agit de la classe ReportBuilder.

  • Le Director, qui orchestre les différentes étapes de construction afin de créer des configurations prédéfinies. Cette classe est optionnelle. Il s'agit de la classe ReportDirector.

Implémentation

Le design pattern Builder consiste à déléguer la construction de l'objet à une classe dédiée. Voici une classe proposant des méthodes permettant de construire un rapport :

public class ReportBuilder
{
    private string Title { get; set; } = string.Empty;
    private string Content { get; set; } = string.Empty;
    private string? Author { get; set; }
    private string? Header { get; set; }
    private string? Footer { get; set; }
    private bool HasTableOfContents { get; set; }

    public ReportBuilder WithTitle(string title)
    {
        Title = title;
        return this;
    }

    public ReportBuilder WithContent(string content)
    {
        Content = content;
        return this;
    }

    public ReportBuilder WithAuthor(string author)
    {
        Author = author;
        return this;
    }

    public ReportBuilder WithHeader(string header)
    {
        Header = header;
        return this;
    }

    public ReportBuilder WithFooter(string footer)
    {
        Footer = footer;
        return this;
    }

    public ReportBuilder WithTableOfContents()
    {
        HasTableOfContents = true;
        return this;
    }

    public Report Build()
    {
        if (string.IsNullOrWhiteSpace(Title))
            throw new InvalidOperationException("Le titre est obligatoire.");

        if (string.IsNullOrWhiteSpace(Content))
            throw new InvalidOperationException("Le contenu est obligatoire.");

        return new Report(Title, Content, Author, Header, Footer, HasTableOfContents);
    }
}

Chaque méthode de cette classe commençant par With configure une partie de l'objet à construire et retourne le Builder lui-même. Ainsi, la construction du rapport peut s'implémenter de la manière suivante :

var report = new ReportBuilder()
    .WithTitle("Design Pattern Builder")
    .WithContent("Contenu du rapport")
    .WithAuthor("James")
    .WithFooter("Page 1")
    .WithTableOfContents()
    .Build();

La méthode Build() permet de centraliser la création de l'objet. Elle peut également vérifier que les informations nécessaires à sa construction ont bien été renseignées. Dans notre exemple, le titre et le contenu sont obligatoires et une exception est levée si l'une de ces informations est manquante.

La construction devient plus explicite : chaque valeur est associée à une opération indiquant clairement son rôle. Les différentes méthodes peuvent également être appelées progressivement :

var builder = new ReportBuilder();

builder.WithTitle("Design Pattern Builder");
builder.WithContent("Contenu du rapport");

if (isIncludeAuthor)
    builder.WithAuthor("James");

if (isIncludeTableOfContents)
    builder.WithTableOfContents();

var report = builder.Build();

Le Builder est donc particulièrement intéressant lorsque la construction dépend de plusieurs conditions ou doit être réalisée en plusieurs étapes.

Implémenter un Director

Lorsque différentes configurations sont nécessaires lors de la création d'objets avec le design pattern Builder, la séquence de construction peut elle-même être encapsulée. Voici un exemple :

public class ReportDirector
{
    public Report CreateStandardReport(string title, string content)
    {
        return new ReportBuilder()
            .WithTitle(title)
            .WithContent(content)
            .WithFooter("Rapport standard")
            .Build();
    }

    public Report CreateDetailedReport(string title, string content, string author)
    {
        return new ReportBuilder()
            .WithTitle(title)
            .WithContent(content)
            .WithAuthor(author)
            .WithHeader("Rapport détaillé")
            .WithFooter("Rapport détaillé")
            .WithTableOfContents()
            .Build();
    }
}

Le Director définit la manière de construire certaines configurations. Le Builder sait comment réaliser chaque étape de la construction. Ainsi, le code client n'a plus besoin de connaître les différentes étapes nécessaires à la création de ces configurations :

var director = new ReportDirector();

var report = director.CreateDetailedReport(
    "Design Pattern Builder",
    "Contenu du rapport ...",
    "James");

Design pattern Builder et Fluent API

Le design pattern Builder est fréquemment associé à une Fluent API qui permet une implémentation où les méthodes peuvent être enchaînées, afin de rendre le code plus lisible :

builder
    .WithTitle("Builder")
    .WithAuthor("James")
    .WithTableOfContents();

Le Builder répond à un problème de conception : organiser et encapsuler la construction progressive d'un objet. Un Builder peut donc utiliser une Fluent API, mais une Fluent API n'est pas nécessairement une implémentation du design pattern Builder.

Avantages

Le design pattern Builder permet de rendre la construction d'objets complexes plus explicite et progressive. Il évite notamment de concentrer un grand nombre de paramètres dans un constructeur et permet d'encapsuler la logique nécessaire à la création d'une instance valide.

Il facilite également la création de différentes configurations d'un même objet et améliore la lisibilité du code client. Il est particulièrement adapté lorsque la construction nécessite plusieurs étapes, des validations ou des règles spécifiques.