Builder ou pas builder ?

Anthony TENNERIELLO
norsys-octogone
Published in
25 min readJun 20, 2024

Si le design pattern Builder ne vous est pas familier, j’ai rédigé un premier article qui le présente à l’aide d’un exemple et où j’exprime mes réticences, ainsi que des pistes d’amélioration visant à rendre plus robuste le produit crée via ce pattern.

Ce nouvel article a pour objectif de mettre en place un cas concret de construction d’un objet complexe en se questionnant sur la légitimité de l’emploi d’un builder pour un exemple qui, à priori, semble s’y prêter.

Je vais vous proposer un essai à partir du langage PHP (version 8.3).

Commandons les ingrédients de nos futures pizzas

C’était très amusant de cuisiner des pizzas, mais aujourd’hui nous allons traiter un exemple concret que nous pourrions croiser dans un cadre professionnel. Nous allons imaginer une API qui permette d’enregistrer un compte sur un site marchand pour, par exemple, acheter les ingrédients de nouvelles recettes de pizzas 😋

Concrètement, notre mission est de construire un client à partir des informations fournies dans le corps d’une requête HTTP.

Super ! Par quoi commençons-nous ?

Avant de se pencher sur la manière dont nous allons construire le client, il nous faut déjà nous demander de quelles informations il a besoin.

Pour l’exemple, notre client est un particulier, mais nous allons imaginer que notre API évoluera dans un second temps afin que des entreprises puissent également se créer un compte.

Afin que nos futurs clients puissent commander des produits et les recevoir chez eux, nous allons avoir besoin des éléments suivants :

  • Nom
  • Prénom
  • Adresse email
  • Adresse de livraison
  • Adresse de facturation (qui peut être identique à celle de livraison)
  • Numéro de téléphone portable
  • Numéro de téléphone fixe (facultatif)

En plus de ces informations nécessaires à notre besoin métier, nous allons également ajouter la date de création du client.

Et les clients ne payent pas ? 😝

En effet, notre site marchand ne serait pas rentable si les clients ne payaient pas les produits qu’ils achètent. Cependant, j’ai choisi de ne pas conserver les informations bancaires du client pour des raisons de sécurité.

Vous remarquerez que nous avons besoin de deux adresses postales. L’adresse postale n’est pas une donnée insécable, mais un sous ensemble. De quoi se compose-t-elle ?

Il y a tout d’abord les informations liées à la voie : le numéro, le type et le nom de la voie. Là, deux approches sont envisageables pour nos futurs objets : soit une propriété pour chacun des éléments décrits précédemment, soit une seule propriété qui contient la ligne d’adresse. Je vous propose d’employer la première version qui nous permet de mieux contrôler la saisie utilisateur.

Ensuite, le client devra nécessairement nous fournir la ville et son code postal.

Nous avons terminé avec les éléments essentiels de l’adresse. Néanmoins, cela pourrait être une bonne idée de faciliter la vie du livreur. Nous allons permettre à un client résidant dans un immeuble de préciser son adresse grâce aux informations optionnelles suivantes :

  • Nom de la résidence
  • Étage
  • Numéro de l’appartement

Pour finir, nous allons mettre à disposition un champ libre que nous nommerons « information complémentaire », afin que le client puisse partager au livreur tout renseignement qui lui semblera pertinent (digicode, présence d’un interphone, etc.).

L’implémentation PHP pas à pas

La première chose à mettre en place est l’objet que nous souhaitons construire, soit le client. Or, notre client a lui-même besoin d’un autre objet complexe à produire. Vous l’avez deviné, il s’agit de l’adresse postale.

Commençons alors par les classes de ces deux objets que j’appelle parfois produit. Dans le contexte des modèles de conception de création, ce terme désigne justement l’objet que nous souhaitons obtenir.

L’implémentation de l’adresse postale est très similaire à celle du client. Je vais ainsi vous épargner son implémentation complète et l’aborderai uniquement lorsque c’est pertinent.

Premier pas : le produit

Vous vous attendez sûrement à ce que je vous propose une classe Customer. Rappelez-vous, notre application autorise deux types de client : les particuliers et les entreprises. De ce fait, Customer est une interface et non une classe.

Les comptes particuliers et entreprises n’auront pas exactement les mêmes champs, mais doivent avoir le même comportement. Ainsi, notre interface déclare les méthodes que doivent implémenter nos classes produits. Pour notre contexte d’exemple, notre client n’a qu’un seul comportement : celui de se décrire.

Vous trouverez ci-dessous la représentation de nos produits.

Néanmoins, quelque chose me dérange avec les propriétés de nos classes.

Comme je l’ai déjà mentionné dans mon précédent article, le type string n’est pas assez restrictif, car il autorise n’importe quelle valeur et ne restreint en rien son contenu.

Ça serait parfait de pouvoir utiliser un type qui contraint la valeur en fonction de nos besoins.

J’avais choisi d’utiliser les énumérations pour mon précédent article. Ici, nous allons exploiter un autre procédé.

Le Value Object

Un Value Object est un objet englobant une seule valeur correspondant à un type qui a un sens dans notre application. Il a pour particularité d’être immuable et de ne pas avoir d’identité, contrairement à une entité. Cela signifie que si deux Value Objects ont la même valeur, ils sont égaux. À contrario, deux entités qui contiennent les mêmes valeurs ne sont pas identiques, car elles ont une identité propre, souvent un id différent.

Nous pouvons rendre notre produit plus robuste en attribuant des Value Objects comme types à ses propriétés. Cela nous offre la possibilité de valider les valeurs selon un aspect métier. De plus, notre code gagne en clarté.

Nous allons commencer par créer une classe abstraite StringValueObject qui va factoriser le code des types string que nous allons transformer en Value Object.

