Cohérence des données dans un modèle orienté objet

On en présentera plus la programmation orientée objet, c'est aujourd'hui l'un, si ce n'est le paradigme de programmation informatique le plus utilisé. Le concept central de ce paradigme, "les objets représente un concept, une idée ou toute entité du monde physique" (définition Wikipédia). Le principal avantage de la programmation orienté objet (POO en abrégé) est de permettre de rassembler au sein d'une structure de données, les propriétés et les actions (méthodes) qui sont liées au concept que l'on manipule. Une des règles essentielles de la POO est que toute action/modification d'un objet doit le laisser dans un état cohérent.

Mais qu'entend-on par état cohérent ? La cohérence d'un système peut être définie comme une exigence selon laquelle toute modification de l'état de ce dernier doit conserver la validité des données selon les règles et contraintes qui le déterminent.

C'est une jolie définition, mais concrètement, ça veut dire quoi ? Prenant comme exemple, un objet Facture. Une facture est composée de 4 propriétés: une date, un montant HT, une TVA et une montant TTC (oui cette dernière est discutable, mais l'exemple vous permettra de bien comprendre). On pourrait donc écrire une première version de notre objet comme ceci:

class Facture
{
    private DateTime $date;
    private float $montantHT;
    private float $tva;
    private float $montantTTC;

    public function __construct(DateTime $date, float $montantHT, float $tva, float $montantTTC)
    {
        $this->date = $date;
        $this->montantHT = $montantHT;
        $this->tva = $tva;
        $this->montantTTC = $montantTTC;
    }

    public function getDate(): DateTime
    {
        return $this->date;
    }

    public function getMontantHT(): float
    {
        return $this->montantHT;
    }

    public function getTVA(): float
    {
        return $this->tva;
    }

    public function getMontantTTC(): float
    {
        return $this->montantTTC;
    }
}

Vous voyez ce qui ne va pas avec cette classe ? Cette dernière nous permet de créer une facture qui pourrait être $facture = new Facture(new DateTime('2020-04-02 18:00:00'), 10.0, 0.1, 50.0) ce qui ne serait pas du tout logique, les trois propriétés de notre facture sont liées entre elles et ne peuvent pas prendre n'importe quelles valeurs. Notre facture est donc dans un état incohérent.

En POO, le constructeur est une fonction appelée lors de l'instanciation d'un objet. Le rôle du constructeur est double, il doit initialiser les propriétés de notre objet, mais il doit également s'assurer que les données sont cohérentes. Dans notre exemple, il doit s'assurer que le montant TTC de notre facture correspond au montant HT auquel s'ajoute le pourcentage de TVA et renvoyer une erreur si ce n'est pas le cas.

class Facture
{
    public function __construct(DateTime $date, float $montantHT, float $tva, float $montantTTC)
    {
        if (($montantHT * (1 + $tva)) !== $montantTTC) {
            throw new DomainException();
        }

        $this->date = $date;
        $this->montantHT = $montantHT;
        $this->tva = $tva;
        $this->montantTTC = $montantTTC;
    }
}

Nous l'avons rapidement mentionné dans l'introduction de ce billet, il est possible de créer des actions sur nos objets. Dans notre exemple, nous souhaiterions par exemple pouvoir modifier le taux de TVA. Dans la plupart des projets informatiques, cela se fait via la création d'une méthode setTVA dont l'implémentation pourrait ressembler à :

class Facture
{
    // ...

    public function setTVA(float $tva): void
    {
        $this->tva = $tva;
    }
}

Rien ne vous choque ? Si cette méthode nous permet effectivement de modifier le taux de TVA de notre facture, cette dernière peut mettre dans un état incohérent notre facture. Par exemple:

// nous construisons un objet valide, 
// la cohérence des données est assuré par le constructeur
$facture = new Facture(new DateTime('2020-01-01 00:00:00'), 10.0, 0.1, 11.0); 

// nous modifions la TVA (= 20%)
$facture->setTVA(0.2);

