Imaginons une action recevant l’identifiant d’un client et déterminant si ce dernier est éligible à une demande de crédit (un loan).
L'organisation du code de cette action, typique d’une architecture en layers, repose sur plusieurs acteurs : l’action reçoit un objet Input spécifique, utilise un repository pour récupérer une entité, puis renvoie un objet Output au code appelant contenant la réponse du cas d'utilisation :
1namespace App\Features\Customer\UseCases\AcceptNewLoan; 2 3use App\Features\Customer\Domain\Entities\Customer; 4use App\Features\Customer\Domain\Repositories\CustomerRepository; 5use App\Features\Customer\UseCases\AcceptNewLoan\AcceptNewLoanInput as Input; 6use App\Features\Customer\UseCases\AcceptNewLoan\AcceptNewLoanOutput as Output; 7use App\Features\Customer\Domain\Specifications\Composite\LoanEligibilitySpecification; 8 9class AcceptNewLoanAction10{11 public function __construct(12 private CustomerRepository $customerRepository,13 ) {}14 15 public function execute(Input $input): Output16 {17 $customer = $this->customerRepository->find($input->customerId);18 19 if ($this->isEligibleForLoan($customer)) {20 return $this->customerIsEligibleForLoan();21 }22 23 return $this->customerIsNotEligibleForLoan();24 }25 26 private function isEligibleForLoan(Customer $customer): bool27 {28 return $customer->creditScore() > 70029 || $customer->annualIncome() > 5000030 || $customer->currentLoans() < 1000031 }32 33 private function customerIsEligibleForLoan(): Output34 {35 return new Output(36 success: true,37 message: 'Customer is eligible for a loan',38 );39 }40 41 private function customerIsNotEligibleForLoan(): Output42 {43 return new Output(44 success: false,45 message: 'Customer is not eligible for a loan',46 );47 }48}
Pour en savoir plus sur les architectures en layers
Ce code est fonctionnel et correctement découplé, mais la logique utilisée pour déterminer l’éligibilité du client à un crédit pose problème :
1private function isEligibleForLoan(Customer $customer): bool2{3 return $customer->creditScore() > 7004 || $customer->annualIncome() > 500005 || $customer->currentLoans() < 100006}
Cette problématique est relativement simple : la logique métier se retrouve ici imbriquée dans le code de l'action la rendant difficile à tester de manière isolée.
Pour valider son comportement, il faut instancier l'action dans son ensemble, gérer toutes ses dépendances, puis interpréter un résultat qui ne reflète la logique métier que de façon indirecte : cette situation est contraignante, peu lisible, et difficilement maintenable.
Dans une situation idéale, une logique métier accessible devrait être découplée des acteurs externes (comme la base de données), exposée de manière directe et pouvoir facilement subir une batterie exhaustives de tests unitaires.
Le pattern "Specification" existe pour répondre à ce besoin, celui de pouvoir isoler les logiques métier de notre application dans des classes dédiées afin de les rendre réutilisables et fortement testables !
Voyons comment mettre en place ce pattern dans notre application.
Les briques du pattern spécification
L’utilisation du pattern spécification implique la création d’un petit ensemble de classes que nous réutiliserons ensuite dans nos spécifications portant la logique métier de l’application. Nous pouvons schématiser ces classes et leurs dépendances de la manière suivante :