abstract readonly class StringValueObject implements ValueObject
{
public function __construct(
public string $value,
) {
}

public function __toString(): string
{
return $this->value;
}
}

Cette classe ne contient qu’une seule propriété de type string qui encapsule notre valeur et qui est readonly, puisque je vous rappelle qu’un Value Object est généralement immuable.

Elle implémente une interface ValueObject, ce qui permet de définir le comportement de tous nos Value Objects. En l’occurrence, celle-ci étend l’interface Stringable, imposant d’implémenter la méthode magique __toString(), ce qui va nous servir lorsque nous souhaiterons décrire notre client.

interface ValueObject extends \Stringable
{
}

Maintenant, nous pouvons créer notre premier Value Object. La classe EmailAddress qui, comme son nom l’indique, sert de type pour les adresses emails et se charge de les valider via son constructeur.

readonly class EmailAddress extends StringValueObject
{
public function __construct(string $value)
{
if (\filter_var($value, \FILTER_VALIDATE_EMAIL) === false) {
throw new \LogicException('Invalid value for email address.');
}

parent::__construct($value);
}
}

Grâce à cette classe, dès que nous recevons une instance de EmailAddress nous savons qu’il s’agit d’une adresse email valide.

Nous allons également mettre en place des Value Objects pour le numéro de téléphone (PhoneNumber ), ainsi que le nom (PersonalName).

À quoi ressemble donc notre produit maintenant ?

class PrivateCustomer implements Customer
{
public function __construct(
private PersonalName $lastName,
private PersonalName $firstName,
private EmailAddress $emailAddress,
private PostalAddress $deliveryAddress,
private PostalAddress $billingAddress,
private PhoneNumber $mobilePhoneNumber,
private ?PhoneNumber $landlinePhoneNumber,
private \DateTimeImmutable $createdAt,
) {
}

public function describe(): string
{
//...
}
}

Nous disposons d’un produit dont le constructeur impose de lui fournir tous les champs en respectant nos types personnalisés. Grâce à ça, les instances de notre classe sont validées dès leur création. Vous en conviendrez que notre application gagne en robustesse.

En plus des avantages déjà cités, nous éliminons beaucoup de répétition de code. En effet, la portion de code qui valide une adresse email n’est présente qu’à un seul endroit de notre projet, dans la classe EmailAddress.

Allons-nous utiliser le pattern Builder ?

À priori, notre produit PrivateCustomer semble s’y prêter. Il possède un certain nombre de propriétés, ce qui rend son instanciation via son constructeur difficilement lisible et maintenable. Notre code gagnerait à séparer la construction de cet objet de sa représentation.

Cependant, si nous reprenons l’intitulé de l’exercice, notre rôle est de transformer une chaîne de caractères, qui peut être de plusieurs formats, en un objet Customer. Or, la philosophie du pattern Builder est de concevoir un objet en coordonnant les différentes méthodes de la classe incarnant le builder. Ici, nous n’avons qu’une seule entrée qu’il faut traiter afin d’obtenir l’objet souhaité.

En revanche, un autre design pattern semble mieux correspondre à notre besoin. Il s’agit de Factory ! C’est également un design pattern créationnel, qui, comme pour le pattern Builder, délègue la responsabilité de création de l’objet à une autre classe. Pourtant, son fonctionnement diffère de celui de son homologue Builder. Il nous invite à définir une interface avec une unique méthode chargée de la création du produit. Ensuite, nous n’avons plus qu’à créer autant de classe implémentant cette interface que de couple format / type de client que nous souhaitons prendre en charge.

Voici un diagramme adaptant le pattern Factory à notre contexte.

Nous allons donc mettre en place deux classes afin de pouvoir construire un client de type particulier à partir des formats JSON et XML. La création de compte entreprise nous demandera également deux classes supplémentaires.

La difficulté de l’exercice réside dans le fait que les propriétés diffèrent selon le type de client. C’est ce qui nous oblige à user de deux classes factory pour un même format. Nous pourrons facilement pallier à ce problème grâce à l’héritage lorsque nous souhaiterons concrètement implémenter la gestion des comptes entreprises.

Nous allons tout mettre en place pour gérer une requête au format JSON, puis nous adapterons notre code dans un second temps afin de prendre en charge le format XML.

Débutons par l’interface CustomerFactory.

interface CustomerFactory
{
public function createCustomerFromString(string $userInput): Customer;
}

Hélas, nous ne pouvons pas encore nous lancer dans le développement de notre première classe JsonPrivateCustomerFactory. Ça serait trop facile 😉

En l’état, nous nous retrouverions à répéter beaucoup de code, car notre factory a deux responsabilités : celle de décoder la chaîne de caractères en entrée et celle de créer un objet Customer à partir de ces données.

Vous l’avez compris, nous allons devoir séparer ces deux logiques dans des classes distinctes. La responsabilité de décoder le format source est celle de notre factory. À l’intérieur même de celle-ci, nous allons faire appel à un autre objet qui nous retournera une instance de Customer à partir de la source décodée.

Finalement, le pattern Builder va toute de même nous servir afin d’assurer cette seconde responsabilité.

Ainsi, le builder

Que le client soit un particulier ou une entreprise il va falloir lui livrer sa commande. De fait, notre métier impose à tous nos clients d’avoir une adresse de livraison. C’est également le cas de plusieurs autres propriétés telles que l’adresse email, l’adresse de facturation ou les numéros de téléphone.

Nous allons pouvoir définir une interface CustomerBuilder déclarant les méthodes communes à tous nos types de client.

interface CustomerBuilder
{
public function emailAddressIs(EmailAddress $emailAddress): static;
public function deliveryAddressIs(PostalAddress $deliveryAddress): static;
public function billingAddressIs(PostalAddress $billingAddress): static;
public function mobilePhoneNumberIs(PhoneNumber $mobilePhoneNumber): static;
public function landlinePhoneNumberIs(PhoneNumber $landlinePhoneNumber): static;
public function createdAtIs(\DateTimeImmutable $createdAt): static;
public function createdAtIsNow(): static;
public function buildCustomer(): Customer;
}

