# Implementing Multi‑Tenancy Scoping Across the VS API – A Step‑by‑Step Code Walkthrough

> Source: <https://dev.to/zaerohell/implementing-multi-tenancy-scoping-across-the-vs-api-a-step-by-step-code-walkthrough-1mio>
> Published: 2026-09-11 18:01:14+00:00

**TL;DR:** I finished Phase 4 of the multi‑tenancy migration by scoping every data‑access point to `organization_id`. The change lives in dozens of controllers/services (construction, brokers, stats, etc.) and required a handful of helper utilities to keep the code DRY and safe.

Our monolithic VS API was built for a single tenant. When we started onboarding multiple real‑estate agencies, every endpoint returned data for *all* organizations, which broke privacy and caused massive performance regressions. The symptom showed up in the test suite:

```
FAIL  apps/api/src/__tests__/stats.test.ts
  ✕ should return stats only for the requested organization (500 ms)

  Expected: {"organizationId":"org_123","visits":42}
  Received: {"organizationId":"org_123","visits":42,"otherOrgVisits":17}
```

The root cause: most controllers performed raw DB queries without filtering by `organization_id`. The code base also mixed “global” services (e.g., `whatsapp-ai.service.ts`) with tenant‑specific logic, making it impossible to enforce isolation at runtime.

My first attempt was to add a NestJS **Guard** that injected the tenant from the JWT and stored it in `request.organizationId`. I then tried to read that value inside each service:

```
@Injectable()
export class StatsService {
  async getStats(req: Request) {
    const orgId = req['organizationId']; // <-- first try
    return this.db.stats.findMany({ where: { organizationId: orgId } });
  }
}
```

Two problems surfaced quickly:

`@Req()` while others used the `ExecutionContext` directly, leading to `undefined` values.`Request` parameter just to fetch the tenant, polluting method signatures and making unit testing harder.
The guard approach also failed to cover background jobs (cron, queue workers) that run outside an HTTP request.

I introduced a tiny utility that extracts the tenant from *any* NestJS context (HTTP, RPC, or custom). The file lives at `apps/api/src/tenancy/tenant-context.ts`:

``` js
// apps/api/src/tenancy/tenant-context.ts
import { ExecutionContext } from '@nestjs/common';
import { Request } from 'express';

export class TenantContext {
  static getOrganizationId(context: ExecutionContext): string {
    const http = context.switchToHttp();
    const request = http.getRequest<Request>();
    if (request?.user?.organizationId) {
      return request.user.organizationId;
    }
    // Fallback for non‑HTTP contexts (e.g., cron jobs)
    const data = context.switchToRpc().getData();
    if (data?.organizationId) return data.organizationId;
    throw new Error('Organization ID not found in context');
  }
}
```

All services now receive the `ExecutionContext` instead of a raw `Request`. This keeps the API surface clean and works for background jobs.

Each controller was updated to call `TenantContext.getOrganizationId(context)` and pass the `orgId` down to its service. Below is the diff for `construction.controller.ts` (the biggest file in Phase 4):

```
--- a/apps/api/src/construction/construction.controller.ts
+++ b/apps/api/src/construction/construction.controller.ts
@@ -12,6 +12,7 @@ import { RequirePerm } from '../auth/permissions.decorator';
 import { ConstructionService } from './construction.service';
 import { CreateProjectDto } from './dto/create-project.dto';
+import { ExecutionContext } from '@nestjs/common';
+import { TenantContext } from '../tenancy/tenant-context';

 @Controller('construction')
 export class ConstructionController {
@@ -17,20 +18,21 @@ export class ConstructionController {
   // ── Proyecto de obra (auto-crea si no existe al primer GET) ───────────
   @Get()
   @RequirePerm("properties:read")
-  async getProject(
-    @Query('propertyId') propertyId: string,
-    @Req() req: Request,
-  ) {
-    const orgId = req.user.organizationId;
-    return await this.constructionService.getOrCreateProject(propertyId, orgId);
+  async getProject(@Query('propertyId') propertyId: string, @Req() req: Request, @Context() ctx: ExecutionContext) {
+    const orgId = TenantContext.getOrganizationId(ctx);
+    return await this.constructionService.getOrCreateProject(propertyId, orgId);
   }
```

Key points:

`@Context() ctx: ExecutionContext` (Nest’s built‑in injection) to keep the method signature identical for testing.`orgId` explicitly.
Each service that touched the DB was updated to accept `organizationId` as the first argument. Example from `virtual-tour.service.ts`:

```
--- a/apps/api/src/virtual-tour/virtual-tour.service.ts
+++ b/apps/api/src/virtual-tour/virtual-tour.service.ts
@@ -90,18 +90,18 @@ export class VirtualTourService {
   }

   /** Trae (o crea) el registro de tour para una propiedad. */
-  async getOrCreate(propertyId: string) {
-    const existing = await db<...>.findFirst({ where: { propertyId } });
+  async getOrCreate(organizationId: string, propertyId: string) {
+    const existing = await db<...>.findFirst({ where: { propertyId, organizationId } });
     if (existing) return existing;
-    return await db.tour.create({ data: { propertyId } });
+    return await db.tour.create({ data: { propertyId, organizationId } });
   }
```

All calls to `getOrCreate` in `virtual-tour.controller.ts` were patched accordingly:

```
--- a/apps/api/src/virtual-tour/virtual-tour.controller.ts
+++ b/apps/api/src/virtual-tour/virtual-tour.controller.ts
@@ -30,28 +30,28 @@ export class VirtualTourController {
   @Get()
   async status(@Param("id") id: string, @Context() ctx: ExecutionContext) {
-    return await this.tourService.getOrCreate(id);
+    const orgId = TenantContext.getOrganizationId(ctx);
+    return await this.tourService.getOrCreate(orgId, id);
   }
```

Phase 4 required us to repeat the same pattern across **10+** modules. Below is a quick inventory of the files touched and the specific scoping added:

| File | What was scoped | Example change | 
|---|---|---|
| `apps/api/src/brokers/brokers.controller.ts` | `broker` queries | `where: { organizationId }` | 
| `apps/api/src/whatsapp/whatsapp.controller.ts` | WhatsApp messages | `findMany({ where: { organizationId } })` | 
| `apps/api/src/whatsapp-ai/whatsapp-ai.service.ts` | AI prompt logs | `create({ data: { organizationId, ... } })` | 
| `apps/api/src/notifications/web-push.controller.ts` | Push subscriptions | `subscription.organizationId = orgId` | 
| `apps/api/src/stats/stats.controller.ts` | Aggregations | `groupBy({ by: ['organizationId'], ... })` | 
| `apps/api/src/feed/share-links.controller.ts` | Share links | `findUnique({ where: { id, organizationId } })` | 
| `apps/api/src/seasonal-pricing/seasonal-pricing.controller.ts` | Pricing tables | `where: { organizationId, season }` | 
| `apps/api/src/db/db.ts` | Connection pool | Added `attachPoolErrorHandler()` (see commit 933b7ef2) | 
| `apps/api/src/ **tests** /stats.test |  |  | 

*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.*

*Repo: `zaerohell/VS` · 2026-09-10*

#playadev #buildinpublic
