Skip to main content
All posts
September 26, 2025Β·2 min readΒ·Diogo Hudson

Multi-tenancy without the pain

Every non-auth row carries a tenant. Services filter. Views stay dumb. Cross-tenant lookups 404, never 403. That's the whole rule set.

Multi-tenancy without the pain

Multi-tenancy has a reputation for being hard. It isn't, if you commit to four rules early.

The reputation comes from systems that bolt on multi-tenancy after the fact β€” adding tenant columns to existing tables, threading tenant context through layers that weren't designed for it, and discovering edge cases one angry customer at a time. Quotery's approach is different: every business model was designed with multi-tenancy from day one. The tenant column is not an afterthought; it's as fundamental as the primary key.

Rule one: every row carries a tenant Every non-auth business model has tenant = ForeignKey('tenants.Tenant', ...). No exceptions outside the auth table. If a business row exists and doesn't know its tenant, you have a bug.

This rule is enforced by convention and code review, not by the database β€” but the enforcement is strict. Every model in api/<app>/models/ that inherits from BaseModel either has a tenant FK or has an explicit, documented reason why it doesn't (auth models, system-wide configuration). New contributors learn this rule in their first PR review. It's the single most important structural invariant in the codebase.

Rule two: services filter, views don't Every service function accepts tenant= and applies a filter. Views are dumb transport: resolve request.user.tenant, pass it into the service call, trust the layer.

This separation is what keeps the codebase maintainable. If views filtered, you'd have to audit every view to verify tenant isolation. If models filtered (via a custom manager), you'd lose the ability to write cross-tenant queries for administrative purposes. By centralizing tenant filtering in the service layer, there's exactly one place to verify that every query is scoped: the service function that owns the query.

Rule three: cross-tenant 404, not 403 If a user requests a resource that exists in someone else's tenant, the API returns 404, not 403. Returning 403 leaks the fact that the resource exists. 404 treats it as non-existent, which is what the user should see.

This is a security-through-obscurity argument that's actually correct in this context. A 403 says 'you don't have permission to see this, but it exists.' A determined attacker can use 403 responses to map which tenants have which resources. A 404 says 'this doesn't exist in your universe.' It's indistinguishable from a genuinely nonexistent resource. The service layer achieves this by filtering queries by tenant and raising DoesNotExist when the filtered query returns empty β€” never by checking ownership after retrieval.

URL-based tenant routing is common (Slack, for example) but introduces a class of bugs where a user bookmarks a URL, shares it, and accidentally reveals their tenant context β€” or worse, where a crafted URL lets a user attempt to access another tenant by changing the slug. Session-based tenant resolution eliminates this attack surface entirely. The user authenticates, the session carries the tenant, and every subsequent request is automatically scoped. The user never sees or thinks about their tenant identifier.

How Quotery's platform is put together.

All posts
Short pieces on quoting, inventory, AI, and how small distributors ship a lot of stuff without the fuss.