Chaque méthode retourne l’instance courante du builder permettant de chaîner les appels.

Sans surprise, voici la classe PrivateCustomerBuilder, qui se charge de construire le produit PrivateCustomer.

class PrivateCustomerBuilder implements CustomerBuilder
{
private PersonalName $lastName;
private PersonalName $firstName;
private EmailAddress $emailAddress;
private PostalAddress $deliveryAddress;
private PostalAddress $billingAddress;
private PhoneNumber $mobilePhoneNumber;
private ?PhoneNumber $landlinePhoneNumber = null;
private \DateTimeImmutable $createdAt;

public function lastNameIs(PersonalName $lastName): static
{
$this->lastName = $lastName;

return $this;
}

public function firstNameIs(PersonalName $firstName): static
{
$this->firstName = $firstName;

return $this;
}

public function emailAddressIs(EmailAddress $emailAddress): static
{
$this->emailAddress = $emailAddress;

return $this;
}

public function deliveryAddressIs(PostalAddress $deliveryAddress): static
{
$this->deliveryAddress = $deliveryAddress;

return $this;
}

public function billingAddressIs(PostalAddress $billingAddress): static
{
$this->billingAddress = $billingAddress;

return $this;
}

public function mobilePhoneNumberIs(PhoneNumber $mobilePhoneNumber): static
{
$this->mobilePhoneNumber = $mobilePhoneNumber;

return $this;
}

public function landlinePhoneNumberIs(PhoneNumber $landlinePhoneNumber): static
{
$this->landlinePhoneNumber = $landlinePhoneNumber;

return $this;
}

public function createdAtIs(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;

return $this;
}

public function createdAtIsNow(): static
{
return $this->createdAtIs(new \DateTimeImmutable());
}

public function buildCustomer(): Customer
{
return new PrivateCustomer(
lastName: $this->lastName,
firstName: $this->firstName,
emailAddress: $this->emailAddress,
deliveryAddress: $this->deliveryAddress,
billingAddress: $this->billingAddress,
mobilePhoneNumber: $this->mobilePhoneNumber,
landlinePhoneNumber: $this->landlinePhoneNumber,
createdAt: $this->createdAt,
);
}
}

Si vous avez lu mon précédent article sur le sujet, cette implémentation ne devrait pas vous surprendre. Je vais tout de même expliciter ma démarche. Notre builder a les mêmes propriétés que l’objet qu’il crée et, grâce à ses méthodes, les alimente avec des valeurs. Le produit n’est lui instancié que dans la méthode buildCustomer à partir des propriétés du builder. Nous ne créons l’objet qu’à la toute fin, une fois seulement que le builder a toutes les valeurs nécessaires.

J’apprécie cette approche, car elle m’offre l’avantage de ne pas avoir à autoriser l’instanciation d’un produit avec des données partielles ou vides, dans le but de l’alimenter petit à petit via le builder. Autrement dit, J’interdis la création d’un objet qui ne serait pas valide du point de vue de l’application.

Vous remarquerez que j’ai attribué des valeurs par défaut aux propriétés qui sont optionnelles. Cela nous permet d’appeler uniquement les méthodes du builder dont nous avons besoin et de ne pas avoir à se préoccuper des propriétés qui ne nous sont pas utiles.

Finalement, PrivateCustomerBuilder met à disposition les méthodes que lui impose d’implémenter son interface, ainsi que des méthodes propres aux propriétés de la classe PrivateCustomer.

Maintenant que notre builder est prêt, nous allons avoir besoin de quelque chose qui va appeler ses méthodes selon la logique de notre application.

Appelez-moi le directeur !

Pour les personnes qui ne connaissent pas ce concept, le directeur sert à ordonnancer la construction du produit via le builder. Il porte la manière dont nous allons créer le produit selon les besoins de notre application. Là où le builder ne se charge que de la fabrication de l’objet à partir des valeurs qui lui sont fournies.

Notre directeur est incarné par une interface qui implémente autant de méthodes qu’il y a de stratégies de création d’un client. J’en vois déjà deux. Nous pouvons avoir un client avec la même adresse de livraison que de facturation, ou à l’inverse, une adresse distincte pour chacune des adresses.

Le directeur possèderait au moins deux méthodes alors ?

C’est une approche possible. Nous pourrions aussi imaginer une seule méthode qui attribue par défaut l’adresse de livraison à l’adresse de facturation si cette dernière n’est pas fournie. La bonne approche dépend davantage du besoin. D’un côté, le directeur s’adapte aux données qu’il reçoit et applique des comportements par défaut en accord avec le métier. De l’autre, on lui impose de concevoir le client d’une certaine manière et générons une erreur si les données ne correspondent pas à la stratégie de création que nous souhaitons appliquer.

Pour notre exercice, nous allons implémenter l’approche qui applique des comportements par défaut lorsque les valeurs ne nous sont pas transmises, ce qui est le plus simple et pertinent pour le cas précis de la gestion des adresses.

Voici l’interface de notre beau directeur. Elle impose l’implémentation d’une seule méthode générique qui, à partir des données décodées, retourne un objet Customer.

interface CustomerDirector
{
public function createCustomer(CustomerInputDto $customerInput): Customer;
}

Qu’est-ce que c’est que cet objet CustomerInputDto ?

Il s’agit d’un DTO et son rôle est essentiel pour notre implémentation.

Avant d’aller plus loin, je vais vous présenter le concept de DTO.

DTO (Data Transfer Object)

Comme son nom l’indique, le but d’un DTO est d’encapsuler des données sous la forme d’un objet afin de le transmettre d’un sous-système à un autre.

