Multi-Tenant Architecture
SkillAI & modelsA skill for ai & models by thelobbi.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Multi-Tenant Architecture skill
What this skill tells your AI
The instructions your AI receives, as published by thelobbi/claude in .claude/skills/multi-tenant/SKILL.md and read by ahel’s review.
Multi-tenant architecture skill for Lobbi member management system. Activates when working with tenant isolation, organization context, or row-level security patterns.
Triggers: multi-tenant, tenant, org_id, organization, isolation, tenant-aware, row-level-security, rls, organization_id
Use this skill when:
- Implementing tenant-scoped database queries
- Adding organization context to API endpoints
- Enforcing tenant isolation in repositories
- Working with tenant middleware or context propagation
- Designing multi-tenant data models
Allowed Tools
- prisma (ORM and migrations)
- postgresql (database operations)
- express (middleware and routing)
- typescript (type safety)
- jest (testing tenant isolation)
Instructions
Core Principles
-
Tenant Context Propagation
- Every request must carry tenant context (org_id)
- Middleware extracts org_id from JWT or headers
- Context flows through service → repository layers
- Never allow cross-tenant data leakage
-
Database Isolation Patterns
- Use row-level filtering with org_id in WHERE clauses
- Prisma models must include organizationId field
- Composite unique constraints: [organizationId, businessKey]
- Never expose global IDs without tenant checks
-
Tenant-Aware Repository Pattern
- All repository methods accept TenantContext
- Automatic org_id filtering in base repository
- Explicit tenant validation in create/update operations
- Audit logging includes tenant information
Architecture Layers
Request → AuthMiddleware → TenantMiddleware → Controller → Service → Repository → Database
(extract JWT) (set context) (validate) (logic) (filter) (RLS)
Implementation Checklist
- Add organizationId to all tenant-scoped Prisma models
- Implement tenant context middleware
- Create BaseTenantRepository with auto-filtering
- Add tenant validation to all CRUD operations
- Implement tenant-aware error handling
- Add tenant isolation tests
- Document tenant security boundaries
Code Examples
1. Prisma Schema Pattern
// backend/prisma/schema.prisma
model Member {
id String @id @default(cuid())
organizationId String @map("organization_id")
email String
firstName String @map("first_name")
lastName String @map("last_name")
status MemberStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
organization Organization @relation(fields: [organizationId], references: [id])
memberships Membership[]
// Composite unique constraint: email unique per tenant
@@unique([organizationId, email])
@@index([organizationId, status])
@@index([organizationId, email])
@@map("members")
}
model Membership {
id String @id @default(cuid())
organizationId String @map("organization_id")
memberId String @map("member_id")
tierId String @map("tier_id")
status MembershipStatus @default(PENDING)
startDate DateTime @map("start_date")
endDate DateTime? @map("end_date")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
organization Organization @relation(fields: [organizationId], references: [id])
member Member @relation(fields: [memberId], references: [id])
tier MembershipTier @relation(fields: [tierId], references: [id])
@@unique([organizationId, memberId, tierId])
@@index([organizationId, status])
@@map("memberships")
}
2. Tenant Context Types
// backend/src/types/tenant.types.ts
export interface TenantContext {
organizationId: string;
userId?: string;
permissions?: string[];
}
export interface TenantRequest extends Request {
tenant: TenantContext;
}
export class TenantError extends Error {
constructor(message: string, public organizationId?: string) {
super(message);
this.name = 'TenantError';
}
}
3. Tenant Context Middleware
// backend/src/middleware/tenant.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { TenantRequest, TenantContext } from '../types/tenant.types';
import { UnauthorizedException } from '../exceptions';
export const tenantMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
const tenantReq = req as TenantRequest;
// Extract org_id from JWT claims (set by auth middleware)
const organizationId = tenantReq.user?.organizationId;
if (!organizationId) {
throw new UnauthorizedException('Organization context required');
}
// Set tenant context
tenantReq.tenant = {
organizationId,
userId: tenantReq.user?.id,
permissions: tenantReq.user?.permissions || []
};
next();
};
// Optional: Override org_id from header (admin/support use only)
export const tenantOverrideMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
const tenantReq = req as TenantRequest;
const overrideOrgId = req.headers['x-organization-id'] as string;
// Only allow override if user has admin role
if (overrideOrgId && tenantReq.user?.role === 'SUPER_ADMIN') {
tenantReq.tenant.organizationId = overrideOrgId;
}
next();
};
4. Base Tenant Repository
// backend/src/repositories/base-tenant.repository.ts
import { PrismaClient } from '@prisma/client';
import { TenantContext } from '../types/tenant.types';
export abstract class BaseTenantRepository<T> {
constructor(protected prisma: PrismaClient) {}
/**
* Find by ID with automatic tenant filtering
*/
protected async findByIdWithTenant(
model: any,
id: string,
tenant: TenantContext
): Promise<T | null> {
return model.findFirst({
where: {
id,
organizationId: tenant.organizationId
}
});
}
/**
* Find many with automatic tenant filtering
*/
protected async findManyWithTenant(
model: any,
where: any,
tenant: TenantContext
): Promise<T[]> {
return model.findMany({
where: {
...where,
organizationId: tenant.organizationId
}
});
}
/**
* Create with automatic tenant context
*/
protected async createWithTenant(
model: any,
data: any,
tenant: TenantContext
): Promise<T> {
return model.create({
data: {
...data,
organizationId: tenant.organizationId
}
});
}
/**
* Update with tenant validation
*/
protected async updateWithTenant(
model: any,
id: string,
data: any,
tenant: TenantContext
): Promise<T> {
// First verify record belongs to tenant
const existing = await this.findByIdWithTenant(model, id, tenant);
if (!existing) {
throw new Error(`Record ${id} not found for organization ${tenant.organizationId}`);
}
return model.update({
where: { id },
data
});
}
/**
* Delete with tenant validation
*/
protected async deleteWithTenant(
model: any,
id: string,
tenant: TenantContext
): Promise<T> {
// First verify record belongs to tenant
const existing = await this.findByIdWithTenant(model, id, tenant);
if (!existing) {
throw new Error(`Record ${id} not found for organization ${tenant.organizationId}`);
}
return model.delete({
where: { id }
});
}
}
5. Concrete Repository Implementation
// backend/src/repositories/member.repository.ts
import { PrismaClient, Member } from '@prisma/client';
import { BaseTenantRepository } from './base-tenant.repository';
import { TenantContext } from '../types/tenant.types';
export interface CreateMemberDto {
email: string;
firstName: string;
lastName: string;
phoneNumber?: string;
status?: 'ACTIVE' | 'INACTIVE';
}
export interface UpdateMemberDto {
email?: string;
firstName?: string;
lastName?: string;
phoneNumber?: string;
status?: 'ACTIVE' | 'INACTIVE';
}
export class MemberRepository extends BaseTenantRepository<Member> {
constructor(prisma: PrismaClient) {
super(prisma);
}
async findById(id: string, tenant: TenantContext): Promise<Member | null> {
return this.findByIdWithTenant(this.prisma.member, id, tenant);
}
async findByEmail(email: string, tenant: TenantContext): Promise<Member | null> {
return this.prisma.member.findUnique({
where: {
organizationId_email: {
organizationId: tenant.organizationId,
email
}
}
});
}
async findAll(tenant: TenantContext, filters?: {
status?: string;
search?: string;
}): Promise<Member[]> {
const where: any = {
organizationId: tenant.organizationId
};
if (filters?.status) {
where.status = filters.status;
}
if (filters?.search) {
where.OR = [
{ email: { contains: filters.search, mode: 'insensitive' } },
{ firstName: { contains: filters.search, mode: 'insensitive' } },
{ lastName: { contains: filters.search, mode: 'insensitive' } }
];
}
return this.prisma.member.findMany({ where });
}
async create(data: CreateMemberDto, tenant: TenantContext): Promise<Member> {
// Check for duplicate email within tenant
const existing = await this.findByEmail(data.email, tenant);
if (existing) {
throw new Error(`Member with email ${data.email} already exists`);
}
return this.createWithTenant(this.prisma.member, data, tenant);
}
async update(
id: string,
data: UpdateMemberDto,
tenant: TenantContext
): Promise<Member> {
// If updating email, check for duplicates
if (data.email) {
const existing = await this.findByEmail(data.email, tenant);
if (existing && existing.id !== id) {
throw new Error(`Member with email ${data.email} already exists`);
}
}
return this.updateWithTenant(this.prisma.member, id, data, tenant);
}
async delete(id: string, tenant: TenantContext): Promise<Member> {
return this.deleteWithTenant(this.prisma.member, id, tenant);
}
async count(tenant: TenantContext): Promise<number> {
return this.prisma.member.count({
where: { organizationId: tenant.organizationId }
});
}
}
6. Service Layer with Tenant Context
// backend/src/services/member.service.ts
import { MemberRepository, CreateMemberDto, UpdateMemberDto } from '../repositories/member.repository';
import { TenantContext } from '../types/tenant.types';
import { Member } from '@prisma/client';
export class MemberService {
constructor(private memberRepository: MemberRepository) {}
async getMember(id: string, tenant: TenantContext): Promise<Member | null> {
return this.memberRepository.findById(id, tenant);
}
async getMembers(tenant: TenantContext, filters?: {
status?: string;
search?: string;
}): Promise<Member[]> {
return this.memberRepository.findAll(tenant, filters);
}
async createMember(data: CreateMemberDto, tenant: TenantContext): Promise<Member> {
// Business logic validation
if (!this.isValidEmail(data.email)) {
throw new Error('Invalid email format');
}
return this.memberRepository.create(data, tenant);
}
async updateMember(
id: string,
data: UpdateMemberDto,
tenant: TenantContext
): Promise<Member> {
if (data.email && !this.isValidEmail(data.email)) {
throw new Error('Invalid email format');
}
return this.memberRepository.update(id, data, tenant);
}
async deleteMember(id: string, tenant: TenantContext): Promise<void> {
await this.memberRepository.delete(id, tenant);
}
private isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
}
7. Controller with Tenant Context
// backend/src/controllers/member.controller.ts
import { Response, NextFunction } from 'express';
import { TenantRequest } from '../types/tenant.types';
import { MemberService } from '../services/member.service';
export class MemberController {
constructor(private memberService: MemberService) {}
async getMembers(req: TenantRequest, res: Response, next: NextFunction) {
try {
const { status, search } = req.query;
const members = await this.memberService.getMembers(
req.tenant,
{
status: status as string,
search: search as string
}
);
res.json({ data: members });
} catch (error) {
next(error);
}
}
async getMember(req: TenantRequest, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const member = await this.memberService.getMember(id, req.tenant);
if (!member) {
return res.status(404).json({ error: 'Member not found' });
}
res.json({ data: member });
} catch (error) {
next(error);
}
}
async createMember(req: TenantRequest, res: Response, next: NextFunction) {
try {
const member = await this.memberService.createMember(
req.body,
req.tenant
);
res.status(201).json({ data: member });
} catch (error) {
next(error);
}
}
async updateMember(req: TenantRequest, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const member = await this.memberService.updateMember(
id,
req.body,
req.tenant
);
res.json({ data: member });
} catch (error) {
next(error);
}
}
async deleteMember(req: TenantRequest, res: Response, next: NextFunction) {
try {
const { id } = req.params;
await this.memberService.deleteMember(id, req.tenant);
res.status(204).send();
} catch (error) {
next(error);
}
}
}
8. Router with Tenant Middleware
// backend/src/routes/member.routes.ts
import { Router } from 'express';
import { MemberController } from '../controllers/member.controller';
import { authMiddleware } from '../middleware/auth.middleware';
import { tenantMiddleware } from '../middleware/tenant.middleware';
export const createMemberRouter = (memberController: MemberController): Router => {
const router = Router();
// All routes require authentication and tenant context
router.use(authMiddleware);
router.use(tenantMiddleware);
router.get('/', memberController.getMembers.bind(memberController));
router.get('/:id', memberController.getMember.bind(memberController));
router.post('/', memberController.createMember.bind(memberController));
router.put('/:id', memberController.updateMember.bind(memberController));
router.delete('/:id', memberController.deleteMember.bind(memberController));
return router;
};
9. Testing Tenant Isolation
// backend/src/repositories/__tests__/member.repository.test.ts
import { PrismaClient } from '@prisma/client';
import { MemberRepository } from '../member.repository';
import { TenantContext } from '../../types/tenant.types';
describe('MemberRepository - Tenant Isolation', () => {
let prisma: PrismaClient;
let repository: MemberRepository;
let tenant1: TenantContext;
let tenant2: TenantContext;
beforeAll(async () => {
prisma = new PrismaClient();
repository = new MemberRepository(prisma);
tenant1 = { organizationId: 'org-1' };
tenant2 = { organizationId: 'org-2' };
});
afterAll(async () => {
await prisma.$disconnect();
});
describe('Tenant Isolation', () => {
it('should isolate members by tenant', async () => {
// Create member for tenant 1
const member1 = await repository.create(
{ email: 'user@tenant1.com', firstName: 'John', lastName: 'Doe' },
tenant1
);
// Create member for tenant 2
const member2 = await repository.create(
{ email: 'user@tenant2.com', firstName: 'Jane', lastName: 'Doe' },
tenant2
);
// Verify tenant 1 can only see their members
const tenant1Members = await repository.findAll(tenant1);
expect(tenant1Members).toHaveLength(1);
expect(tenant1Members[0].id).toBe(member1.id);
// Verify tenant 2 can only see their members
const tenant2Members = await repository.findAll(tenant2);
expect(tenant2Members).toHaveLength(1);
expect(tenant2Members[0].id).toBe(member2.id);
});
it('should prevent cross-tenant access by ID', async () => {
const member = await repository.create(
{ email: 'test@tenant1.com', firstName: 'Test', lastName: 'User' },
tenant1
);
// Tenant 2 should not be able to access tenant 1's member
const result = await repository.findById(member.id, tenant2);
expect(result).toBeNull();
});
it('should allow duplicate emails across tenants', async () => {
const email = 'duplicate@example.com';
// Create member with same email in different tenants
const member1 = await repository.create(
{ email, firstName: 'User1', lastName: 'Tenant1' },
tenant1
);
const member2 = await repository.create(
{ email, firstName: 'User2', lastName: 'Tenant2' },
tenant2
);
expect(member1.email).toBe(email);
expect(member2.email).toBe(email);
expect(member1.id).not.toBe(member2.id);
});
it('should prevent duplicate emails within same tenant', async () => {
const email = 'duplicate@tenant1.com';
await repository.create(
{ email, firstName: 'First', lastName: 'User' },
tenant1
);
// Attempt to create duplicate
await expect(
repository.create(
{ email, firstName: 'Second', lastName: 'User' },
tenant1
)
).rejects.toThrow();
});
});
});
Best Practices
- Always validate tenant context - Never trust org_id from client
- Use composite unique constraints - [organizationId, businessKey]
- Audit tenant operations - Log org_id in all mutations
- Test isolation rigorously - Automated tests for cross-tenant leakage
- Index efficiently - Include organizationId in all query indexes
- Handle errors gracefully - Don't expose cross-tenant data in errors
- Document tenant boundaries - Clear API documentation on scoping
Common Pitfalls
- ❌ Forgetting to add organizationId to WHERE clause
- ❌ Using findUnique with just ID (bypasses tenant filter)
- ❌ Exposing global IDs without validation
- ❌ Accepting org_id from request body
- ❌ Missing indexes on [organizationId, ...]
- ❌ Not testing cross-tenant access scenarios
Migration Checklist
When adding multi-tenancy to existing models:
- Add
organizationId String @map("organization_id")field - Add relation to Organization model
- Update all unique constraints to include organizationId
- Create indexes:
@@index([organizationId, otherField]) - Update all queries to filter by organizationId
- Add tenant context to all repository methods
- Write tenant isolation tests
- Run migration:
npx prisma migrate dev
Related Skills
- authentication - JWT token structure and claims
- rest-api - API design with tenant scoping
- database - Prisma schema design and indexing
- testing - Integration tests for tenant isolation
Signals
- GitHub stars
- 21
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
multi-tenant- Source
- github.com/thelobbi/claude