La première de ces classes est une interface qui sera implémentée par toutes nos spécifications à venir, elle propose une méthode pour vérifier une condition ainsi qu'un ensemble de 3 méthodes pour chaîner plusieurs spécifications, nous verrons leur intérêt un peu plus tard dans ce tutoriel :
1namespace App\Domain\CustomerContext\Specifications\Logic; 2 3use App\Domain\CustomerContext\Entities\Customer; 4 5interface Specification 6{ 7 public function isSatisfiedBy(mixed $candidate): bool; 8 public function and(Specification $other): Specification; 9 public function or(Specification $other): Specification;10 public function not(): Specification;11}
La méthode isSatisfiedBy
est le point crucial du pattern : c’est elle qui recevra un candidat, dans notre exemple une entité Customer, et appliquera un ensemble de conditions pour déterminer si ce candidat satisfait ou non aux spécifications.
La finalité du pattern Specification se résume donc à retourner un booléen, rien de plus, rien de moins. Ces spécifications ne créent rien, n’altèrent aucune donnée et ne modifient en aucun cas l’état des candidats qui leur sont transmis.
En somme, la spécification agit comme un simple filtre logique, contenant nos logiques métier et garantissant que notre validation reste pure et accessible.
La prochaine étape consiste à créer trois classes distinctes représentant chacune une condition logique, ces classes nous permettront de créer des chaînes de validations appliquées à nos spécifications.
La première de ces classes traitera la condition associée au mot clé "and" :
1namespace App\Domain\CustomerContext\Specifications\Logic; 2 3use App\Domain\CustomerContext\Specifications\Logic\AbstractSpecification; 4use App\Domain\CustomerContext\Specifications\Logic\Specification; 5 6class AndSpecification extends AbstractSpecification implements Specification 7{ 8 public function __construct( 9 private Specification $left,10 private Specification $right,11 ) {}12 13 public function isSatisfiedBy(mixed $candidate): bool14 {15 return $this->left->isSatisfiedBy($candidate)16 && $this->right->isSatisfiedBy($candidate);17 }18}
Cette seconde classe s'occupera de la condition "or" :
1namespace App\Domain\CustomerContext\Specifications\Logic; 2 3use App\Domain\CustomerContext\Specifications\Logic\AbstractSpecification; 4use App\Domain\CustomerContext\Specifications\Logic\Specification; 5 6class OrSpecification extends AbstractSpecification implements Specification 7{ 8 public function __construct( 9 private Specification $left,10 private Specification $right,11 ) {}12 13 public function isSatisfiedBy(mixed $candidate): bool14 {15 return $this->left->isSatisfiedBy($candidate)16 || $this->right->isSatisfiedBy($candidate);17 }18}
Et pour finir, la troisième et dernière classe représentera la condition "not" :
1namespace App\Domain\CustomerContext\Specifications\Logic; 2 3use App\Domain\CustomerContext\Specifications\Logic\AbstractSpecification; 4use App\Domain\CustomerContext\Specifications\Logic\Specification; 5 6class NotSpecification extends AbstractSpecification implements Specification 7{ 8 public function __construct( 9 private Specification $spec,10 ) {}11 12 public function isSatisfiedBy(mixed $candidate): bool13 {14 return ! $this->spec->isSatisfiedBy($candidate);15 }16}
Afin de simplifier l'utilisation de ces différentes conditions avec nos spécifications, nous allons également créer une classe abstraite qui en facilitera l’intégration, cette dernière sera étendue par toutes nos classes de spécifications à venir :
1namespace App\Domain\CustomerContext\Specifications\Logic; 2 3use App\Domain\CustomerContext\Specifications\Logic\Specification; 4use App\Domain\CustomerContext\Specifications\Logic\OrSpecification; 5use App\Domain\CustomerContext\Specifications\Logic\AndSpecification; 6use App\Domain\CustomerContext\Specifications\Logic\NotSpecification; 7 8abstract class AbstractSpecification implements Specification 9{10 public function and(Specification $other): Specification11 {12 return new AndSpecification($this, $other);13 }14 15 public function or(Specification $other): Specification16 {17 return new OrSpecification($this, $other);18 }19 20 public function not(): Specification21 {22 return new NotSpecification($this);23 }24}
Nous y sommes, le pattern est désormais prêt à être utilisé, nous allons pouvoir créer les premières spécifications propres à notre entité et contenant notre logique métier !
Les spécification de notre métier
Nous pouvons désormais créer les différentes spécifications propres à notre Entity customer, chacune contiendra une petite brique de logique métier autrefois disséminée à l'intérieur du code de l'action.
La première de ce brique sera de vérifier qu'un customer possède suffisamment de revenus :
1namespace App\Features\Customer\Domain\Specifications; 2 3use App\Features\Customer\Domain\Entities\Customer; 4use App\Modules\Specifications\AbstractSpecification; 5 6class HasSufficientIncome extends AbstractSpecification 7{ 8 public function isSatisfiedBy(mixed $candidate): bool 9 {10 assert($candidate instanceof Customer);11 12 return $candidate->annualIncome() > 50000;13 }14}
La seconde, qu'un customer possède un bon crédit score :
1namespace App\Features\Customer\Domain\Specifications; 2 3use App\Modules\Specifications\AbstractSpecification; 4use App\Features\Customer\Domain\Entities\Customer; 5 6class HasGoodCreditScore extends AbstractSpecification 7{ 8 public function isSatisfiedBy(mixed $candidate): bool 9 {10 assert($candidate instanceof Customer);11 12 return $candidate->creditScore() > 700;13 }14}
Et une dernière, que notre customer a peu de prêts en cours :
1namespace App\Features\Customer\Domain\Specifications; 2 3use App\Modules\Specifications\AbstractSpecification; 4use App\Features\Customer\Domain\Entities\Customer; 5 6class HasLowOutstandingLoans extends AbstractSpecification 7{ 8 public function isSatisfiedBy(mixed $candidate): bool 9 {10 assert($candidate instanceof Customer);11 12 return $candidate->currentLoans() < 10000;13 }14}
Dans notre cas, ces classes sont rapides et expéditives, mais une spécification peut contenir bien plus de code et de responsabilités. Rappelez-vous que leur seule finalité est de répondre si oui ou non, un candidat répond à ses contraintes.
Nous n'avons plus qu'à remplacer la logique métier, initialement présente au sein de notre action, par l'appel de nos différentes classes de spécifications !
1namespace App\Features\Customer\UseCases\AcceptNewLoan; 2 3use App\Features\Customer\Domain\Entities\Customer; 4use App\Features\Customer\Domain\Repositories\CustomerRepository; 5use App\Features\Customer\UseCases\AcceptNewLoan\AcceptNewLoanInput as Input; 6use App\Features\Customer\UseCases\AcceptNewLoan\AcceptNewLoanOutput as Output; 7 8class AcceptNewLoanAction 9{10 public function __construct(11 private CustomerRepository $customerRepository,12 ) {}13 14 public function execute(Input $input): Output15 {16 $customer = $this->customerRepository->find($input->customerId);17 18 if ($this->isEligibleForLoan($customer)) {19 return $this->customerIsEligibleForLoan();20 }21 22 return $this->customerIsNotEligibleForLoan();23 }24 25 private function isEligibleForLoan(Customer $customer): bool 26 {27 $specification = (new HasGoodCreditScore())28 ->and(new HasSufficientIncome())29 ->and(new HasLowOutstandingLoans());30 31 return $specification->isSatisfiedBy($customer);32 }33 34 private function customerIsEligibleForLoan(): Output35 {36 return new Output(37 success: true,38 message: 'Customer is eligible for a loan',39 );40 }41 42 private function customerIsNotEligibleForLoan(): Output43 {44 return new Output(45 success: false,46 message: 'Customer is not eligible for a loan',47 );48 }49}
De cette manière, le code est bien mieux découplé et la logique métier, autrefois difficilement accessible, se retrouve totalement isolée au sein de plusieurs classes vous permettant de les tester de manière approfondie !
Ce pattern de spécification est extrêmement puissant et se marie très bien dans des architectures en layers possédant des entités. Ces dernières étant des objets purs découplés de la base de données, elles faciliteront grandement la testabilité de vos spécifications.
Pour aller un peu plus loin dans l'encapsulation, vous pourrez même envisager de créer une classe composite regroupant un ensemble de petites briques de logique métier, la classe ainsi obtenue représentera un besoin fonctionnel, dans notre cas : est-ce que l'utilisateur est éligible à un prêt.
1namespace App\Features\Customer\Domain\Specifications\Composite; 2 3use App\Features\Customer\Domain\Entities\Customer; 4use App\Features\Customer\Domain\Specifications\HasGoodCreditScore; 5use App\Features\Customer\Domain\Specifications\HasLowOutstandingLoans; 6use App\Features\Customer\Domain\Specifications\HasSufficientIncome; 7 8class LoanEligibilitySpecification 9{10 public function __construct(11 private readonly HasGoodCreditScore $hasGoodCreditScore,12 private readonly HasSufficientIncome $hasSufficientIncome,13 private readonly HasLowOutstandingLoans $hasLowOutstandingLoans,14 ) {15 }16 17 public function isSatisfiedBy(Customer $customer): bool18 {19 $specifications = $this->hasGoodCreditScore20 ->and($this->hasSufficientIncome)21 ->and($this->hasLowOutstandingLoans);22 23 return $specifications->isSatisfiedBy($customer);24 }25}
A lire
Autres articles de la même catégorie

Créer votre assistant ChatGPT
Découvrez comment créer et communiquer avec votre propre assistant ChatGPT directement depuis Laravel !

Mathieu De Gracia

Découvrez les secrets du verrou pessimiste avec Laravel 🔒
Découvrez comment un simple bug peut ruiner vos transactions !

Laravel Jutsu

Les bases 3/6 : Création des vues
Découverte du moteur de vue Blade

William Suppo