Multi‑tenancy

arango‑typed provides automatic tenant filtering at the model level and a middleware to extract the tenant from requests. This minimizes leakage risk and removes boilerplate from every query.

What is Multi-Tenancy?

Multi-tenancy is an architecture pattern where a single application instance serves multiple customers (tenants). Each tenant's data must be isolated from other tenants, ensuring that one tenant cannot access or see another tenant's data.

In SaaS applications, multi-tenancy is essential for:

  • Data Isolation: Ensuring tenant data is completely separated
  • Security: Preventing data leakage between tenants
  • Scalability: Efficiently serving multiple customers from one application
  • Cost Efficiency: Sharing infrastructure across tenants

How Multi-Tenancy Works in arango-typed

arango-typed provides automatic multi-tenancy support that:

  1. Extracts tenant ID from incoming requests (header, query, JWT, etc.)
  2. Stores tenant ID in a request-scoped context (TenantContext)
  3. Automatically filters all queries to include the tenant ID
  4. Automatically injects tenant ID when creating new documents
  5. Clears context after the request completes

This means you don't need to manually add tenantId to every query—it happens automatically!

Core Concepts

TenantContext

TenantContext is a request-scoped holder for the active tenant ID. It uses Node.js's execution context to ensure tenant isolation per request.

import { TenantContext } from 'arango-typed';

// Set tenant for current request
TenantContext.set('tenant-123');

// Get current tenant
const tenantId = TenantContext.get(); // 'tenant-123'

// Clear tenant (usually done automatically by middleware)
TenantContext.clear();

Automatic Filtering

When tenantEnabled: true is set on a model, all queries automatically include the tenant filter:

// This query:
const users = await User.find({ active: true }).all();

// Automatically becomes:
// { active: true, tenantId: 'current-tenant-id' }

Automatic Injection

When creating documents, the tenant ID is automatically injected:

// This create:
const user = await User.create({ name: 'John', email: 'john@example.com' });

// Automatically becomes:
// { name: 'John', email: 'john@example.com', tenantId: 'current-tenant-id' }

Enabling Multi-Tenancy on Models

Enable multi-tenancy by setting tenantEnabled: true when creating a model:

Basic Setup

import { Schema, model } from 'arango-typed';

const UserSchema = new Schema({
  name: String,
  email: String
});

// Enable multi-tenancy (uses default 'tenantId' field)
const User = model('users', UserSchema, { tenantEnabled: true });

// All queries are now automatically scoped to the current tenant

Custom Tenant Field Name

Use a custom field name if your schema uses a different field:

const User = model('users', UserSchema, {
  tenantEnabled: true,
  tenantField: 'organizationId'  // Custom field name
});

Multiple Models

Enable multi-tenancy on all models that need tenant isolation:

const User = model('users', UserSchema, { tenantEnabled: true });
const Post = model('posts', PostSchema, { tenantEnabled: true });
const Comment = model('comments', CommentSchema, { tenantEnabled: true });

// All three models now automatically filter by tenant

Tenant Extraction in Express

The tenantMiddleware automatically extracts the tenant ID from incoming requests and sets it in TenantContext.

From Header (Most Common)

Extract tenant ID from HTTP headers (e.g., x-tenant-id):

import { tenantMiddleware } from 'arango-typed/integrations/express';
import express from 'express';

const app = express();

// Default: extracts from 'x-tenant-id' header
app.use(tenantMiddleware({ extractFrom: 'header' }));

// Custom header name
app.use(tenantMiddleware({
  extractFrom: 'header',
  headerName: 'x-organization-id'
}));

// Make tenant mandatory (returns 400 if missing)
app.use(tenantMiddleware({
  extractFrom: 'header',
  required: true
}));

From Query Parameters

Extract tenant ID from URL query parameters:

// Extract from ?tenant=tenant-123
app.use(tenantMiddleware({
  extractFrom: 'query',
  queryParam: 'tenant'
}));

// Usage: GET /api/users?tenant=tenant-123

From Route Parameters

Extract tenant ID from Express route parameters:

// Extract from route param
app.use(tenantMiddleware({
  extractFrom: 'params',
  paramName: 'tenantId'
}));

// Route: /api/:tenantId/users
app.get('/api/:tenantId/users', async (req, res) => {
  const users = await User.find({}).all(); // Automatically filtered by tenantId
  res.json(users);
});

From JWT Token

Extract tenant ID from JWT token (requires authentication middleware first):

import jwt from 'jsonwebtoken';

// Auth middleware (sets req.user)
app.use((req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (token) {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    (req as any).user = decoded;
  }
  next();
});

