Multi-Tenancy in DynamoBao
DynamoBao provides built-in support for multi-tenancy using a pool model approach, where all tenants share the same DynamoDB table with tenant-prefixed keys for complete data isolation.
Overview
Multi-tenancy in DynamoBao allows you to:
- Isolate data between different tenants in the same DynamoDB table
- Maintain a single codebase for all tenants
- Ensure complete data separation with no cross-tenant data access
- Leverage existing test isolation patterns for production use
Enabling Multi-Tenancy
To enable multi-tenancy, update your configuration:
// dynamo-bao.config.js
module.exports = {
aws: {
region: "us-east-1",
},
db: {
tableName: "my-app-table",
},
tenancy: {
enabled: true, // Enable multi-tenancy
},
// ... other config
};
Setting Tenant Context
When tenancy is enabled, you must provide a tenant context before performing any database operations. DynamoBao provides concurrency-safe tenant management using Node.js AsyncLocalStorage.
1. Recommended: Using runWithTenant (Concurrency-Safe)
const { TenantContext } = require("dynamo-bao");
// Run operations within a tenant context (concurrency-safe)
await TenantContext.runWithTenant("tenant-123", async () => {
const users = await User.queryByIndex("byStatus", "active");
const user = await User.create({
name: "John Doe",
email: "john@tenant123.com"
});
});
2. Cross-Tenant Operations
// Safely switch tenant context for admin operations
const adminService = {
async getTenantStats(tenantId) {
return await TenantContext.withTenant(tenantId, async () => {
const activeUsers = await User.queryByIndex("byStatus", "active");
return {
tenantId,
userCount: activeUsers.items.length
};
});
}
};
3. Direct Tenant Setting (Backward Compatibility)
const { TenantContext } = require("dynamo-bao");
// Set tenant for the current context (simple use cases)
TenantContext.setCurrentTenant("tenant-123");
// Now all operations will be scoped to tenant-123
const users = await User.queryByIndex("byStatus", "active");
4. Tenant Resolvers
For applications where tenant context can be determined automatically:
const { TenantContext } = require("dynamo-bao");
// Add resolvers that will be tried in order
TenantContext.addResolver(() => {
// Try to get tenant from request headers
return getCurrentRequest()?.headers['x-tenant-id'];
});
TenantContext.addResolver(() => {
// Fall back to user's tenant
const user = getCurrentUser();
return user?.tenantId;
});
// Tenant will be resolved automatically
await TenantContext.runWithTenant(null, async () => {
const users = await User.queryByIndex("byStatus", "active");
});
Integration Examples
Express.js Middleware (Concurrency-Safe)
const express = require('express');
const { TenantContext, initModels } = require('dynamo-bao');
const app = express();
// Initialize models once at startup
const manager = initModels({
tenancy: { enabled: true }
});
// Set up tenant resolvers for automatic resolution
TenantContext.addResolver(() => {
return getCurrentRequest()?.headers['x-tenant-id'];
});
TenantContext.addResolver(() => {
return getCurrentUser()?.tenantId;
});
// Middleware to run each request in tenant context
app.use((req, res, next) => {
setCurrentRequest(req);
const tenantId = req.headers['x-tenant-id'] || req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant context required' });
}
// Run the entire request within tenant context (concurrency-safe)
TenantContext.runWithTenant(tenantId, () => {
next();
});
});
// Routes automatically use correct tenant context
app.get('/api/users', async (req, res) => {
// This will see the correct tenant even with concurrent requests
const users = await User.queryByIndex('byStatus', 'active');
res.json(users.items);
});
// No need to clear tenant - AsyncLocalStorage handles cleanup automatically
AWS Lambda (Async Context Safe)
const { TenantContext, initModels } = require('dynamo-bao');
// Initialize outside handler for reuse
const manager = initModels({
tenancy: { enabled: true }
});
exports.handler = async (event) => {
const tenantId = event.requestContext.authorizer?.tenantId ||
event.headers['x-tenant-id'];
if (!tenantId) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Tenant context required' })
};
}
// Run entire handler within tenant context (concurrency-safe)
return TenantContext.runWithTenant(tenantId, async () => {
try {
const users = await User.queryByIndex('byStatus', 'active');
return {
statusCode: 200,
body: JSON.stringify(users.items)
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({ error: error.message })
};
}
});
// No need to manually clear tenant - AsyncLocalStorage handles cleanup
};
Background Jobs / Cron
const { TenantContext } = require('dynamo-bao');
async function processTenantData(tenantId) {
try {
TenantContext.setCurrentTenant(tenantId);
// Process data for this tenant
const expiredSubscriptions = await Subscription.queryByIndex(
'byStatus',
'expired'
);
for (const sub of expiredSubscriptions.items) {
await sub.delete();
}
} finally {
TenantContext.clearTenant();
}
}
// Process multiple tenants
async function processAllTenants() {
const tenants = ['tenant-1', 'tenant-2', 'tenant-3'];
for (const tenantId of tenants) {
await processTenantData(tenantId);
}
}
Cross-Tenant Operations
For operations across multiple tenants (e.g., admin functions), use withTenant()
for safe tenant context switching:
const adminService = {
async getTenantStats(tenantId) {
// Safe cross-tenant operation - doesn't affect current request context
return await TenantContext.withTenant(tenantId, async () => {
const userCount = await User.count();
const activeSubscriptions = await Subscription.queryByIndex(
'byStatus',
'active',
null,
{ countOnly: true }
);
return {
tenantId,
userCount: userCount.count,
activeSubscriptions: activeSubscriptions.count
};
});
},
async compareTenants(tenant1Id, tenant2Id) {
// Multiple tenant operations in parallel (concurrency-safe)
const [tenant1Stats, tenant2Stats] = await Promise.all([
TenantContext.withTenant(tenant1Id, async () => {
const users = await User.queryByIndex('byStatus', 'active');
return { tenantId: tenant1Id, userCount: users.items.length };
}),
TenantContext.withTenant(tenant2Id, async () => {
const users = await User.queryByIndex('byStatus', 'active');
return { tenantId: tenant2Id, userCount: users.items.length };
})
]);
return { tenant1Stats, tenant2Stats };
},
async getAllTenantsStats() {
const tenants = await this.getAllTenantIds();
// Process tenants in parallel safely
const stats = await Promise.all(
tenants.map(tenantId => this.getTenantStats(tenantId))
);
return stats;
}
};
// Usage in request handler
app.get('/admin/tenant-comparison', async (req, res) => {
const { tenant1, tenant2 } = req.query;
// This operation won't interfere with the current request's tenant context
const comparison = await adminService.compareTenants(tenant1, tenant2);
res.json(comparison);
});
Testing with Multi-Tenancy
The test utilities have been updated to support tenant-based isolation:
const { initTestModelsWithTenant, cleanupTestData } = require('./test/utils/test-utils');
const { ulid } = require('ulid');
describe('User Service', () => {
let tenantId;
beforeEach(async () => {
// Use a unique tenant ID for test isolation
tenantId = ulid();
// Initialize models with tenant context
const manager = initTestModelsWithTenant(testConfig, tenantId);
// Clean up any existing test data
await cleanupTestData(tenantId);
User = manager.getModel('User');
});
afterEach(async () => {
TenantContext.clearTenant();
await cleanupTestData(tenantId);
});
test('should create user in tenant context', async () => {
const user = await User.create({
name: 'Test User',
email: 'test@example.com'
});
expect(user.exists()).toBe(true);
// User is automatically scoped to the test tenant
});
});
Concurrency Safety
DynamoBao's multi-tenancy uses Node.js AsyncLocalStorage to ensure tenant isolation in concurrent environments. This prevents race conditions that could cause tenant data leakage.
Why AsyncLocalStorage?
// ❌ UNSAFE: Static variables cause race conditions
static _currentTenant = 'tenant-a'; // Request 1 sets this
// ... async operation ...
static _currentTenant = 'tenant-b'; // Request 2 overwrites it!
// Request 1 now sees tenant-b data! 🐛
// ✅ SAFE: AsyncLocalStorage maintains per-request context
TenantContext.runWithTenant('tenant-a', async () => {
// Request 1 operations - always sees tenant-a
await someAsyncOperation();
const users = await User.find(); // Always tenant-a data
});
TenantContext.runWithTenant('tenant-b', async () => {
// Request 2 operations - always sees tenant-b
await someAsyncOperation();
const users = await User.find(); // Always tenant-b data
});
Requirements
- Node.js 12.17.0+ for AsyncLocalStorage support
- Use
runWithTenant()
for production applications with concurrent requests - Use
setCurrentTenant()
for simple use cases and tests
Best Practices
1. Use runWithTenant for Production
Always use runWithTenant()
for concurrent applications to ensure tenant safety:
// ✅ Recommended for production
app.use((req, res, next) => {
const tenantId = getTenantFromRequest(req);
TenantContext.runWithTenant(tenantId, () => {
next();
});
});
2. Backward Compatibility Support
setCurrentTenant()
still works for simple use cases and maintains backward compatibility:
// ✅ OK for tests and simple scripts
TenantContext.setCurrentTenant(tenantId);
const users = await User.find();
TenantContext.clearTenant();
3. Validate Tenant Context Early
When tenancy is enabled, operations will fail if no tenant context is set. Validate early in your request pipeline:
// This will throw if tenancy.enabled: true but no tenant is set
const manager = initModels(config);
4. Use Tenant Resolvers for Complex Apps
For applications with complex authentication, use resolvers to automatically determine tenant context:
// Add multiple resolvers for fallback
TenantContext.addResolver(() => getFromAuthToken());
TenantContext.addResolver(() => getFromRequestHeader());
TenantContext.addResolver(() => getFromUserSession());
5. Secure Tenant IDs
Never trust client-provided tenant IDs without validation. Always verify the tenant ID against the authenticated user's permissions:
app.use(async (req, res, next) => {
const requestedTenant = req.headers['x-tenant-id'];
const userTenants = await getUserTenants(req.user.id);
if (!userTenants.includes(requestedTenant)) {
return res.status(403).json({ error: 'Access denied' });
}
TenantContext.runWithTenant(requestedTenant, () => {
next();
});
});
Migration from testId
If you're already using testId
for test isolation, the migration to tenant-based isolation is straightforward:
Before:
const manager = dynamoBao.initModels({
...config,
testId: testId,
});
After:
TenantContext.setCurrentTenant(tenantId);
const manager = dynamoBao.initModels({
...config,
tenancy: { enabled: true }
});
The key formatting remains the same - [testId]#modelPrefix#value
becomes [tenantId]#modelPrefix#value
.
Limitations and Considerations
Single Table Design: Multi-tenancy in DynamoBao uses a pool model where all tenants share the same table. This is ideal for most SaaS applications but may not suit applications requiring physical isolation.
Tenant ID in Keys: Tenant IDs are prefixed to all keys, so choose tenant IDs wisely. They should be:
- Unique and immutable
- Reasonably short to minimize storage
- Safe for use in DynamoDB keys (no special characters)
No Cross-Tenant Queries: By design, you cannot query across tenants in a single operation. This ensures complete isolation but means admin operations must iterate through tenants.
GSI Considerations: All Global Secondary Indexes are also tenant-scoped, ensuring complete isolation across all access patterns.
Summary
DynamoBao's multi-tenancy support provides:
- Complete data isolation between tenants
- Flexible tenant resolution strategies
- Easy integration with web frameworks and serverless
- Reuse of existing test isolation patterns
- Zero configuration changes to models
By leveraging the same patterns used for test isolation, DynamoBao makes it simple to add multi-tenancy to your application while maintaining complete data separation and security.