ATOM Documentation

← Back to App

Tenant Context Migration Guide

Problem: 887 Redundant Tenant Lookups

Every API route was calling getTenantFromRequest() independently, causing:

  • **2.2M+ Redis GET requests per day**
  • Slow response times (multiple Redis roundtrips per request)
  • High database load during cache misses

Solution: Tenant Context Singleton

The new architecture extracts tenant **once per request** and makes it available everywhere via AsyncLocalStorage.

---

Migration Options

**Best for**: New routes, major refactors

**Before**:

// src/app/api/users/route.ts
import { getTenantFromRequest } from '@/lib/tenant/tenant-extractor'

export async function GET(request: NextRequest) {
  const tenant = await getTenantFromRequest(request)  // ❌ Slow
  if (!tenant) {
    return NextResponse.json({ error: 'Tenant not found' }, { status: 404 })
  }

  const users = await db.query('SELECT * FROM users WHERE tenant_id = $1', [tenant.id])
  return NextResponse.json({ users })
}

**After**:

// src/app/api/users/route.ts
import { requireTenant } from '@/lib/middleware/tenant-middleware'

export const GET = requireTenant(async (tenant, request) => {  // ✅ Fast
  const users = await db.query('SELECT * FROM users WHERE tenant_id = $1', [tenant.id])
  return NextResponse.json({ users })
})

**Benefits**:

  • ✅ Zero lookups (tenant pre-extracted by middleware)
  • ✅ Cleaner code (no manual tenant checks)
  • ✅ Type-safe (tenant guaranteed to exist)

---

Option 2: Optimized Extractor

**Best for**: Quick migration of existing routes

**Before**:

import { getTenantFromRequest } from '@/lib/tenant/tenant-extractor'

export async function GET(request: NextRequest) {
  const tenant = await getTenantFromRequest(request)  // ❌ Lookup every time
  // ...
}

**After**:

import { getTenantOrThrow } from '@/lib/tenant/tenant-extractor-v2'

export async function GET(request: NextRequest) {
  const tenant = await getTenantOrThrow(request)  // ✅ Cached after first call
  // ...
}

**Benefits**:

  • ✅ Minimal code changes (1 import change)
  • ✅ Automatic caching after first call
  • ✅ Backward compatible

---

Option 3: Helper Functions

**Best for**: Services, utilities, background jobs

**Before**:

// src/lib/some-service.ts
export async function someServiceFunction(tenantId: string) {
  // Need to pass tenantId everywhere
  const data = await db.query('SELECT * FROM data WHERE tenant_id = $1', [tenantId])
  return data
}

// Usage in API route:
export async function GET(request: NextRequest) {
  const tenant = await getTenantFromRequest(request)
  const data = await someServiceFunction(tenant.id)  // ❌ Passing tenantId manually
}

**After**:

// src/lib/some-service.ts
import { getCurrentTenantOrThrow } from '@/lib/tenant/tenant-extractor-v2'

export async function someServiceFunction() {
  const tenant = getCurrentTenantOrThrow()  // ✅ No parameters needed!
  const data = await db.query('SELECT * FROM data WHERE tenant_id = $1', [tenant.id])
  return data
}

// Usage in API route:
export const GET = requireTenant(async (tenant, request) => {
  const data = await someServiceFunction()  // ✅ No tenant passing needed
})

**Benefits**:

  • ✅ Cleaner function signatures (no tenant parameters)
  • ✅ Automatic tenant context available everywhere
  • ✅ Better testability

---

Middleware Setup

Step 1: Add to Next.js Middleware (Optional)

If you want tenant extraction to happen automatically for ALL routes:

// middleware.ts
import { withTenant } from '@/lib/middleware/tenant-middleware'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  return withTenant(request, () => {
    // Tenant is now available via getCurrentTenant()
    return NextResponse.next()
  })
}

export const config = {
  matcher: ['/api/:path*', '/dashboard/:path*'],
}

Step 2: Migrate API Routes Gradually

You don't have to migrate all routes at once. The old and new patterns are compatible:

// This still works (but slower):
export async function GET(request: NextRequest) {
  const tenant = await getTenantFromRequest(request)  // ❌ Old way
  // ...
}

// This is faster (recommended):
export const GET = requireTenant(async (tenant, request) => {  // ✅ New way
  // ...
})

---

Testing

Test with Mock Tenant Context

import { withTenantContext, getCurrentTenant } from '@/lib/tenant/tenant-extractor-v2'
import { mockTenant } from '@/test/fixtures'

describe('My Service', () => {
  it('should use tenant context', async () => {
    await withTenantContext(mockTenant, async () => {
      const tenant = getCurrentTenant()
      expect(tenant?.id).toBe(mockTenant.id)

      // Test your service functions here
      const result = await myServiceFunction()
      expect(result.tenantId).toBe(mockTenant.id)
    })
  })
})

Clear Context Between Tests

import { clearTenantContext } from '@/lib/tenant/tenant-extractor-v2'

afterEach(() => {
  clearTenantContext()  // Clean up after each test
})

---

Performance Monitoring

Track Cache Hit Rate

