Une API tierce d’affiliation n’était pas encore livrée, mais le reste du parcours devait avancer. Décision classique : la mocker derrière une interface, avec une implémentation locale qui génère un numéro de contrat provisoire court et lisible — du genre PROV-20251209-a3f8b2c1. La partie aléatoire de ce numéro était dérivée de l’UUID v7 de l’entité.
Le réflexe pour « prendre 8 caractères d’un UUID » :
$suffix = substr($uuid->toRfc4122(), 0, 8); // ⚠️ les 8 PREMIERS
C’est précisément ce qu’il ne faut pas faire avec un UUID v7. Et le bug ne se voit qu’en charge.
Pourquoi les 8 premiers caractères collisionnent
Un UUID v7 (RFC 9562) n’est pas aléatoire sur toute sa longueur. Sa structure est, en gros :
018f2a1b-4c3d-7xxx-yxxx-zzzzzzzzzzzz
└──────┬──────┘ │ └──────┬──────┘
timestamp ms version aléatoire
(48 bits) /variant
Les 48 premiers bits encodent un horodatage en millisecondes. C’est voulu : c’est ce qui rend les UUID v7 triables par ordre de création (excellent pour des clés primaires, ça évite la fragmentation d’index des UUID v4). Mais ça a une conséquence directe :
Deux entités créées dans la même milliseconde partagent les premiers caractères de leur UUID.
Donc substr($uuid, 0, 8) sur deux souscriptions quasi-simultanées (une rafale d’inscriptions, un batch, un test de charge) produit le même suffixe → collision de numéros provisoires. Exactement la classe de bug qu’on croyait éliminer en partant d’un UUID « unique ».
La partie réellement aléatoire — et donc anti-collision — est à la fin de l’UUID :
// -8 = on prend la queue, partie 100 % aléatoire d'un UUID v7
$suffix = substr($uuid->toRfc4122(), -8);
L’implémentation mock, derrière une interface
L’intérêt de mocker une dépendance non livrée, c’est de figer son contrat maintenant pour brancher l’implémentation réelle plus tard sans rien changer en amont. D’où un port (interface) dans le domaine, neutre vis-à-vis de l’infrastructure :
interface ContractNumberGateway
{
/**
* Retourne un numéro de contrat unique et stable pour ces données.
*
* @throws ContractNumberException si l'attribution échoue
*/
public function assign(Uuid $subscriptionId, \DateTimeImmutable $createdAt): string;
}
Deux décisions de conception dans cette signature :
- On passe un
Uuidet unDateTimeImmutable, pas l’entité Doctrine. Le domaine ne doit pas dépendre de l’ORM (DIP). En prenant des types neutres (symfony/uid,DateTimeImmutable), le port reste pur et l’implémentation réelle — qui appellera une API HTTP — ne traînera pas l’entité avec elle. - Le contrat est documenté comme idempotent. Appelé deux fois avec les mêmes arguments, il doit rendre le même numéro. C’est ce qui autorisera de rejouer l’attribution quand l’API réelle arrivera, sans créer de doublon.
L’implémentation mock, justement, est idempotente par construction — elle est une fonction pure de ses entrées, aucune génération aléatoire à l’appel :
final class LocalContractNumberGateway implements ContractNumberGateway
{
public function assign(Uuid $subscriptionId, \DateTimeImmutable $createdAt): string
{
$datePrefix = $createdAt->format('Ymd');
// -8 : queue aléatoire de l'UUID v7 (les premiers chars = timestamp ms → collisions)
$randomSuffix = substr($subscriptionId->toRfc4122(), -8);
return sprintf('PROV-%s-%s', $datePrefix, $randomSuffix);
}
}
Comme le numéro est dérivé des entrées (et non tiré au sort), le rappeler donne le même résultat : l’idempotence est gratuite. Si j’avais fait random_bytes() à l’intérieur, j’aurais dû persister le numéro pour le rejouer — ici, pas besoin.
Le branchement par feature flag
Le choix de l’implémentation se fait par variable d’environnement, ce qui rend la bascule mock → réel triviale le jour de la livraison :
# services.yaml — alias résolu par env
App\Domain\Affiliation\ContractNumberGateway:
alias: 'App\Infrastructure\Affiliation\%env(CONTRACT_NUMBER_GATEWAY)%'
# .env : mock par défaut tant que l'API n'est pas livrée
CONTRACT_NUMBER_GATEWAY=LocalContractNumberGateway
Le jour où l’API réelle arrive : on code HttpContractNumberGateway (qui implémente la même interface), on bascule la variable, et tout le code en amont — qui ne connaît que ContractNumberGateway — ne bouge pas d’une ligne.
Lessons learned
- Un UUID v7 n’est aléatoire qu’à la fin. Ses premiers bits encodent un timestamp en millisecondes (c’est ce qui le rend triable). Pour en extraire un identifiant court anti-collision, prenez la queue (
substr($uuid, -8)), jamais la tête. - « C’est un UUID, donc c’est unique » est un faux ami quand on en tronque un morceau. L’unicité est globale, pas répartie uniformément. Deux v7 de la même ms partagent leur préfixe. Sachez où vit l’entropie avant de découper.
- Mockez une dépendance non livrée derrière une interface dès maintenant. On fige le contrat (signature + invariants) pendant que le reste avance ; l’implémentation réelle se branche plus tard sans toucher l’amont.
- Gardez le port domaine neutre vis-à-vis de l’ORM. Passez
Uuid + DateTimeImmutable, pas l’entité Doctrine. Le domaine ne doit pas dépendre de l’infrastructure (DIP) — et l’implémentation HTTP future n’en sera que plus simple. - Une fonction pure de ses entrées est idempotente gratuitement. Dériver le numéro des arguments (plutôt que le tirer au sort) le rend rejouable sans persistance supplémentaire — précieux quand il faudra rejouer en batch contre l’API réelle.
- Sélectionnez l’implémentation par feature flag (env + alias de service). La bascule mock → réel devient une variable d’environnement, pas une modification de code.
#php #uuid #ddd #symfony #design