Aller au contenu
Sylvain Joffraud l4w
Retour

UUID v7 : pas les 8 premiers caractères pour un ID court

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 :

  1. On passe un Uuid et un DateTimeImmutable, 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.
  2. 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

#php #uuid #ddd #symfony #design


Partager cet article :

Article précédent
Apache + PaaS moderne : cohabiter sur un seul serveur
Article suivant
Coolify : le PaaS open source qui remplace Vercel et Netlify