Provisionner un tenant SaaS via AMQP : le pattern bout-en-bout
Une implémentation de provisioning multi-service qui sépare clairement orchestration métier, exécution asynchrone et activation du tenant dans une architecture SaaS réelle.
- Multi-tenant
- Integrations
- Engineering
Le provisioning d’un tenant n’est pas un simple “create account”. C’est un problème d’architecture : où s’arrête le cycle commercial, où commence le runtime de la plateforme, et comment faire traverser cette frontière sans couplage fragile.
Dans Cercly, cette chaîne traverse deux APIs indépendantes — operator-api (NestJS) et platform-api (Symfony/Sylius) — via RabbitMQ. Le sujet intéressant n’est pas RabbitMQ en lui-même. Le sujet intéressant, c’est la manière de découper les responsabilités, de rendre les transitions explicites et de faire tenir l’implémentation quand les deux côtés n’ont ni la même stack, ni la même sérialisation, ni la même base de données.
Le vrai problème d’architecture
On a deux périmètres métier distincts.
operator-apigère le cycle commercial : inscription, paiement, statut de la demande, relance opérateur.platform-apigère le runtime du tenant :Platform,ChannelSylius, locales, devises, compte admin.
Le point clé : platform-api ne doit pas connaître les détails du tunnel commercial, et operator-api ne doit pas piloter directement les écritures internes de la plateforme.
C’est cette séparation qui justifie le passage par un message métier, et non un appel HTTP synchrone de type “crée-moi le tenant maintenant”.
La forme du pipeline
Le flux complet ressemble à ça :
POST /api/applicationscrée une demande dansoperator-api- Stripe confirme le paiement
- la demande passe à l’état
provisioning operator-apipublieTenantProvisionRequestedplatform-apiconsomme et exécute le setup technique du tenantplatform-apipublieTenantProvisionedEventoperator-apipasse la demande àactive
operator-api (NestJS)
└── TenantProvisionRequested → RabbitMQ exchange platform.inbox
↓
platform-api worker (Symfony)
└── Platform setup + AdminUser bootstrap
└── TenantProvisionedEvent
↓
operator-api updates application state
Le pattern important n’est pas “on a mis Rabbit entre deux services”. Le pattern, c’est qu’un service reste propriétaire de son état métier, et qu’il expose uniquement un événement ou une commande assez stable pour que l’autre puisse travailler sans connaître son modèle interne.
L’état applicatif compte plus que le broker
Le broker transporte. Il ne donne pas, à lui seul, de visibilité métier.
Ce qui rend le pipeline opérable, c’est l’état explicite dans operator-api :
provisioningStatus: "pending" | "provisioning" | "active" | "failed";
provisioningAttempts: number;
provisioningError: string | null;
C’est cette machine d’état qui permet trois choses :
- montrer une progression lisible dans le dashboard opérateur ;
- reprendre un provisioning sans bricolage manuel ;
- distinguer un problème de paiement, un problème de setup plateforme et un problème de confirmation finale.
Autrement dit : la récupérabilité ne vient pas de RabbitMQ. Elle vient du fait que l’état applicatif raconte où on en est et ce qu’on peut refaire.
Un contrat de message minimal mais stable
Le contrat publié par operator-api est volontairement petit :
// src/amqp/messages/tenant-provision-requested.ts
export interface TenantProvisionRequested {
applicationUuid: string;
tenantCode: string;
adminEmail: string;
planCode: string;
}
Ce contrat n’essaie pas de refléter toute la base operator-api. Il contient uniquement ce dont platform-api a besoin pour construire son propre état.
C’est un point de maturité important : un message inter-service n’est pas un dump d’ORM. C’est une frontière d’intégration. Plus il est petit et intentionnel, plus il tient dans le temps.
L’implémentation côté plateforme
Le worker Symfony ne “termine pas une inscription”. Il exécute un bootstrap technique du tenant.
Concrètement, il fait trois choses :
- créer la
Platformet les éléments de segmentation associés ; - initialiser le socle Sylius (
Channel, locales, devises) ; - préparer le compte admin et le parcours d’activation.
Le code utile ici n’est pas spectaculaire. Ce qui compte, c’est qu’il soit idempotent et qu’il sache repartir proprement :
// src/Platform/PlatformSetupService.php
public function ensureAdminUser(TenantProvisionRequested $message): void
{
$platform = $this->platformRepository->findOneByCode($message->tenantCode);
$existing = $this->adminUserRepository->findOneByEmail($message->adminEmail);
if ($existing !== null) {
return;
}
$admin = new AdminUser();
$admin->setEmail($message->adminEmail);
$admin->setUsername($message->adminEmail);
$admin->setPlatform($platform);
$admin->setEnabled(false);
$admin->generateVerificationToken();
$this->adminUserRepository->add($admin);
$this->mailer->sendActivationEmail($admin, $platform);
}
L’intérêt de cet extrait n’est pas l’envoi d’email. C’est le fait que le setup soit pensé comme une série d’opérations rejouables, pas comme un script one-shot qu’on espère voir réussir du premier coup.
Le vrai piège d’implémentation : la frontière NestJS/Symfony
Le problème de production le plus instructif n’a pas été un bug métier. C’était un bug de contrat technique entre deux stacks.
Le publisher NestJS envoyait du JSON brut. Le consumer Symfony Messenger attendait une enveloppe compatible avec son transport AMQP. Résultat : le worker essayait de unserialize() du JSON.
Could not decode Envelope: unserialize(): Error at offset 0 of 487 bytes
La correction n’a pas consisté à “réparer RabbitMQ”. Elle a consisté à expliciter la frontière d’intégration :
const envelope = {
body: JSON.stringify(body),
headers: {
type: messageType,
"Content-Type": "application/json"
}
};
Puis côté Symfony, à assumer ce contrat avec un serializer dédié :
$type = $headers['type'] ?? null;
$class = self::INCOMING[$type] ?? null;
return new Envelope($this->serializer->deserialize($body, $class, 'json'));
C’est un bon rappel : dans une architecture polyglotte, la vraie difficulté n’est pas le transport. C’est la discipline sur les contrats.
Ce que ce pattern montre vraiment
L’intérêt de ce pipeline n’est pas qu’il soit “asynchrone”. Beaucoup de systèmes deviennent plus flous, pas plus robustes, dès qu’on ajoute un broker.
Ce qui le rend défendable techniquement, c’est plutôt ceci :
- chaque service garde sa responsabilité métier ;
- l’état visible par l’opérateur reste dans le service qui porte la relation commerciale ;
- le message transporte une intention stable, pas un modèle interne complet ;
- le bootstrap plateforme est rejouable et idempotent ;
- la reprise sur erreur est pensée comme une capacité produit, pas comme un patch d’exploitation.
C’est ce genre de choix qui montre une maîtrise du sujet devant une audience tech : pas “on utilise RabbitMQ”, mais “voilà comment on tient une frontière inter-service sans perdre lisibilité, reprise et évolutivité”.
Ce que j’aurais fait encore plus tôt
J’aurais ajouté dès le départ un test d’intégration qui publie un vrai message depuis le publisher NestJS et le fait consommer par le worker Symfony via RabbitMQ.
On avait les briques. On n’avait pas le test qui valide la frontière complète. C’est exactement là que les architectures cross-stack cassent en production.