// Extract tenant from JWT
app.use(tenantMiddleware({
  extractFrom: 'jwt',
  jwtPath: 'org.id'  // Extract from user.org.id
}));

// Or if tenant is at top level of JWT
app.use(tenantMiddleware({
  extractFrom: 'jwt',
  field: 'tenantId'  // Extract from user.tenantId
}));

Custom Extractor

Use a custom function to extract tenant ID from anywhere:

app.use(tenantMiddleware({
  extractFrom: 'custom',
  customExtractor: (req) => {
    // Extract from custom location
    return req.getTenant?.() || 
           req.session?.tenantId || 
           req.cookies?.tenantId || 
           null;
  }
}));

Complete Express Setup

Full example of Express app with multi-tenancy:

import express from 'express';
import { connect } from 'arango-typed';
import { tenantMiddleware } from 'arango-typed/integrations/express';
import { Schema, model } from 'arango-typed';

// Connect to database
await connect({
  url: process.env.ARANGO_URL,
  database: process.env.ARANGO_DB,
  username: process.env.ARANGO_USER,
  password: process.env.ARANGO_PASS
});

// Define schemas
const UserSchema = new Schema({
  name: String,
  email: String
});

// Create models with multi-tenancy enabled
const User = model('users', UserSchema, { tenantEnabled: true });

// Express app
const app = express();
app.use(express.json());

// Extract tenant from header (must be before routes)
app.use(tenantMiddleware({
  extractFrom: 'header',
  headerName: 'x-tenant-id',
  required: true  // Require tenant ID
}));

// Routes (tenant is automatically set in context)
app.get('/users', async (req, res) => {
  // Automatically filtered by tenant
  const users = await User.find({}).all();
  res.json(users);
});

app.post('/users', async (req, res) => {
  // Tenant ID automatically injected
  const user = await User.create(req.body);
  res.json(user);
});

app.listen(3000);

Lifecycle and Context Management

The tenant middleware manages the tenant context lifecycle automatically:

Request Lifecycle

  1. Request arrives: Middleware extracts tenant ID from request
  2. Context set: TenantContext.set(tenantId) is called
  3. Request processing: All model operations use the tenant context
  4. Response sent: TenantContext.clear() is called automatically

Manual Context Management

For non-Express scenarios, manage context manually:

import { TenantContext } from 'arango-typed';

async function processRequest(tenantId: string) {
  // Set tenant context
  TenantContext.set(tenantId);
  
  try {
    // All model operations are scoped to this tenant
    const users = await User.find({}).all();
    const posts = await Post.find({}).all();
    
    return { users, posts };
  } finally {
    // Always clear context
    TenantContext.clear();
  }
}

Using run() Helper

Use the run() helper for automatic cleanup:

import { TenantContext } from 'arango-typed';

// Automatically sets and clears context
const result = await TenantContext.run('tenant-123', async () => {
  const users = await User.find({}).all();
  const posts = await Post.find({}).all();
  return { users, posts };
});

Nested Contexts

TenantContext supports nested contexts (useful for background jobs):

// Outer context
TenantContext.set('tenant-1');

// Inner context (temporarily overrides)
TenantContext.set('tenant-2');

// Current tenant is 'tenant-2'
const tenant = TenantContext.get(); // 'tenant-2'

// Clear inner context (restores outer)
TenantContext.clear();
const tenant = TenantContext.get(); // 'tenant-1'

// Clear outer context
TenantContext.clear();
const tenant = TenantContext.get(); // null

Automatic Filtering Details

When multi-tenancy is enabled, all model operations automatically include tenant filtering:

Find Operations

// This query:
const users = await User.find({ active: true }).all();

// Automatically becomes:
// { active: true, tenantId: TenantContext.get() }

FindOne Operations

// This query:
const user = await User.findOne({ email: 'john@example.com' });

// Automatically becomes:
// { email: 'john@example.com', tenantId: TenantContext.get() }

Update Operations

// This update:
await User.updateOne(
  { email: 'john@example.com' },
  { age: 31 }
);

// Automatically filters by tenant:
// WHERE email = 'john@example.com' AND tenantId = TenantContext.get()

Delete Operations

// This delete:
await User.deleteOne({ email: 'john@example.com' });

// Automatically filters by tenant:
// WHERE email = 'john@example.com' AND tenantId = TenantContext.get()

Create Operations

// This create:
const user = await User.create({
  name: 'John',
  email: 'john@example.com'
});