import { getTenantContextStats } from '@/lib/tenant/tenant-extractor-v2'

export async function GET(request: NextRequest) {
  const stats = getTenantContextStats()
  console.log('Tenant context stats:', stats)

  // Output:
  // {
  //   hasContext: true,
  //   contextAge: 45,  // ms since extraction
  //   requestSignature: "req_1696838400000_abc123"
  // }

  // ... rest of handler
}

---

Common Patterns

Conditional Tenant Logic

import { getCurrentTenant, hasTenantContext } from '@/lib/tenant/tenant-extractor-v2'

export async function publicEndpoint() {
  if (hasTenantContext()) {
    const tenant = getCurrentTenant()!
    // Tenant-specific logic
  } else {
    // Public logic
  }
}

Background Jobs with Tenant Context

import { withTenantContext } from '@/lib/tenant/tenant-extractor-v2'

async function processBackgroundJob(tenantId: string) {
  const tenant = await db.query('SELECT * FROM tenants WHERE id = $1', [tenantId])

  await withTenantContext(tenant, async () => {
    // All code here has access to tenant via getCurrentTenant()
    await sendEmailNotifications()
    await generateReports()
    await cleanupOldData()
  })
}

Nested Function Calls

// API route
export const GET = requireTenant(async (tenant, request) => {
  const result = await orchestrateWorkflow()  // ✅ No tenant passing
  return NextResponse.json({ result })
})

// Orchestrator
async function orchestrateWorkflow() {
  const tenant = getCurrentTenantOrThrow()  // ✅ Available here
  const data = await fetchData()  // ✅ And here
  const processed = await processData(data)  // ✅ And here
  return processed
}

// Data service
async function fetchData() {
  const tenant = getCurrentTenantOrThrow()  // ✅ Still available!
  return db.query('SELECT * FROM data WHERE tenant_id = $1', [tenant.id])
}

// Processing service
async function processData(data: any[]) {
  const tenant = getCurrentTenantOrThrow()  // ✅ Available throughout call stack
  return data.filter(item => item.tenantId === tenant.id)
}

---

Rollout Plan

Phase 1: Enable Middleware (Week 1)

  1. Add tenant middleware to middleware.ts
  2. Monitor logs for context age and hit rate
  3. Verify no breaking changes

Phase 2: Migrate High-Traffic Routes (Week 2-3)

  1. Identify top 20 routes by traffic
  2. Migrate to requireTenant pattern
  3. Measure performance improvement

Phase 3: Migrate Remaining Routes (Week 4-6)

  1. Migrate remaining API routes
  2. Update helper functions to use getCurrentTenantOrThrow()
  3. Remove tenant parameter passing

Phase 4: Cleanup (Week 7)

  1. Remove old getTenantFromRequest calls
  2. Update documentation
  3. Deprecate old pattern

---

Expected Results

Before Migration

  • **Redis GETs**: 2.2M per day
  • **Avg latency**: 150ms per request (multiple Redis calls)
  • **Code complexity**: High (tenant passing everywhere)

After Migration

  • **Redis GETs**: ~440K per day (80% reduction)
  • **Avg latency**: 50ms per request (1 Redis call per request)
  • **Code complexity**: Low (tenant context automatic)

---

Troubleshooting

Issue: "Tenant context not available" error

**Cause**: Calling getCurrentTenantOrThrow() outside of request context

**Solution**:

// ❌ Wrong
export async function backgroundJob() {
  const tenant = getCurrentTenantOrThrow()  // Error!
}

// ✅ Correct
export async function backgroundJob(tenantId: string) {
  const tenant = await db.query('SELECT * FROM tenants WHERE id = $1', [tenantId])
  await withTenantContext(tenant, async () => {
    const currentTenant = getCurrentTenantOrThrow()  // Works!
    // ... rest of job
  })
}

Issue: Tenant context is stale

**Cause**: Long-running request (>5 seconds)

**Solution**: Tenant context TTL is 5 seconds by design. For long-running operations, re-fetch tenant:

import { getTenantOrThrow } from '@/lib/tenant/tenant-extractor-v2'

export async function longRunningOperation(request: NextRequest) {
  const tenant = await getTenantOrThrow(request)

  // ... operation that takes >5 seconds

  // Refresh tenant context if needed
  const freshTenant = await getTenantOrThrow(request)
}

---

FAQ

**Q: Do I need to migrate all routes at once?**

A: No, old and new patterns are compatible. Migrate gradually.

**Q: Will this break existing code?**

A: No, the old getTenantFromRequest() still works. This is an optimization.

**Q: What about server components?**

A: Server components can use getCurrentTenant() if wrapped in middleware, or pass tenant as prop.

**Q: How does this work with concurrent requests?**

A: AsyncLocalStorage ensures each request has its own isolated tenant context.

**Q: Can I use this in background jobs?**

A: Yes, use withTenantContext() to set tenant context for the job.

---

Need Help?

  • 📖 See /src/lib/middleware/tenant-middleware.ts for middleware API
  • 📖 See /src/lib/tenant/tenant-extractor-v2.ts for helper functions
  • 📖 See /src/lib/tenant/tenant-context.ts for implementation details