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 :
- Aucun ordre garanti. Rien n’interdit
Draft → Enrolleden sautant signature et paiement. - Données liées non atomiques. Le statut et la donnée qui doit l’accompagner (la référence de paiement quand on passe
Paid) peuvent diverger. - L’invariant est distribué. Chaque service réimplémente sa propre idée des transitions légales. Le jour où l’un se trompe, c’est silencieux.
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
- Pas de
setStatus()public sur une entité à cycle de vie. Exposez des transitions nommées (markSigned,markPaid…) qui valident l’état de départ et posent la donnée liée atomiquement. Le statut ne se mute que par une intention métier explicite. - L’invariant de transition appartient à l’agrégat, pas au service. Un service peut se tromper d’ordre ; une entité qui refuse les transitions illégales est juste par construction, où qu’on l’appelle.
- Modélisez l’état terminal comme une interdiction explicite. « Une fois affilié, jamais failed » est une règle métier — codez-la dans l’entité avec un message clair, pas dans un
ifde handler qu’on oubliera de répliquer. - Une transition d’échec se modélise souvent en « tout sauf l’état terminal », pas en liste blanche d’états d’origine. Ça évite de maintenir l’énumération de tous les chemins menant à l’échec.
- Séparez transitions et opérations append-only. Accumuler une donnée (PDF poussé, tentative enregistrée) n’est pas changer d’état : ne le guardez pas comme une transition, vous y perdriez de la souplesse sans gagner de sûreté.
- Couplez statut + donnée liée dans la même méthode. Passer
Paidet poser la référence de paiement doivent être indissociables. Deux setters distincts = deux occasions de diverger.
#php #symfony #ddd #doctrine #domain-modeling