Pour notre contexte et étant donné que notre produit contient beaucoup de propriétés, nous nous retrouverions avec des méthodes contenant soit une dizaine de paramètres, soit un tableau associatif en paramètre. Cette seconde approche qui semble plus simple pour transporter beaucoup de données est en réalité chaotique ! Comment est-ce que je peux savoir ce qu’attend la méthode createCustomer si je sais juste que je dois lui fournir un tableau ? En outre, un tableau est une simple boite pouvant contenir absolument tout et surtout n’importe quoi. Vous l’avez compris, le risque d’erreur n’est pas négligeable. Il faudrait alors alourdir notre code applicatif par des vérifications de la présence et de la validité de chaque élément du tableau.

En préférant l’utilisation d’un DTO, nous disposons d’un objet contenant un nombre fini de propriétés, toutes typées. Vous en conviendrez que ça facilite grandement l’échange entre deux méthodes. Sans oublier que le typage fort sécurise l’application et réduit considérablement le risque d’erreur.

C’est aussi bien plus facile pour un développeur de savoir quelles données fournir à une méthode lorsqu’elle attend un DTO.

L’avantage du DTO pour notre exercice

Nous avons déjà vu qu’il existe deux responsabilités à gérer : celle de décoder le format d’entrée et celle de créer un objet Customer à partir de ces données.

Essayez d’imaginer notre programme comme un ensemble de briques Lego. Nous avons une brique XML, une brique JSON, une brique client particulier et une brique client entreprise. En suivant cette logique, il nous suffit d’intervertir la brique JSON avec la brique XML afin de d’alimenter les informations d’un client particulier à partir d’une chaîne au format XML. C’est l’idéal que nous souhaitons atteindre, rendre cela aussi facile que s’il s’agissait réellement de briques Lego.

C’est justement pour répondre à cet objectif que le DTO va nous aider. Il jouera le rôle de structure commune de référence afin que nos classes puissent collaborer entre elles. Tous les formats que nous décodons sont transformés en DTO que n’importe quel directeur est capable d’interpréter pour en faire un objet Customer.

Assez parler, je vais vous dévoiler notre DTO CustomerInputDto.

interface CustomerInputDto
{
}

Mais, c’est une interface ! Qu’est-ce que c’est que cette arnaque ?

N’oubliez pas que nous pouvons tout aussi bien créer un client particulier qu’une entreprise et qu’ils ne disposent pas des mêmes propriétés. Fatalement, nous allons avoir besoin d’une interface.

Voici enfin notre DTO pour le client particulier.

readonly class PrivateCustomerInputDto implements CustomerInputDto
{
public function __construct(
public string $lastName,
public string $firstName,
public string $emailAddress,
public string $mobilePhoneNumber,
public PostalAddressInputDto $deliveryPostalAddress,
public ?PostalAddressInputDto $billingPostalAddress,
public ?string $landlinePhoneNumber,
public ?string $createdAt,
) {
}
}

Notre objet est readonly, car une fois instancié, nous n’avons plus besoin de le modifier. En effet, son rôle se cantonne à celui de transporter des données.

Nous aurions pu tout simplement rendre les propriétés publiques sans forcément passer la classe readonly ?

Oui en effet, c’est ce que j’aurais fait avant la version 8.1 de PHP qui a ajoutée cette fonctionnalité. Passer par une classe readonly m’apporte la certitude sur le fait qu’aucune propriété n’a pu être modifiée après son initialisation.

Vous l’avez sûrement remarqué, l’adresse postale est également représentée par un DTO.

readonly class PostalAddressInputDto
{
public function __construct(
public string $laneNumber,
public string $laneType,
public string $laneName,
public int $postcode,
public string $city,
public ?string $buildingName,
public ?int $floorNumber,
public ?string $apartmentNumber,
public ?string $additionalInformation,
) {
}
}

Les types des propriétés ne sont pas les mêmes que celles des objets PrivateCustomer et PostalAddress 🧐

Oui bien vu ! Le problème étant que si au moment de décoder l’objet je converti les valeurs en mes Value Objects, je devrais le répéter partout où j’instancie un DTO. C’est dommage et ce n’est pas sa responsabilité. C’est notre directeur qui s’en chargera comme vous allez le voir très rapidement.

Le directeur arrive enfin

Maintenant que nous sommes au clair sur le concept de DTO et le rôle clé qu’il incarne dans notre application, nous pouvons revenir à notre directeur.

Rappelez-vous, nous avons une interface CustomerDirector qui nous sert de base afin de créer les deux types de client que peut gérer notre application, à savoir les particuliers et les entreprises.

Ci-dessous vous trouverez l’exemple de la classe PrivateCustomerDirector qui conçoit l’objet PrivateCustomer à partir des données transformées par la factory et qui nous sont transmises grâce à notre DTO.

readonly class PrivateCustomerDirector implements CustomerDirector
{
public function __construct(
private PostalAddressDirector $postalAddressDirector =
new CompletePostalAddressDirector(),
) {
}

public function createCustomer(CustomerInputDto $customerInput): Customer
{
if (!$customerInput instanceof PrivateCustomerInputDto) {
throw new \LogicException(sprintf(
'Invalid user input data type "%s"',
$customerInput::class
));
}

$deliveryPostalAddress = $this->postalAddressDirector
->createPostalAddress($customerInput->deliveryPostalAddress);

$billingPostalAddress = $customerInput->billingPostalAddress !== null
? $this->postalAddressDirector->createPostalAddress(
$customerInput->billingPostalAddress
)
: $deliveryPostalAddress;

$builder = (new PrivateCustomerBuilder())
->lastNameIs(new PersonalName($customerInput->lastName))
->firstNameIs(new PersonalName($customerInput->firstName))
->emailAddressIs(new EmailAddress($customerInput->emailAddress))
->deliveryAddressIs($deliveryPostalAddress)
->billingAddressIs($billingPostalAddress)
->mobilePhoneNumberIs(
new PhoneNumber($customerInput->mobilePhoneNumber)
)
;

if ($customerInput->landlinePhoneNumber !== null) {
$builder->landlinePhoneNumberIs(
new PhoneNumber($customerInput->landlinePhoneNumber)
);
}

$this->buildCreatedAt($builder, $customerInput);

return $builder->buildCustomer();
}

private function buildCreatedAt(
CustomerBuilder $builder,
PrivateCustomerInputDto $customerInput
): void {
$createdAt = $customerInput->createdAt !== null
? \DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$customerInput->createdAt
)
: false;

if ($createdAt !== false) {
$builder->createdAtIs($createdAt);

return;
}

$builder->createdAtIsNow();
}
}