// Automatically injects tenant:
// { name: 'John', email: 'john@example.com', tenantId: TenantContext.get() }

Indexes and Performance

Proper indexing is crucial for multi-tenant performance:

Single Field Index

Always create an index on the tenant field:

const UserSchema = new Schema({
  name: String,
  email: String,
  tenantId: String  // Tenant field
});

// Create index on tenant field
UserSchema.index('tenantId');

const User = model('users', UserSchema, { tenantEnabled: true });

Composite Indexes

For queries that filter by tenant and another field, use composite indexes:

// Query: { tenantId: 'x', email: 'john@example.com' }
// Use composite index:
UserSchema.index(['tenantId', 'email'], { unique: true });

// Query: { tenantId: 'x', status: 'active', createdAt: ... }
// Use composite index:
UserSchema.index(['tenantId', 'status', 'createdAt']);

Performance Tips

  • Always index tenant field: Without an index, queries scan all documents
  • Use composite indexes: For queries filtering by tenant + other fields
  • Use lean queries: findLean() for read-heavy endpoints
  • Avoid cross-tenant queries: Never query without tenant filter
  • Monitor query performance: Use ArangoDB's query profiler

Isolation Strategies

arango-typed supports different multi-tenancy isolation strategies:

1. Shared Database, Tenant Field (Default)

Best for: Most SaaS applications

How it works: All tenants share the same database and collections. Each document has a tenantId field that's automatically filtered.

Pros:

  • Simplest to implement
  • Easy to manage and backup
  • Efficient resource usage
  • Easy cross-tenant analytics (when needed)

Cons:

  • Requires careful indexing
  • Potential for data leakage if not properly implemented
// Default strategy - just enable tenantEnabled
const User = model('users', UserSchema, { tenantEnabled: true });

2. Database-Per-Tenant

Best for: High-security requirements, regulatory compliance

How it works: Each tenant has their own database. Requires dynamic connection management.

Pros:

  • Maximum isolation
  • Easier compliance (can backup/restore per tenant)
  • No risk of cross-tenant queries

Cons:

  • More complex to manage
  • Higher resource usage
  • More difficult to scale
// Custom connection per tenant
async function getTenantConnection(tenantId: string) {
  return await connect({
    url: process.env.ARANGO_URL,
    database: `tenant_${tenantId}`,
    username: process.env.ARANGO_USER,
    password: process.env.ARANGO_PASS
  });
}

// Use tenant-specific connection
const tenantDb = await getTenantConnection(tenantId);
const User = model('users', UserSchema, { connection: tenantDb });

3. Collection-Per-Tenant

Best for: Sharding, legal separation requirements

How it works: Each tenant has their own collections (e.g., users_tenant1, users_tenant2).

Pros:

  • Good isolation
  • Can shard collections across servers

Cons:

  • Complex to manage
  • Difficult to query across tenants
  • More collections to manage
// Dynamic collection name per tenant
function getTenantCollection(tenantId: string) {
  return model(`users_${tenantId}`, UserSchema);
}

Testing Multi-Tenancy

Properly test multi-tenancy to ensure data isolation:

Unit Tests

import { TenantContext } from 'arango-typed';
import { User } from './models';

describe('User Model', () => {
  beforeEach(() => {
    TenantContext.set('test-tenant-1');
  });

  afterEach(() => {
    TenantContext.clear();
  });

  it('should only return users for current tenant', async () => {
    // Create user for tenant 1
    await User.create({ name: 'User 1', email: 'user1@test.com' });

    // Switch to tenant 2
    TenantContext.set('test-tenant-2');
    await User.create({ name: 'User 2', email: 'user2@test.com' });

    // Switch back to tenant 1
    TenantContext.set('test-tenant-1');
    const users = await User.find({}).all();

    // Should only see tenant 1's users
    expect(users).toHaveLength(1);
    expect(users[0].email).toBe('user1@test.com');
  });
});

Integration Tests

import request from 'supertest';
import { app } from './app';

describe('Multi-tenant API', () => {
  it('should isolate data by tenant', async () => {
    // Create user for tenant 1
    await request(app)
      .post('/users')
      .set('x-tenant-id', 'tenant-1')
      .send({ name: 'User 1', email: 'user1@test.com' });

    // Try to get users for tenant 2
    const response = await request(app)
      .get('/users')
      .set('x-tenant-id', 'tenant-2');

    // Should not see tenant 1's users
    expect(response.body).toHaveLength(0);
  });
});

Common Pitfalls and Solutions

1. Missing Index on Tenant Field

Problem: Queries are slow because they scan all documents

