Skip to content
5 min read Moustakime KIFIA

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
Provisioning a SaaS tenant via AMQP: end-to-end pattern

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-api owns the commercial lifecycle: signup, payment, application status, operator retries.
  • platform-api owns the tenant runtime: Platform, Sylius Channel, 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:

  1. POST /api/applications creates an application in operator-api
  2. Stripe confirms the payment
  3. the application moves to provisioning
  4. operator-api publishes TenantProvisionRequested
  5. platform-api consumes and runs tenant technical setup
  6. platform-api publishes TenantProvisionedEvent
  7. operator-api moves the application to active
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 Platform and 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.

Keep reading

A few related articles to extend the topic and keep the internal linking strong.