Il y a pas mal de choses à dire sur cette classe, je vais la détailler pas à pas.

public function __construct(
private PostalAddressDirector $postalAddressDirector =
new CompletePostalAddressDirector(),
) {
}

Notre classe contient un constructeur qui attend une instance de l’interface PostalAddressDirector. Comme je vous l’ai mentionné plus tôt, l’adresse postale est également un objet complexe que j’ai choisi de créer via un builder. Cela prend tout son sens ici. Notre directeur ne se préoccupe pas de la création de l’adresse postale, mais délègue cette responsabilité à un autre directeur dédié. Si demain les champs de l’adresse postale changent, aucune ligne de code ne bougera dans notre classe. Et tant mieux, car ça n’est pas sa responsabilité !

Nous définissons un directeur par défaut, mais il est facile de le surcharger par une autre classe implémentant l’interface PostalAddressDirector.

Nous voilà enfin dans la méthode qui crée concrètement notre produit.

public function createCustomer(CustomerInputDto $customerInput): Customer
{
if (!$customerInput instanceof PrivateCustomerInputDto) {
throw new \LogicException(sprintf(
'Invalid user input data type "%s"',
$customerInput::class
));
}

// ...

La première instruction s’assure que le DTO que nous recevons est une instance de PrivateCustomerInputDto, puisque nous souhaitons créer un particulier avec notre directeur. Ici, le fait que le paramètre implémente l’interface CustomerInputDto ne nous suffit pas.

N’est-ce pas le rôle de l’interface justement ?

Une interface nous permet de déclarer les méthodes que devront mettre en place les classes qui l’implémentent. Or, notre cas est un peu spécifique. Que le client soit un particulier ou une entreprise, le comportement est le même ; il souhaite commander un produit sur notre site marchand. Cependant, les propriétés de notre client ne sont pas les mêmes selon qu’il soit un particulier ou un professionnel. Le rôle de notre interface Customer est de s’assurer que quelque soit leurs champs, ils ont le même comportement.

En revanche, lorsque nous souhaitons créer un objet Customer, ses propriétés n’étant pas les mêmes, nous devons nous assurer de la classe dont dépend l’objet que nous recevons en paramètre, afin de renseigner correctement les champs de notre produit final.

Cette approche est perfectible et je pense qu’il peut être possible de faire mieux, puisqu’ici l’interface ne nous permet pas de substituer un objet par un autre car nous nous attendons à une instance de classe spécifique. Du point de vue du paradigme objet, ça n’est en effet pas une bonne manière de procéder. À contrario, en regardant du côté de notre logique métier, nous créons ici un client particulier, donc il n’y aurait pas de sens à lui fournir une instance d’une autre classe.

Ensuite, nous exploitons le directeur de l’adresse postale à deux reprises afin de renseigner l’adresse de livraison et de facturation de notre client, avec pour particularité que si l’adresse de facturation n’est pas renseignée, nous prenons l’adresse de livraison.

$deliveryPostalAddress = $this->postalAddressDirector
->createPostalAddress($customerInput->deliveryPostalAddress);

$billingPostalAddress = $customerInput->billingPostalAddress !== null
? $this->postalAddressDirector->createPostalAddress(
$customerInput->billingPostalAddress
)
: $deliveryPostalAddress;

Nous arrivons enfin au coeur du processus, puisque nous construisons le produit à l’aide des méthodes du builder. C’est bien ici que nous convertissons les valeurs du DTO en Value Objects comme je vous l’ai expliqué précédemment.

$builder = (new PrivateCustomerBuilder())
->lastNameIs(new PersonalName($customerInput->lastName))
->firstNameIs(new PersonalName($customerInput->firstName))
->emailAddressIs(new EmailAddress($customerInput->emailAddress))
->deliveryAddressIs($deliveryPostalAddress)
->billingAddressIs($billingPostalAddress)
->mobilePhoneNumberIs(new PhoneNumber($customerInput->mobilePhoneNumber))
;

if ($customerInput->landlinePhoneNumber !== null) {
$builder->landlinePhoneNumberIs(
new PhoneNumber($customerInput->landlinePhoneNumber)
);
}

$this->buildCreatedAt($builder, $customerInput);

return $builder->buildCustomer();

Notre builder rempli parfaitement sa tâche ici, car, comme vous pouvez le voir, nous concevons pas à pas notre objet avec une syntaxe plus lisible que si nous appelions le constructeur de la classe PrivateCustomer avec une dizaine de paramètres. Voire pire, si nous autorisions la valeur null dans l’objet client, que nous appellerions avec des setters de manière similaire à ici. Cette alternative serait désastreuse, car elle nuirait à l’intégrité de notre produit final, comme je l’ai longuement explicité dans mon précédent article sur le sujet, que je vous invite à lire si vous ne l’avez pas déjà fait.

Je reviens très rapidement sur le directeur de l’adresse postale, afin de vous montrer son implémentation concrète.

class CompletePostalAddressDirector implements PostalAddressDirector
{
public function createPostalAddress(
PostalAddressInputDto $postalAddressInput
): PostalAddress {
$builder = (new CompletePostalAddressBuilder())
->laneNumberIs($postalAddressInput->laneNumber)
->laneTypeIs(LaneType::from($postalAddressInput->laneType))
->laneNameIs($postalAddressInput->laneName)
->postcodeIs(new Postcode($postalAddressInput->postcode))
->cityIs($postalAddressInput->city)
;

if ($postalAddressInput->buildingName !== null) {
$builder->buildingNameIs($postalAddressInput->buildingName);
}

if ($postalAddressInput->floorNumber !== null) {
$builder->floorNumberIs($postalAddressInput->floorNumber);
}

if ($postalAddressInput->apartmentNumber !== null) {
$builder->apartmentNumberIs($postalAddressInput->apartmentNumber);
}

if ($postalAddressInput->additionalInformation !== null) {
$builder->additionalInformationIs(
$postalAddressInput->additionalInformation
);
}

return $builder->buildPostalAddress();
}
}

Sans surprise, il suit exactement la même logique. À partir du DTO PostalAddressInputDto, nous alimentons les champs de l’objet PostalAddress via son builder.

Enfin, qu’il s’agisse de l’adresse postale ou du client, c’est le directeur qui prend en charge la logique métier de création du produit.

La dernière pièce du puzzle : la Factory

Nous avons terminé avec l’implémentation du pattern Builder. Nous pouvons reprendre le développement de la factory responsable du décodage d’une chaîne JSON (on a fait une sacré parenthèse oui 😅).

Allons-y pas à pas.

readonly class JsonPrivateCustomerFactory implements CustomerFactory
{
public function __construct(
private CustomerDirector $customerDirector,
) {
}

// ...

De la même manière que le directeur que nous venons d’achever, notre classe factory possède un constructeur et celui-ci attend justement un directeur.

Ce que nous venons de réaliser correspond au principe de composition. La classe JsonPrivateCustomerFactory n’a qu’une seule responsabilité : décoder la chaîne d’entrée au format JSON. Pourtant, elle retourne un objet Customer et nous savons maintenant que cette tâche n’est pas de son ressort. Or, grâce à l’instance de CustomerDirector que la classe attend dans son constructeur, nous ajoutons un comportement à notre classe sans pour autant lui ajouter une responsabilité. Nous avons ainsi combiné les comportements des deux classes afin d’obtenir le résultat escompté.

Dans ce contexte, le directeur ne peut être que celui d’un particulier, pourquoi autoriser un autre type de directeur en typant la propriété avec l’interface CustomerDirector ?

Il est vrai que nos classes implémentant l’interface Customer n’ont pas les mêmes champs, ce qui causerait une erreur si nous fournissions à notre factory un directeur qui se charge de créer une entreprise. Une erreur, qui s’adresse aux développeurs, et que nous gérons déjà avec le code suivant issu du directeur :

if (!$customerInput instanceof PrivateCustomerInputDto) {
throw new \LogicException(sprintf(
'Invalid user input data type "%s"',
$customerInput::class
));
}

Néanmoins, je recommande de privilégier l’interface pour le type de nos propriétés, puisque, je vous le rappelle, une interface est un contrat que doit respecter chaque classe qui l’implémente. Notre application va pouvoir évoluer, mais les contrats d’interface doivent toujours être respectés. C’est ce qui nous intéresse lorsque nous utilisons l’interface comme type de propriété. L’évolution de notre application peut nous amener à ne plus utiliser la même classe concrète dans notre factory. Dans ce cas, la classe qui se substituera à elle devra implémenter la même interface afin d’assurer la non régression de notre code applicatif.

Pour la suite, j’ai préparé des méthodes privées afin d’extraire les données du tableau associatif découlant de la chaîne JSON.

/**
* @param array<string, string> $inputData
*/
private function extractStringValueFromArray(
array $inputData,
string $field
): string {
if (!isset($inputData[$field])) {
throw new \LogicException(
sprintf('Required field %s is missing', $field)
);
}

return (string) $inputData[$field];
}

/**
* @param array<string, string> $inputData
*/
private function extractStringOrNullValueFromArray(
array $inputData,
string $field
): ?string {
return empty($inputData[$field]) ? null : (string) $inputData[$field];
}

Premièrement, il ne faut bien évidemment jamais faire confiance aux données utilisateur. De ce fait, ces méthodes nous servent à vérifier la présence des champs attendus, ainsi qu’à assurer un typage strict de leurs valeurs.

Pour rappel, ce sont les Value Objects qui se chargent de valider le contenu des champs.

Voici enfin le code qui exploite ce que je viens de vous présenter.

public function createCustomerFromString(string $userInput): Customer
{
$userInputData = json_decode($userInput, true);
$billingAddress = null;

if (!is_array($userInputData)) {
throw new \RuntimeException('Invalid JSON input');
}

if (
!isset($userInputData['deliveryAddress'])
|| !is_array($userInputData['deliveryAddress'])
) {
throw new \LogicException(
'Required delivery address is missing or invalid'
);
}

if (!empty($userInputData['billingAddress'])) {
$billingAddress = $this->getPostalAddressDtoFromArrayData(
$userInputData['billingAddress']
);
}

$customerInput = new PrivateCustomerInputDto(
lastName: $this->extractStringValueFromArray(
$userInputData,
'lastName'
),
firstName: $this->extractStringValueFromArray(
$userInputData,
'firstName'
),
emailAddress: $this->extractStringValueFromArray(
$userInputData,
'emailAddress'
),
mobilePhoneNumber: $this->extractStringValueFromArray(
$userInputData,
'mobilePhoneNumber'
),
deliveryPostalAddress: $this->getPostalAddressDtoFromArrayData(
$userInputData['deliveryAddress']
),
billingPostalAddress: $billingAddress,
landlinePhoneNumber: $this->extractStringOrNullValueFromArray(
$userInputData,
'landlinePhoneNumber'
),
createdAt: $this->extractStringOrNullValueFromArray(
$userInputData, 'createdAt'
),
);

return $this->customerDirector->createCustomer($customerInput);
}

/**
* @param array<string, string> $postalAddressData
*/
private function getPostalAddressDtoFromArrayData(
array $postalAddressData
): PostalAddressInputDto {
return new PostalAddressInputDto(
laneNumber: $this->extractStringValueFromArray(
$postalAddressData,
'laneNumber'
),
laneType: $this->extractStringValueFromArray(
$postalAddressData,
'laneType'
),
laneName: $this->extractStringValueFromArray(
$postalAddressData,
'laneName'
),
postcode: (int) $this->extractStringValueFromArray(
$postalAddressData,
'postcode'
),
city: $this->extractStringValueFromArray(
$postalAddressData,
'city'
),
buildingName: $this->extractStringOrNullValueFromArray(
$postalAddressData,
'buildingName'
),
floorNumber: (int) $this->extractStringOrNullValueFromArray(
$postalAddressData,
'floorNumber'
),
apartmentNumber: $this->extractStringOrNullValueFromArray(
$postalAddressData,
'apartmentNumber'
),
additionalInformation: $this->extractStringOrNullValueFromArray(
$postalAddressData,
'additionalInformation'
),
);
}

Nous ne faisons rien d’exceptionnel ici. Nous décodons la chaîne JSON à l’aide de la fonction bien connue json_decode, puis nous vérifions les entrées utilisateurs avant de les fournir aux constructeurs de nos deux DTO PostalAddressInputDto et PrivateCustomerInputDto.

Enfin, notre directeur intervient pour créer un objet Customer à partir du DTO qu‘il reçoit en paramètre. C’est ici que nous profitons du principe de composition. Nous utilisons le comportement du directeur au sein de notre factory.

return $this->customerDirector->createCustomer($userInput);

Créons notre premier client

Nous avons fait beaucoup de chemin pour arriver jusqu’ici. En voici la récompense : nous allons voir comment s’utilise tout ce que nous venons de mettre en place.

Alors, imaginons la chaîne JSON suivante :

$jsonUserInput = <<<JSON
{
"lastName": "Doe",
"firstName": "John",
"emailAddress": "jdoe@email.com",
"mobilePhoneNumber": "06 12 12 12 12",
"deliveryAddress": {
"laneNumber": "7",
"laneType": "boulevard",
"laneName": "PHP",
"postcode": "35001",
"city": "PHP City",
"floorNumber": 8,
"apartmentNumber": "C816"
}
}
JSON;

Pour l’exemple j’ai créé une classe Request qui représente la requête HTTP que nous pourrions recevoir. Elle a été simplifiée au strict nécessaire.

readonly class Request
{
public function __construct(
public ContentType $contentType,
public UserType $userType,
public string $data,
) {
}
}

Celle-ci contient trois propriétés :

  • contentType : le type du corps de la requête
  • userType : nous n’allons pas nous amuser à deviner si les champs correspondent à un particulier ou à un professionnel. Nous allons imposer aux personnes qui appellent notre API de nous fournir cette information
  • data : le corps de la requête qui contient la chaîne de caractères à traiter

Pour les deux premières propriétés j’ai choisi d’utiliser des énumérations.

enum ContentType: string
{
case JSON = 'application/json';
}
enum UserType
{
case Private;
}

Nous sommes maintenant en mesure de traiter la requête HTTP qui nous envoie les informations d’un client à intégrer à notre système.

$request = new Request(ContentType::JSON, UserType::Private, $jsonUserInput);

$customerDirector = match ($request->userType) {
UserType::Private => new PrivateCustomerDirector(),
};

$customerFactory = match ($request->contentType) {
ContentType::JSON => new JsonPrivateCustomerFactory($customerDirector),
};

$customer = $customerFactory->createCustomerFromString($request->data);

echo nl2br($customer->describe());

Aussi facile qu’avec des briques Lego 😉 Tout ce que nous avons développé nous offre la possibilité d’intervertir nos briques à souhait et d’une extrême facilité grâce aux instructions match.

Les plus futés d’entre vous remarqueront que l’instanciation de la factory ne fonctionnera pas telle quelle lorsque nous ajouterons la gestion des entreprises. C’est tout à fait normal, nous ne le prenons pas en charge pour le moment, puisque nous n’avons pas développé encore la conception d’un client de type entreprise. De plus, je tiens à ne pas surcharger le projet avec du code inutile pour notre exemple.

Ajoutons le format XML

Je vous ai promis qu’il était facile d’étendre notre implémentation afin d’être en mesure de traiter des requêtes avec un corps au format XML. Voyons concrètement comment faire et si l’objectif est effectivement rempli.

Qui dit nouveau format dit…

Nouvelle factory !

Exactement ! Comme nous travaillons toujours sur la création d’un compte client particulier, notre factory se nomme XmlPrivateCustomerFactory et implémente également l’interface CustomerFactory.

Cette classe est strictement identique à son homonyme traitant du format JSON. Elle vérifie la bonne présence des champs requis et crée une instance de PrivateCustomerInputDto en utilisant également des méthodes privées afin d’extraire les données de l’objet SimpleXMLElement résultant du décodage de la chaîne XML. Enfin, elle appelle la méthode createCustomer du directeur, qui lui a également été fourni dans son constructeur. Le réel changement réside dans la gestion du format XML.

readonly class XmlPrivateCustomerFactory implements CustomerFactory
{
public function __construct(
private CustomerDirector $customerDirector,
) {
}

public function createCustomerFromString(string $userInput): Customer
{
$userInputData = \simplexml_load_string($userInput);
$billingAddress = null;

if ($userInputData === false) {
throw new \RuntimeException('Invalid XML input');
}

if (!isset($userInputData->{'delivery-address'})) {
throw new \LogicException(
'Required delivery address is missing or invalid'
);
}

if (isset($userInputData->{'billing-address'})) {
$billingAddress = $this->getPostalAddressDtoFromXmlNode(
$userInputData->{'billing-address'}
);
}

$customerInput = new PrivateCustomerInputDto(
lastName: $this->extractStringValueFromXml(
$userInputData,
'last-name'
),
firstName: $this->extractStringValueFromXml(
$userInputData,
'first-name'
),
emailAddress: $this->extractStringValueFromXml(
$userInputData,
'email-address'
),
mobilePhoneNumber: $this->extractStringValueFromXml(
$userInputData,
'mobile-phone-number'
),
deliveryPostalAddress: $this->getPostalAddressDtoFromXmlNode(
$userInputData->{'delivery-address'}
),
billingPostalAddress: $billingAddress,
landlinePhoneNumber: $this->extractStringOrNullValueFromXml(
$userInputData,
'landline-phone-number'
),
createdAt: $this->extractStringOrNullValueFromXml(
$userInputData,
'created-at'
),
);

return $this->customerDirector->createCustomer($customerInput);
}

private function extractStringValueFromXml(
\SimpleXMLElement $xml,
string $field
): string {
if (!isset($xml->{$field})) {
throw new \LogicException(
sprintf('Required field %s is missing', $field)
);
}

return (string) $xml->{$field};
}

private function extractStringOrNullValueFromXml(
\SimpleXMLElement $xml,
string $field
): ?string {
return isset($xml->{$field}) ? (string) $xml->{$field} : null;
}

private function getPostalAddressDtoFromXmlNode(
\SimpleXMLElement $postalAddressData
): PostalAddressInputDto {
return new PostalAddressInputDto(
laneNumber: $this->extractStringValueFromXml(
$postalAddressData,
'lane-number'
),
laneType: $this->extractStringValueFromXml(
$postalAddressData,
'lane-type'
),
laneName: $this->extractStringValueFromXml(
$postalAddressData,
'lane-name'
),
postcode: (int) $this->extractStringValueFromXml(
$postalAddressData,
'postcode'
),
city: $this->extractStringValueFromXml(
$postalAddressData,
'city'
),
buildingName: $this->extractStringOrNullValueFromXml(
$postalAddressData,
'building-name'
),
floorNumber: (int) $this->extractStringOrNullValueFromXml(
$postalAddressData,
'floor-number'
),
apartmentNumber: $this->extractStringOrNullValueFromXml(
$postalAddressData,
'apartment-number'
),
additionalInformation: $this->extractStringOrNullValueFromXml(
$postalAddressData,
'additional-information'
),
);
}
}

Nous réutilisons ainsi le directeur que nous avons déjà développé pour la gestion du format JSON.

Il ne nous reste plus qu’à ajouter ce nouveau format à l’énumération ContentType et à adapter l’instanciation de la factory.

enum ContentType: string
{
case JSON = 'application/json';
case XML = 'application/xml';
}
$customerFactory = match ($request->contentType) {
ContentType::JSON => new JsonPrivateCustomerFactory($customerDirector),
ContentType::XML => new XmlPrivateCustomerFactory($customerDirector),
};

Oui, nous avons déjà terminé ! Nous créons toujours le même objet, donc il n’est bien entendu pas question de toucher ni au builder, ni au directeur. La seule chose qui change est le format et c’est donc la seule modification que nous avons apportée.

Que retenir de cet exemple ?

Revenons-en à l’intention initiale derrière cet exemple. Mon souhait était de prendre un cas précis qui semble se prêter à l’usage du design pattern Builder et de se questionner sur la pertinence de son emploi.

Finalement oui, le design pattern Builder a bien sa place ici. Néanmoins, nous avons vu que tout seul il ne répond pas totalement à notre besoin. Nous l’avons associé au design pattern Factory et, ensemble, grâce au concept de composition, ils ont été redoutablement efficaces.

Le travail combiné de ces deux patterns nous a permis de créer des petites classes simples, faciles à maintenir et que nous pouvons associer presque à souhait. En effet, le fait que toutes les classes Customer n’ont pas les mêmes champs a complexifié notre travail. Maintenant, nous pouvons créer autant de nouvelles briques que nous le souhaitons sans avoir à revenir sur les briques existantes. Notre application est ouverte à l’extension, mais fermée à la modification. Vous reconnaissez donc le deuxième principe SOLID.

Je vais terminer la métaphore avec Lego en vous rappelant que lorsque vous construisez des monuments ou autre, vous vous contentez d’assembler des briques entre elles. Vous n’avez jamais eu a ouvrir une brique pour la modifier afin qu’elle s’adapte à votre contexte. L’idéal serait de procéder exactement de la même manière avec vos applications.

Conclusion

Je me suis intéressé une première fois au design pattern Builder avec en tête l’idée d’explorer et d’approfondir ce qui me titillait avec les implémentations que je croisais. J’ai également essayé de proposer un essai qui se veut plus robuste du point de vue du produit généré.

Je souhaitais cette fois-ci questionner la pertinence d’employer ce pattern pour un cas précis et vous proposer une implémentation reprenant les concepts proposés dans mon précédent article sur le sujet.

L’alternative que je vous ai présentée n’est qu’un exemple. Je reconnais qu’elle est perfectible et c’est pour cette raison que j’insiste sur le fait qu’elle sert davantage de piste de recherche que de modèle à suivre.

Un grand merci à celles et ceux qui ont eu le courage d’arriver jusque là 🙏

Remerciements

Je tiens à remercier Christophe Vaudry et Thomas Verhoken pour leur relecture attentive et leurs critiques constructives.

--

--