Multi-Tenancy Code Patterns Reference
MULTI_TENANCY_CODE_PATTERNS.md
Multi-Tenancy Code Patterns Reference
Project: MyClinicsoftSoft
Purpose: Common patterns and code examples for multi-tenant implementation
Last updated: 2026-04-23
Table of Contents
- API Route Patterns
- Database Query Patterns
- Server Component Patterns
- Error Handling Patterns
- Testing Patterns
- Migration Patterns
- Performance Patterns
1. API Route Patterns
Pattern 1.1: Basic Tenant-Guarded GET
// app/api/appointments/route.ts
import { verifySession } from '@/app/lib/dal';
import { getTenantContext } from '@/lib/tenant';
import Appointment from '@/models/Appointment';
import { Types } from 'mongoose';
export async function GET(req: Request) {
try {
// 1. Verify session
const session = await verifySession();
if (!session) {
return Response.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// 2. Get tenant context
const tenantContext = await getTenantContext();
const tenantId = session.tenantId || tenantContext.tenantId;
if (!tenantId) {
return Response.json(
{ error: 'No tenant context' },
{ status: 400 }
);
}
// 3. Check tenant is active (optional but recommended)
if (tenantContext.subdomain && !tenantContext.tenant) {
return Response.json(
{ error: 'Tenant not found or inactive' },
{ status: 404 }
);
}
// 4. Query with tenant filter
const appointments = await Appointment.find({
tenantId: new Types.ObjectId(tenantId)
})
.select('_id title startTime endTime patientId')
.sort({ startTime: -1 })
.limit(100);
return Response.json(appointments);
} catch (error) {
console.error('GET /api/appointments error:', error);
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Pattern 1.2: POST with Role Authorization
// app/api/appointments/route.ts
import { getTenantContext } from '@/lib/tenant';
import { verifySession } from '@/app/lib/dal';
import Permission from '@/models/Permission';
import Appointment from '@/models/Appointment';
import { Types } from 'mongoose';
export async function POST(req: Request) {
try {
// 1. Verify session + get payload
const session = await verifySession();
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const { patientId, title, startTime, endTime } = await req.json();
if (!patientId || !title || !startTime || !endTime) {
return Response.json({ error: 'Missing required fields' }, { status: 400 });
}
// 2. Get tenant context
const tenantContext = await getTenantContext();
const tenantId = session.tenantId || tenantContext.tenantId;
if (!tenantId) {
return Response.json({ error: 'No tenant' }, { status: 400 });
}
// 3. Check permission (role-based)
const permission = await Permission.findOne({
tenantId: new Types.ObjectId(tenantId),
roleId: session.roleId,
resource: 'appointments',
action: 'create'
});
if (!permission?.allowed) {
return Response.json(
{ error: 'Forbidden - insufficient permissions' },
{ status: 403 }
);
}
// 4. Validate patient belongs to this tenant
const Patient = require('@/models/Patient').default;
const patient = await Patient.findOne({
_id: new Types.ObjectId(patientId),
tenantIds: new Types.ObjectId(tenantId) // ← Array membership!
});
if (!patient) {
return Response.json(
{ error: 'Patient not found' },
{ status: 404 }
);
}
// 5. Create appointment
const appointment = await Appointment.create({
patientId: new Types.ObjectId(patientId),
tenantId: new Types.ObjectId(tenantId),
title,
startTime: new Date(startTime),
endTime: new Date(endTime),
status: 'scheduled',
createdBy: session.userId
});
// 6. Log action
const AuditLog = require('@/models/AuditLog').default;
await AuditLog.create({
tenantId: new Types.ObjectId(tenantId),
userId: session.userId,
action: 'create',
resource: 'appointments',
resourceId: appointment._id,
timestamp: new Date()
});
return Response.json(appointment, { status: 201 });
} catch (error) {
console.error('POST /api/appointments error:', error);
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Pattern 1.3: PATCH with Ownership Verification
// app/api/appointments/[id]/route.ts
import { verifySession } from '@/app/lib/dal';
import { getTenantContext } from '@/lib/tenant';
import Appointment from '@/models/Appointment';
import { Types } from 'mongoose';
export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await verifySession();
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantContext = await getTenantContext();
const tenantId = session.tenantId || tenantContext.tenantId;
if (!tenantId) {
return Response.json({ error: 'No tenant' }, { status: 400 });
}
const { title, notes } = await req.json();
// ✅ Key: Verify document belongs to this tenant
const appointment = await Appointment.findOne({
_id: new Types.ObjectId(params.id),
tenantId: new Types.ObjectId(tenantId) // ← Ownership check!
});
if (!appointment) {
return Response.json(
{ error: 'Appointment not found' },
{ status: 404 }
);
}
// Update allowed fields only
if (title) appointment.title = title;
if (notes) appointment.notes = notes;
appointment.updatedAt = new Date();
await appointment.save();
return Response.json(appointment);
} catch (error) {
console.error('PATCH /api/appointments/:id error:', error);
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Pattern 1.4: DELETE with Audit Trail
// app/api/appointments/[id]/route.ts
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await verifySession();
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantContext = await getTenantContext();
const tenantId = session.tenantId || tenantContext.tenantId;
if (!tenantId) {
return Response.json({ error: 'No tenant' }, { status: 400 });
}
// Verify permission
const permission = await Permission.findOne({
tenantId: new Types.ObjectId(tenantId),
roleId: session.roleId,
resource: 'appointments',
action: 'delete'
});
if (!permission?.allowed) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
// ✅ Verify ownership AND delete atomically
const appointment = await Appointment.findOneAndDelete({
_id: new Types.ObjectId(params.id),
tenantId: new Types.ObjectId(tenantId) // ← Prevent cross-tenant deletion!
});
if (!appointment) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
// Log deletion
await AuditLog.create({
tenantId: new Types.ObjectId(tenantId),
userId: session.userId,
action: 'delete',
resource: 'appointments',
resourceId: appointment._id,
timestamp: new Date()
});
return Response.json({ success: true });
} catch (error) {
console.error('DELETE error:', error);
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
2. Database Query Patterns
Pattern 2.1: Single-Tenant Query (Most Common)
// Models with tenantId (single ObjectId)
const appointment = await Appointment.findOne({
_id: appointmentId,
tenantId: new Types.ObjectId(tenantId)
});
const invoices = await Invoice.find({
tenantId: new Types.ObjectId(tenantId),
status: 'paid'
}).sort({ paidAt: -1 });
const prescription = await Prescription.updateOne(
{
_id: prescriptionId,
tenantId: new Types.ObjectId(tenantId) // ← Must include!
},
{ status: 'filled' }
);
Pattern 2.2: Multi-Tenant Patient Query
// Patient uses tenantIds array (can belong to multiple clinics)
// Get patient for current clinic
const patient = await Patient.findOne({
_id: patientId,
tenantIds: new Types.ObjectId(tenantId) // ← Array membership!
});
// Find patients visited by any clinic in the list
const patientIds = [clinicA_id, clinicB_id];
const patients = await Patient.find({
tenantIds: { $in: patientIds.map(id => new Types.ObjectId(id)) }
});
// Count patients per clinic
const patientsByClinic = await Patient.aggregate([
{ $match: { tenantIds: { $in: patientIds.map(id => new Types.ObjectId(id)) } } },
{ $unwind: '$tenantIds' },
{ $group: { _id: '$tenantIds', count: { $sum: 1 } } },
{ $lookup: {
from: 'tenants',
localField: '_id',
foreignField: '_id',
as: 'tenant'
}
}
]);
Pattern 2.3: Aggregation Pipeline
// ✅ Always filter by tenant first!
const appointmentStats = await Appointment.aggregate([
// 1. Filter by tenant FIRST
{ $match: { tenantId: new Types.ObjectId(tenantId) } },
// 2. Then aggregate
{
$group: {
_id: { status: '$status' },
count: { $sum: 1 },
avgDuration: { $avg: { $subtract: ['$endTime', '$startTime'] } }
}
},
// 3. Sort
{ $sort: { count: -1 } }
]);
Pattern 2.4: Populate with Tenant Filter
// Prevent cross-tenant reference leaks
const appointment = await Appointment.findOne({
_id: appointmentId,
tenantId: new Types.ObjectId(tenantId)
}).populate({
path: 'patientId',
match: { tenantIds: new Types.ObjectId(tenantId) }, // ← Filter populate!
select: 'name email phone'
});
// Multi-level populate
const visit = await Visit.findOne({
_id: visitId,
tenantId: new Types.ObjectId(tenantId)
}).populate({
path: 'appointmentId',
populate: {
path: 'patientId',
match: { tenantIds: new Types.ObjectId(tenantId) }
}
});
Pattern 2.5: Backward Compatibility (Null tenantId)
// For documents that existed before multi-tenancy
const query = tenantId
? { tenantId: new Types.ObjectId(tenantId) }
: {
$or: [
{ tenantId: { $exists: false } },
{ tenantId: null }
]
};
const docs = await MyModel.find(query);
Pattern 2.6: Bulk Operations
// Bulk update with tenant filter
const result = await Appointment.bulkWrite([
{
updateMany: {
filter: {
tenantId: new Types.ObjectId(tenantId),
status: 'pending',
startTime: { $lt: new Date() }
},
update: { $set: { status: 'missed' } }
}
},
{
deleteMany: {
filter: {
tenantId: new Types.ObjectId(tenantId),
status: 'cancelled',
deletedAt: { $lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } // 30 days old
}
}
}
]);
3. Server Component Patterns
Pattern 3.1: Render Tenant Name in Layout
// app/(app)/layout.tsx
import { getTenantContext } from '@/lib/tenant';
import { verifySession } from '@/app/lib/dal';
export default async function DashboardLayout({
children
}: {
children: React.ReactNode;
}) {
const session = await verifySession();
if (!session) {
redirect('/login');
}
// Get tenant context
const tenantContext = await getTenantContext();
// Handle inactive tenant
if (tenantContext.subdomain && !tenantContext.tenant) {
return <TenantNotFound subdomain={tenantContext.subdomain} />;
}
return (
<div>
<header>
<h1>{tenantContext.tenant?.displayName || 'Dashboard'}</h1>
</header>
{children}
</div>
);
}
Pattern 3.2: Fetch Tenant Settings in Component
// app/(app)/settings/page.tsx
import { getSettings } from '@/lib/settings';
import { getTenantContext } from '@/lib/tenant';
export default async function SettingsPage() {
const settings = await getSettings(); // Auto-resolves by tenantId
const tenantContext = await getTenantContext();
return (
<div>
<h2>Settings for {tenantContext.tenant?.name}</h2>
<dl>
<dt>Timezone</dt>
<dd>{settings.timezone}</dd>
<dt>Currency</dt>
<dd>{settings.currency}</dd>
</dl>
</div>
);
}
Pattern 3.3: Pass Tenant Context to Client Component
// app/(app)/appointments/page.tsx (Server)
import { getTenantContext } from '@/lib/tenant';
import AppointmentsClient from '@/components/AppointmentsClient';
export default async function AppointmentsPage() {
const tenantContext = await getTenantContext();
const tenantId = tenantContext.tenantId;
return (
<AppointmentsClient
tenantId={tenantId}
tenantName={tenantContext.tenant?.name}
/>
);
}
// components/AppointmentsClient.tsx (Client)
'use client';
import { useEffect, useState } from 'react';
export default function AppointmentsClient({
tenantId,
tenantName
}: {
tenantId: string | null;
tenantName?: string;
}) {
const [appointments, setAppointments] = useState([]);
useEffect(() => {
if (!tenantId) return;
fetch('/api/appointments')
.then(res => res.json())
.then(data => setAppointments(data));
}, [tenantId]);
return (
<div>
<h2>{tenantName}'s Appointments</h2>
{/* Render appointments */}
</div>
);
}
4. Error Handling Patterns
Pattern 4.1: Structured Error Responses
export async function GET(req: Request) {
try {
const session = await verifySession();
if (!session) {
return Response.json(
{
error: 'UNAUTHORIZED',
message: 'Session expired or invalid',
code: 401
},
{ status: 401 }
);
}
const tenantContext = await getTenantContext();
const tenantId = session.tenantId || tenantContext.tenantId;
if (!tenantId) {
return Response.json(
{
error: 'NO_TENANT',
message: 'Unable to determine tenant context',
code: 400
},
{ status: 400 }
);
}
// ... proceed
} catch (error) {
console.error('Unhandled error:', error);
return Response.json(
{
error: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
code: 500
},
{ status: 500 }
);
}
}
Pattern 4.2: Tenant-Aware Error Context
class TenantError extends Error {
constructor(
public message: string,
public statusCode: number,
public tenantId?: string,
public userId?: string
) {
super(message);
}
}
// Usage:
throw new TenantError(
'Patient not found',
404,
tenantId,
session.userId
);
// In middleware/error handler:
if (error instanceof TenantError) {
console.log(`[Tenant: ${error.tenantId}] ${error.message}`);
return Response.json(
{ error: error.message },
{ status: error.statusCode }
);
}
5. Testing Patterns
Pattern 5.1: Test Tenant Isolation
// __tests__/tenant-isolation.test.ts
import { Types } from 'mongoose';
import Appointment from '@/models/Appointment';
describe('Tenant Isolation', () => {
const tenantA_id = new Types.ObjectId();
const tenantB_id = new Types.ObjectId();
beforeAll(async () => {
await Appointment.create({
_id: new Types.ObjectId(),
tenantId: tenantA_id,
patientId: new Types.ObjectId(),
title: 'Clinic A Appointment',
startTime: new Date(),
endTime: new Date(Date.now() + 3600000)
});
await Appointment.create({
_id: new Types.ObjectId(),
tenantId: tenantB_id,
patientId: new Types.ObjectId(),
title: 'Clinic B Appointment',
startTime: new Date(),
endTime: new Date(Date.now() + 3600000)
});
});
it('should not return cross-tenant data', async () => {
// Clinic A queries
const clinicAAppts = await Appointment.find({
tenantId: tenantA_id
});
// Should only see Clinic A data
expect(clinicAAppts).toHaveLength(1);
expect(clinicAAppts[0].title).toBe('Clinic A Appointment');
expect(clinicAAppts[0].tenantId).toEqual(tenantA_id);
// Clinic B should NOT be in results
const clinicBInClinicA = clinicAAppts.find(
a => a.tenantId.equals(tenantB_id)
);
expect(clinicBInClinicA).toBeUndefined();
});
it('should fail if tenantId filter is missing', async () => {
// ⚠️ This demonstrates the vulnerability
const allAppts = await Appointment.find({});
// Should have 2 (from both clinics) — shows the bug!
expect(allAppts.length).toBeGreaterThan(1);
});
});
Pattern 5.2: Test API Route with Mocked Session
// __tests__/api/appointments.test.ts
import { GET } from '@/app/api/appointments/route';
jest.mock('@/app/lib/dal', () => ({
verifySession: jest.fn()
}));
jest.mock('@/lib/tenant', () => ({
getTenantContext: jest.fn()
}));
import { verifySession } from '@/app/lib/dal';
import { getTenantContext } from '@/lib/tenant';
describe('GET /api/appointments', () => {
it('should return 401 if no session', async () => {
(verifySession as jest.Mock).mockResolvedValue(null);
const req = new Request('http://localhost/api/appointments');
const response = await GET(req);
expect(response.status).toBe(401);
});
it('should return appointments for the tenant', async () => {
const tenantId = 'tenant-123';
(verifySession as jest.Mock).mockResolvedValue({
userId: 'user-1',
tenantId,
roleId: 'role-1'
});
(getTenantContext as jest.Mock).mockResolvedValue({
tenantId,
tenant: { name: 'Clinic A' }
});
const req = new Request('http://localhost/api/appointments');
const response = await GET(req);
expect(response.status).toBe(200);
const data = await response.json();
// Verify all returned appointments belong to this tenant
data.forEach(appt => {
expect(appt.tenantId).toBe(tenantId);
});
});
});
6. Migration Patterns
Pattern 6.1: Add tenantId to Existing Model
// scripts/migrate-add-tenantid.ts
import connectDB from '@/lib/mongodb';
import Appointment from '@/models/Appointment';
import Tenant from '@/models/Tenant';
import { Types } from 'mongoose';
async function migrateAppointments() {
await connectDB();
// Get the default/first tenant
const defaultTenant = await Tenant.findOne({
subdomain: 'default'
});
if (!defaultTenant) {
console.error('No default tenant found');
return;
}
// Update all appointments without tenantId
const result = await Appointment.updateMany(
{ tenantId: { $exists: false } },
{ $set: { tenantId: defaultTenant._id } }
);
console.log(`Migrated ${result.modifiedCount} appointments`);
}
migrateAppointments().catch(console.error);
Pattern 6.2: Verify Migration Success
// scripts/verify-migration.ts
async function verifyTenantIdPresence() {
const models = [Appointment, Invoice, Visit, Prescription];
for (const Model of models) {
const count = await Model.countDocuments({ tenantId: { $exists: false } });
if (count > 0) {
console.warn(`⚠️ ${Model.modelName}: ${count} documents missing tenantId`);
} else {
console.log(`✓ ${Model.modelName}: All documents have tenantId`);
}
}
}
7. Performance Patterns
Pattern 7.1: Efficient Pagination
// Paginate with tenant filter
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '50');
const skip = (page - 1) * limit;
const tenantId = /* ... */;
// Query + count (in parallel)
const [appointments, total] = await Promise.all([
Appointment.find({ tenantId: new Types.ObjectId(tenantId) })
.sort({ startTime: -1 })
.skip(skip)
.limit(limit)
.lean(), // ← Use lean() for read-only data
Appointment.countDocuments({ tenantId: new Types.ObjectId(tenantId) })
]);
return Response.json({
data: appointments,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
});
}
Pattern 7.2: Selective Field Fetching
// Don't fetch unnecessary fields
// ❌ Slow:
const appointments = await Appointment.find({ tenantId });
// ✅ Fast:
const appointments = await Appointment.find({ tenantId })
.select('_id title startTime endTime patientId')
.lean();
Pattern 7.3: Index Usage in Queries
// Ensure indexes exist on schema:
appointmentSchema.index({ tenantId: 1 });
appointmentSchema.index({ tenantId: 1, status: 1 });
appointmentSchema.index({ tenantId: 1, startTime: -1 });
// Query uses index:
const upcoming = await Appointment.find({
tenantId: new Types.ObjectId(tenantId),
startTime: { $gte: new Date() }
})
.sort({ startTime: 1 })
.limit(10);
References
- Full Architecture: MULTI_TENANCY.md
- Quick Start: MULTI_TENANCY_QUICK_START.md
- Security Deep Dive: TENANT_ISOLATION_SECURITY.md
Last updated: 2026-04-23