cd /news/developer-tools/implementing-multi-tenancy-scoping-a… · home topics developer-tools article
[ARTICLE · art-127114] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer completed Phase 4 of a multi-tenancy migration for the VS API, a real-estate monolith that previously returned data across all organizations. The work scopes every data-access point to organization_id using a new TenantContext utility that extracts the tenant from any NestJS execution context, including HTTP requests and background jobs. Controllers and services were updated to pass the organization ID down rather than reading it from raw Request objects.

by read4 min views3 publishedSep 11, 2026

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:

// 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 series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-09-10

#playadev #buildinpublic

── more in #developer-tools 4 stories · sorted by recency
── more on @vs api 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/implementing-multi-t…] indexed:0 read:4min 2026-09-11 ·