// notre objet est maintenant dans un état incohérent
// si nous le montant HT est de 10€, la TVA à 20%
// le montant TTC devrait être de 12€, or voici les
// valeurs portées par notre facture :
//
// class Facture#1 (3) {
//   private $date => class DateTime#2 (3) {
//     public $date => string(26) "2020-01-01 00:00:00.000000"
//     public $timezone_type => int(3)
//     public $timezone => string(13) "Europe/Berlin"
//  }
//   private $montantHT => double(10)
//   private $tva => double(0.2)
//   private $montantTTC => double(11)
// }

Comme nous l'avons déjà dit, toute modification de notre objet doit le laisser dans un état cohérent. Cela signifie donc que s'il est effectivement possible de modifier la TVA de notre facture, le montant TTC doit également être mis à jour en conséquence pour que notre valide soit toujours valide.

class Facture
{
    // ...

    public function setTVA(float $tva): void
    {
        $this->tva = $tva;

        $this->montantTTC = $this->montantHT * (1 + $this->tva);
    }
}

Imaginons maintenant que cette classe est issue d'une application en production et que l'on nous demande d'implémenter une nouvelle fonctionnalité: à partir du mois d'avril 2020, la TVA aura un taux constant de 30%. Lors du développement de cette fonctionnalité, il est primordial que le fonctionnement des factures antérieures à cette date ne soit pas modifié.

Si l'on souhaite effectuer ce changement rapidement, en modifiant un minimum de code, une solution naïve pourrait alors être implémentée comme ceci :

class Facture
{
    private DateTime $date;
    private float $montantHT;
    private float $tva;
    private float $montantTTC;

    public function __construct(DateTime $date, float $montantHT, float $tva, float $montantTTC)
    {
        if (($montantHT * (1 + $tva)) !== $montantTTC) {
            throw new DomainException();
        }

        $this->date = $date;
        $this->montantHT = $montantHT;
        $this->tva = $tva;
        $this->montantTTC = $montantTTC;
    }

    public function getDate(): DateTime
    {
        return $this->date;
    }

    public function getMontantHT(): float
    {
        return $this->montantHT;
    }

    public function getTVA(): float
    {
        if ($this->date->after('2020-04-01')) {
            return 0.3;
        }

        return $this->tva;
    }

    public function getMontantTTC(): float
    {
        return $this->montantTTC;
    }
}

Si le code permet effectivement de répondre à la demande qui a été formulée, ce dernier va poser de nombreux problèmes.

Tout d'abord le code précédent enfreint la première règle que nous avons évoquée sur le constructeur. Effectivement, dans le cas présent rien ne nous empêcherait de créer l'objet facture suivant $facture = new Facture(new DateTime('2020-04-02 18:00:00'), 10.0, 0.1, 50.0). Or il y a ici une incohérence puisqu'il ne devrait pas être possible de créer un tel objet, le taux de TVA devant être à 30% à partir du mois d'avril. Il est probable que les données renseignées proviennent d'une interface utilisateur ou d'une API, ce qui signifierait ici que l'utilisateur n'est en plus pas averti d'une erreur de saisie.

Pire encore, si cette solution est une source de confusion pour l'utilisateur final, le code est également une source d'erreurs pour les développeurs. En effet, lorsqu'on lit le nom de la méthode getTVA, on est en doit de s'attendre à ce qu'elle renvoie la valeur de la propriété correspondante. Or ce ne serait pas le cas ici, puisque la donnée peut potentiellement être modifiée à la volée selon la valeur des autres propriétés de la facture.

Voilà qui conclut cette introduction à la notion de cohérence dans un modèle objet. Si ce sujet vous intéresse et que vous souhaitez aller plus loin, je ne peux que vous encourager à fouiller du côté du Domain Driven Design (DDD). Matthias NOBACK a également écrit un livre où il présente des techniques pour écrire un code orienté objet de qualité et pérenne (où il est notamment question de cohérence de données) que je ne peux que vous recommander.