Solution: Always create an index on the tenant field

// ✅ Good: Index on tenant field
UserSchema.index('tenantId');

// ❌ Bad: No index
// Queries will be slow

2. Bypassing Model Layer

Problem: Using raw AQL without tenant filter

Solution: Always include tenant filter in raw queries

// ❌ Bad: Raw AQL without tenant filter
const cursor = await db.query(`
  FOR user IN users
  FILTER user.active == true
  RETURN user
`);

// ✅ Good: Include tenant filter
const tenantId = TenantContext.get();
const cursor = await db.query(`
  FOR user IN users
  FILTER user.active == true
  FILTER user.tenantId == @tenantId
  RETURN user
`, { tenantId });

3. Cross-Tenant Aggregations

Problem: Aggregation queries that leak data across tenants

Solution: Always filter by tenant in each aggregation stage

// ❌ Bad: Missing tenant filter in aggregation
const result = await User.aggregate([
  { $match: { status: 'active' } },
  { $group: { _id: '$role', count: { $sum: 1 } } }
]);

// ✅ Good: Include tenant filter
const tenantId = TenantContext.get();
const result = await User.aggregate([
  { $match: { status: 'active', tenantId } },
  { $group: { _id: '$role', count: { $sum: 1 } } }
]);

4. Forgetting to Set Tenant Context

Problem: Background jobs or CLI scripts don't have tenant context

Solution: Always set tenant context manually

// ✅ Good: Set tenant context for background job
async function processTenantData(tenantId: string) {
  await TenantContext.run(tenantId, async () => {
    const users = await User.find({}).all();
    // Process users...
  });
}

5. Tenant Field Not in Schema

Problem: Tenant field doesn't exist in schema, causing validation errors

Solution: Add tenant field to schema (optional, as it's injected automatically)

// ✅ Good: Include tenant field in schema (optional but recommended)
const UserSchema = new Schema({
  name: String,
  email: String,
  tenantId: String  // Optional but helps with TypeScript
});

Best Practices

  • Always use middleware: Use tenantMiddleware in Express apps
  • Index tenant field: Create indexes on tenantId for performance
  • Use composite indexes: For queries filtering by tenant + other fields
  • Test isolation: Write tests to verify tenant isolation
  • Never bypass model layer: Avoid raw AQL queries without tenant filters
  • Set context for background jobs: Use TenantContext.run() for async operations
  • Make tenant required: Set required: true in middleware for production
  • Monitor queries: Use ArangoDB query profiler to verify tenant filtering
  • Document tenant strategy: Document which isolation strategy you're using
  • Regular audits: Periodically audit queries to ensure tenant isolation

Complete Example

Full example of a multi-tenant Express application:

import express from 'express';
import { connect } from 'arango-typed';
import { tenantMiddleware } from 'arango-typed/integrations/express';
import { Schema, model } from 'arango-typed';

// Connect to database
await connect({
  url: process.env.ARANGO_URL,
  database: process.env.ARANGO_DB,
  username: process.env.ARANGO_USER,
  password: process.env.ARANGO_PASS
});

// Define schemas
const UserSchema = new Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  tenantId: String  // Tenant field (optional but recommended)
});

const PostSchema = new Schema({
  title: { type: String, required: true },
  content: String,
  authorId: String,
  tenantId: String
});

// Create indexes
UserSchema.index(['tenantId', 'email'], { unique: true });
PostSchema.index(['tenantId', 'authorId']);

// Create models with multi-tenancy
const User = model('users', UserSchema, { tenantEnabled: true });
const Post = model('posts', PostSchema, { tenantEnabled: true });

// Express app
const app = express();
app.use(express.json());

// Extract tenant from header (REQUIRED - must be before routes)
app.use(tenantMiddleware({
  extractFrom: 'header',
  headerName: 'x-tenant-id',
  required: true  // Require tenant ID
}));

// Routes
app.get('/users', async (req, res) => {
  // Automatically filtered by tenant
  const users = await User.find({}).all();
  res.json(users);
});

app.post('/users', async (req, res) => {
  // Tenant ID automatically injected
  const user = await User.create(req.body);
  res.json(user);
});

app.get('/posts', async (req, res) => {
  // Automatically filtered by tenant
  const posts = await Post.find({}).all();
  res.json(posts);
});

app.post('/posts', async (req, res) => {
  // Tenant ID automatically injected
  const post = await Post.create(req.body);
  res.json(post);
});

app.listen(3000);
📚 API Reference: For complete API documentation, see the Express integration module.
Next: Learn about Queries for advanced querying capabilities.