When I started building TalkSense, the multi-tenancy question felt like an infrastructure decision. Separate databases per tenant? Schema-per-tenant? Row-level scoping? The choice would shape the entire system.
I settled on row-level scoping with tenant ID on every table — the operationally simplest approach — but the interesting architectural work wasn't the database choice. It was the four places where tenant isolation can break if you're not careful.
Where tenant isolation actually matters
1. Data queries
This is the obvious one. Every query must be scoped by tenant ID. In NestJS with TypeORM, I implemented this as a TenantScopeInterceptor that extracts the tenant ID from the JWT and sets it in a request-scoped context. Repository methods are wrapped to always include the tenant ID in their WHERE clauses.
// Simplified tenant scope interceptor
@Injectable()
export class TenantScopeInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler) {
const req = ctx.switchToHttp().getRequest();
const tenantId = req.user?.tenantId;
if (!tenantId) throw new UnauthorizedException();
// Set in AsyncLocalStorage for downstream services
tenantContext.run({ tenantId }, () => next.handle());
return next.handle();
}
}
The discipline is making this automatic — not something each developer has to remember to add to each query. If the scoping isn't applied by default, it will be forgotten somewhere.
2. Credential storage
TalkSense stores X OAuth access tokens per-tenant per-connected-account. These are extraordinarily sensitive — a credential leak would mean an attacker could post to a user's X account.
Credentials are stored encrypted at rest using AES-256-GCM with a per-tenant encryption key derived from a master secret and the tenant ID. This means a database dump doesn't give you usable credentials even if you have the data. The master secret never touches the database — it lives in the environment.
3. Rate limit tracking
X API rate limits are per-authentication-context, not per-application. A single tenant making too many requests will hit their own limits, not others'. But at the application level, a high-activity tenant can trigger concerns at the app-credential level if you're routing through shared app keys.
TalkSense tracks credit consumption per tenant in Redis with a sliding window. When a tenant approaches 80% of their allocated credits, they get a notification. At 100%, their scheduled posts queue but don't execute until the next window. No tenant's usage affects another tenant's service.
4. Background job isolation
Scheduled posts run as background jobs via a Redis-backed queue. Each job is tagged with the tenant ID and the connected account ID. Workers process jobs from the queue and re-establish the tenant context before executing. A worker failure on a job from tenant A never affects tenant B's queue position.
Billing integration without a separate billing service
For the initial version, I didn't want the operational overhead of a separate billing microservice. Usage is tracked in the main database with an event log — every API call to X that consumes credits writes a credit deduction event. Monthly billing runs a simple aggregation query across these events.
The event log approach has one major advantage over storing a running total: it's auditable. When a customer disputes their bill, the raw events are there. When a bug causes incorrect credit counting, the events let you reconstruct the correct state.
What I'd do differently
The tenant scoping via AsyncLocalStorage works but is fragile — if a developer accidentally breaks the context chain (for example by using Promise.all without preserving the context), queries run without tenant scoping and either return no data or, worse, return all tenants' data. I'd implement a schema-level check — a database trigger that rejects queries without a tenant filter on tenant-scoped tables — as a safety net.