Provisioning a SaaS tenant via AMQP: end-to-end pattern
A multi-service provisioning implementation that cleanly separates business orchestration, asynchronous execution and tenant activation in a real SaaS architecture.
- Multi-tenant
- Integrations
- Engineering
Tenant provisioning is not a simple “create account” flow. It is an architecture problem: where the commercial lifecycle ends, where the platform runtime begins, and how to cross that boundary without fragile coupling.
In Cercly, that chain crosses two independent APIs — operator-api (NestJS) and platform-api (Symfony/Sylius) — over RabbitMQ. The interesting part is not RabbitMQ itself. The interesting part is how responsibilities are split, how transitions stay explicit, and how the implementation holds when both sides do not share the same stack, serializer or database.
The real architecture problem
We have two distinct business domains.
operator-apiowns the commercial lifecycle: signup, payment, application status, operator retries.platform-apiowns the tenant runtime:Platform, SyliusChannel, locales, currencies, admin account.
The key point is this: platform-api must not know the details of the commercial funnel, and operator-api must not directly orchestrate the platform’s internal writes.
That separation is exactly why a business message makes sense here, instead of a synchronous HTTP call that says “create the tenant now”.
The shape of the pipeline
The full flow looks like this:
POST /api/applicationscreates an application inoperator-api- Stripe confirms the payment
- the application moves to
provisioning operator-apipublishesTenantProvisionRequestedplatform-apiconsumes and runs tenant technical setupplatform-apipublishesTenantProvisionedEventoperator-apimoves the application toactive
operator-api (NestJS)
└── TenantProvisionRequested → RabbitMQ exchange platform.inbox
↓
platform-api worker (Symfony)
└── Platform setup + AdminUser bootstrap
└── TenantProvisionedEvent
↓
operator-api updates application state
The important pattern is not “we put Rabbit between two services”. The pattern is that one service stays the owner of its business state, and only exposes an event or command stable enough for the other side to work without knowing its internal model.
Application state matters more than the broker
The broker transports messages. It does not, by itself, provide business visibility.
What makes this pipeline operable is the explicit state in operator-api:
provisioningStatus: "pending" | "provisioning" | "active" | "failed";
provisioningAttempts: number;
provisioningError: string | null;
That state machine gives us three things:
- readable progress in the operator dashboard;
- a retry path without manual cleanup;
- a clean distinction between payment issues, platform setup issues and final confirmation issues.
In other words: recoverability does not come from RabbitMQ. It comes from application state that explains where the process is and what can be retried.
A minimal but stable message contract
The contract published by operator-api is intentionally small:
// src/amqp/messages/tenant-provision-requested.ts
export interface TenantProvisionRequested {
applicationUuid: string;
tenantCode: string;
adminEmail: string;
planCode: string;
}
This contract does not try to mirror the whole operator-api database. It only contains what platform-api needs to build its own state.
That is an important maturity signal: an inter-service message is not an ORM dump. It is an integration boundary. The smaller and more intentional it is, the longer it survives.
Platform-side implementation
The Symfony worker is not “finishing a signup”. It is running technical tenant bootstrap.
Concretely, it does three things:
- create the
Platformand the related segmentation layer; - initialize the Sylius base (
Channel, locales, currencies); - prepare the admin account and activation path.
The useful code here is not flashy. What matters is that it is idempotent and restartable:
// 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);
}
The point of this excerpt is not the email. It is that the setup is designed as a sequence of replayable operations, not as a one-shot script we merely hope will succeed on the first try.
The real implementation trap: the NestJS/Symfony boundary
The most instructive production issue was not a business bug. It was a contract bug between two stacks.
The NestJS publisher was sending raw JSON. The Symfony Messenger consumer expected an AMQP transport envelope. The worker ended up trying to unserialize() JSON.
Could not decode Envelope: unserialize(): Error at offset 0 of 487 bytes
The fix was not “repair RabbitMQ”. It was to make the integration boundary explicit:
const envelope = {
body: JSON.stringify(body),
headers: {
type: messageType,
"Content-Type": "application/json"
}
};
Then, on the Symfony side, to own that contract with a dedicated serializer:
$type = $headers['type'] ?? null;
$class = self::INCOMING[$type] ?? null;
return new Envelope($this->serializer->deserialize($body, $class, 'json'));
It is a good reminder: in a polyglot architecture, the hard part is not transport. It is contract discipline.
What this pattern actually demonstrates
The value of this pipeline is not that it is “asynchronous”. Many systems become blurrier, not more robust, as soon as a broker is introduced.
What makes this one technically defensible is this:
- each service keeps its business responsibility;
- operator-visible state stays in the service that owns the commercial relationship;
- the message carries a stable intent, not a full internal model;
- platform bootstrap is replayable and idempotent;
- failure recovery is designed as a product capability, not an operations patch.
That is the kind of point that signals mastery to a technical audience: not “we use RabbitMQ”, but “here is how we keep an inter-service boundary explicit without losing readability, recoverability or evolvability”.
What I would add even earlier
I would have added, from day one, an integration test that publishes a real message from the NestJS publisher and consumes it through the Symfony worker over RabbitMQ.
We had the building blocks. We did not have the test that validates the full boundary. That is exactly where cross-stack architectures tend to break in production.