Des objets autovalidants avec le "Self-Validating Object" pattern
J’ai déjà évoqué dans ce blog la notion de cohérence des données au sein d’un modèle orienté objet. Ce principe est connu sous le nom de Self-Validating Object (objet autovalidant). On le retrouve également sous le nom de Validated Domain Object ou Invariant-Enforcing Object dans la littérature proche du Domain Driven Design. Il s’agit d’un modèle de conception consistant à ajouter une logique de validation directement dans les objets.
Cette approche possède de nombreux avantages. Tout d’abord, elle permet de garantir la cohérence des données, tout en renforçant l’encapsulation de nos objets, puisque ce sont ces derniers qui sont responsables de leurs propres validations. De plus, en cas d’erreur d’une donnée ou d’une règle de validation précise, il est possible de générer des erreurs précises.
Prenons un exemple. Le plus évident et commun est certainement celui du constructeur d’un objet. Nous allons donc modéliser un horaire que l’on va construire autour de 3 propriétés que sont: l’heure, la minute et la seconde.
readonly class Time
{
public function __construct(
public int $hour,
public int $minute,
public int $second,
) {
}
}
Pour que l’objet soit autovalidant, nous allons y ajouter les contrôles nécessaires sur nos propriétés:
readonly class Time
{
public function Time(
public int $hour,
public int $minute,
public int $second,
) {
if ($this->hour < 0 || $this->hour > 23) {
throw new InvalidArgumentException("Hour value should be between 0 and 23: {$this->hour} given.");
}
if ($this->minute < 0 || $this->minute > 59) {
throw new InvalidArgumentException("Minute value should be between 0 and 59: {$this->minute} given.");
}
if ($this->second < 0 || $this->second > 59) {
throw new InvalidArgumentException("Second value should be between 0 and 59: {$this->second} given.");
}
}
}
La classe ci-dessus est autovalidate. Elle vérifie que les données qu’on lui donne correspondent effectivement à un horaire conforme. Dans le cas contraire, une exception est levée avec un message d’erreur explicite. Nous avons ici pris l’exemple d’un constructeur, mais le principe serait exactement le même sur une méthode de notre objet.
Il s’agit de l’implémentation la plus courante que l’on retrouve dans les bases de code. Elle est simple et rapide à mettre en place. Néanmoins, elle présente un inconvénient majeur: chaque donnée est validée individuellement, et la vérification s’arrête à la première erreur détectée. Ainsi, si on tente de créer l’instance new Time(25, 120, 0)
, on obtiendra d’abord l’erreur Hour value should be between 0 and 23: 25 given.
, mais sans aucune information sur les minutes, qui sont pourtant invalide. Ce n’est qu’après la correction de cette première erreur qu’on remarquera celle concernant les minutes.
Sur un objet simple comme celui que nous utilisons ici, ce n’est pas très grave, car il contient peu de propriétés. Cependant, sur des applications métiers plus complexes, cela peut s’avérer plus contraignant. L’idéal serait donc de mettre en place un mécanisme de validation permettant de vérifier l’intégrité de l’ensemble des données et de remonter l’ensemble des erreurs d’un seul coup. Nous pouvons alors imaginer un nouveau pattern.
Commençons par créer un conteneur pouvant stocker plusieurs erreurs:
class ValidationException extends InvalidArgumentException
{
/**
* @param list<string> $errors
*/
public function __construct(
string $message,
public private(set) array $errors,
) {
parent::__construct($message);
}
}
L’idée est ensuite d’effectuer chacune des vérifications de nos propriétés en ajoutant les erreurs dans un tableau et lever l’exception précédente si ce dernier n’est pas vide:
readonly class Time
{
public function __construct(
public int $hour,
public int $minute,
public int $second,
) {
$errors = [];
if ($this->hour < 0 || $this->hour > 23) {
$errors[] = "Hour value should be between 0 and 23: {$this->hour} given.";
}
if ($this->minute < 0 || $this->minute > 59) {
$errors[] = "Minute value should be between 0 and 59: {$this->minute} given.";
}
if ($this->second < 0 || $this->second > 59) {
$errors[] = "Second value should be between 0 and 59: {$this->second} given.";
}
if (!empty($errors)) {
throw new ValidationException('Invalid time data.', $errors);
}
}
}
Maintenant, lorsque nous tentons d’instancier notre objet avec plusieurs propriétés invalides, nous obtenons une exception qui contient toutes les erreurs:
$time = new Time(25, 120, 0);
// class ValidationException#2 (8) {
// protected $message =>
// string(18) "Invalid time data."
// public array $errors =>
// array(2) {
// [0] =>
// string(38) "Hour value should be between 0 and 23: 25 given."
// [1] =>
// string(40) "Minute value should be between 0 and 59: 120 given."
// }
// }
Ce mécanisme de validation peut être très intéressant même s’il est un peu plus complexe à mettre en place. Il fonctionne pour des objets ou structures de données relativement simples. Si un objet nécessite une validation contextuelle ou dépendant de plusieurs objets extérieurs, il sera peut-être préférable de sortir la logique de validation de l’objet lui-même.