Aller au contenu
Sylvain Joffraud l4w
Retour

State machine dans l’agrégat : transitions protégées

Une entité métier qui traverse un cycle de vie — brouillon → signé → payé → en attente → finalisé, avec une branche échec — finit presque toujours avec un setStatus() public et la logique de transition éparpillée dans des services. Chaque service « sait » dans quel ordre les statuts s’enchaînent, et rien n’empêche d’appeler setStatus(Status::Enrolled) depuis un brouillon. L’invariant n’existe que dans la tête des devs.

Sur une entité Subscription (un tunnel de souscription qui se signe, se paie, puis déclenche une affiliation), j’ai déplacé toute la mécanique de transition dans l’entité elle-même. Pas de setter public de statut. L’entité devient un agrégat qui protège ses propres invariants.

Le problème du setStatus() public

// L'anti-pattern : entité anémique + logique dans le service
$subscription->setStatus(Status::Paid);   // depuis n'importe où, dans n'importe quel ordre
$subscription->setPaymentReference($ref);  // ou pas — rien ne l'impose

Trois fragilités :

La transition comme seule API de mutation

Plutôt qu’un setter, des méthodes intentionnelles — une par transition — qui valident l’état de départ, posent la donnée liée et changent le statut, le tout atomiquement :

public function markSigned(string $signatureTransactionId): void
{
    $this->ensureCanTransitionFrom(Status::Draft, Status::Signed);
    $this->signatureTransactionId = $signatureTransactionId;
    $this->signedAt = new \DateTimeImmutable();
    $this->status = Status::Signed;
}

public function markPaid(?string $paymentReference = null): void
{
    $this->ensureCanTransitionFrom(Status::Signed, Status::Paid);
    $this->paymentReference = $paymentReference;
    $this->paidAt = new \DateTimeImmutable();
    $this->status = Status::Paid;
}

public function markAwaitingEnrolment(string $provisionalContractNumber): void
{
    if ($provisionalContractNumber === '') {
        throw new \InvalidArgumentException('provisionalContractNumber requis');
    }
    $this->ensureCanTransitionFrom(Status::Paid, Status::AwaitingEnrolment);
    $this->provisionalContractNumber = $provisionalContractNumber;
    $this->status = Status::AwaitingEnrolment;
}

Le garde commun, privé, centralise la règle « on ne transitionne que depuis tel état » :

private function ensureCanTransitionFrom(Status $from, Status $to): void
{
    if ($this->status !== $from) {
        throw new \LogicException(sprintf(
            'Transition invalide : %s → %s (statut actuel : %s)',
            $from->value, $to->value, $this->status->value,
        ));
    }
}

Plus de setter public de statut. Pour changer l’état, on est obligé de passer par une transition nommée, qui ne s’exécute que depuis le bon état de départ. L’agrégat refuse lui-même les chemins illégaux.

L’invariant qui ne se discute pas : l’état terminal

Certaines transitions sont interdites, point. Une souscription déjà affiliée ne doit jamais retomber en failed — sinon on déclencherait un retry sur un contrat qui existe pour de vrai. Cet invariant vit dans l’entité, pas dans un if perdu dans un handler :

public function markFailed(string $reason): void
{
    if ($this->status === Status::Enrolled) {
        throw new \LogicException('Une souscription déjà affiliée ne peut pas passer à failed');
    }
    $this->lastError = $reason;
    $this->status = Status::Failed;
}

Noter aussi que markFailed n’utilise pas ensureCanTransitionFrom(X, Failed) : l’échec peut survenir depuis plusieurs états (signature KO, paiement KO, affiliation KO). La règle n’est donc pas « depuis quel état unique » mais « depuis tout sauf l’état terminal ». Modéliser la transition d’échec comme « tout sauf Enrolled » plutôt que comme une liste blanche évite d’avoir à maintenir l’énumération de tous les états d’origine possibles.

Les données append-only ne sont pas des transitions

Toutes les mutations ne sont pas des changements d’état. Enregistrer qu’un PDF a été poussé vers la GED, par exemple, est append-only et indépendant du statut :

public function recordPdfPushed(string $documentType, string $gedId): void
{
    if ($documentType === '' || $gedId === '') {
        throw new \InvalidArgumentException('documentType et gedId requis');
    }
    $this->pushedPdfs[$documentType] = $gedId;   // accumulation, jamais d'overwrite métier
}

La distinction est importante : les transitions valident l’état de départ et le font évoluer ; les opérations append-only accumulent sans toucher au cycle de vie. Les mélanger (par exemple guarder un recordPdfPushed par un ensureCanTransitionFrom) coupe inutilement de la flexibilité — un PDF peut être poussé à plusieurs moments du cycle.

Lessons learned

#php #symfony #ddd #doctrine #domain-modeling


Partager cet article :

Article précédent
Magic strings de session : un SessionManager typé
Article suivant
Remplacer un array de constantes par un enum PHP 8.1