
July 20, 2026
Most Rails applications that become SaaS products follow the same trajectory. They begin with a single customer, acquire a second, then a third, and eventually someone adds a tenant_id column to every table. From that moment forward, every query is expected to remember to filter by that identifier. The application appears to work, until the inevitable forgotten where clause quietly leaks another customer’s data.
Mirabile dictu, this is often considered an acceptable architecture.
Martin Fowler once observed:
“Any damn fool can write code that a computer can understand; good programmers write code that humans can understand.”
I would extend the idea slightly. Any disciplined developer can remember to write where(tenant_id: …). The challenge is to design a system in which forgetting to write it cannot violate the architecture.
Main Points
- Conceptual model of a Tenant.
- Separation between application context and security policy.
- Design-time versus run-time responsibilities.
- PostgreSQL Row Level Security as the enforcement mechanism.
- Rails as the source of tenant context rather than the guardian of isolation.
- Rejected alternatives and their consequences.
- Practical implications for large Rails applications.
1. Operational Definitions
Before discussing mechanics, terminology must be precise.
1.1 Tenant
A Tenant is a business entity that owns a logically isolated subset of the application’s data.
A Tenant is not a user.
A Tenant is not a session.
A Tenant is not a Rails model, although one may exist to represent it.
It is an ownership boundary.
1.2 User
A User is an authenticated identity operating inside exactly one Tenant.
Authentication answers:
Who are you?
Authorization answers:
What may you do?
Tenant isolation answers:
Which data may exist?
These are distinct questions.
1.3 Tenant Context
The Tenant Context is the execution state indicating which Tenant owns the current request.
It is transient.
It exists only during execution.
1.4 Row Level Security
Row Level Security (RLS) is a PostgreSQL mechanism that transforms data visibility from an application convention into a database invariant.
The distinction is essential.
Without RLS:
The application promises to filter rows.
With RLS:
The database refuses to expose rows.
2. The Architectural Observation
Many Rails applications implement multi-tenancy by placing tenant_id into every model and relying on conventions.
For example:
User.where(tenant_id: Current.tenant.id)
Nothing prevents someone from writing:
User.all
The resulting bug is not accidental.
It is permitted by the architecture.
An architecture that permits data leakage merely because one predicate was omitted is fragile a priori.
3. Separation of Responsibilities
The central idea is surprisingly simple.
Rails should know which tenant is active.
PostgreSQL should decide which rows are visible.
Neither should perform the other’s responsibility.
3.1 Rails Responsibilities
Rails should:
- Resolve the Tenant from the incoming domain.
- Authenticate users.
- Maintain the execution context.
- Persist records.
Rails should not decide whether another tenant’s data may be returned.
3.2 PostgreSQL Responsibilities
PostgreSQL should:
- Store ownership information.
- Evaluate Row Level Security policies.
- Reject unauthorized visibility.
Notice that no application code participates in Step 3.
4. Design-Time versus Run-Time
This distinction is frequently ignored.
It should not be.
4.1 Design Time
At design time, the engineer establishes:
- every tenant-owned table contains tenant_id;
- every relevant table enables Row Level Security;
- every table defines an RLS policy;
- every unique constraint becomes tenant-aware.
Examples include:
- (tenant_id, email)
- (tenant_id, slug)
- (tenant_id, external_id)
These are structural decisions.
4.2 Run Time
At run time, execution follows a deterministic sequence.
State S₀
Incoming HTTP request
↓
State S₁
Resolve Tenant from domain
↓
State S₂
Store Tenant inside Current
↓
State S₃
Configure PostgreSQL session
↓
State S₄
Execute arbitrary ActiveRecord queries
↓
State S₅
PostgreSQL evaluates Row Level Security
↓
State S₆
Rows outside the active Tenant never become visible.
Observe that Step S₄ contains no explicit tenant filter.
This is intentional.
5. Why This Matters
Suppose a developer writes:
User.all
There are only two possibilities.
Case A
The architecture depends on every query remembering to filter.
Case B
The architecture prevents mistakes from violating isolation.
Case B is preferable because correctness no longer depends on memory.
Humans forget.
Architectures should not.
6. Remark 1.
Why not use default_scope?
Rejected.
Reasons:
- Hidden behavior complicates reasoning.
- Administrative queries become unnecessarily difficult.
- Scopes compose poorly.
- Unexpected interactions appear in reporting code.
- Debugging becomes less deterministic.
The convenience gained does not justify the ambiguity introduced.
7. Remark 2.
Why not rely exclusively on Current.tenant?
Because Current is application state.
Application state can be ignored.
Database policy cannot.
Confusing the two is equivalent to confusing intention with enforcement.
8. Remark 3.
Why not use Apartment?
Apartment solves a different problem.
Schema-per-tenant isolation is attractive when tenants require independent schema evolution or stronger operational isolation.
However, for many SaaS systems:
- schema migrations become operationally expensive;
- connection management becomes more complex;
- analytics across tenants become less straightforward.
Time will show.
Neither approach dominates universally.
Architecture follows requirements, not fashion.
9. Remark 4.
Should Rails configure PostgreSQL through middleware, controller callbacks, or another mechanism?
Undecided yet.
The essential invariant is simply this:
Before the first SQL statement executes, PostgreSQL must know the active Tenant.
How that configuration is introduced is, largely, an implementation detail.
10. Human Readability
The interesting consequence of this architecture is not improved performance.
Nor is it reduced code.
The improvement is conceptual.
A developer reading:
Invoice.find(params[:id])
can interpret it literally.
Find the invoice.
Not:
Find the invoice, but remember fifteen hidden conventions, three scopes, two callbacks, and one security filter.
Readable software is software whose invariants are obvious.
Everything else is accidental complexity.
11. Future Considerations
Several questions remain open.
Should background jobs inherit tenant context automatically?
Should Action Cable connections establish independent PostgreSQL session variables?
Should every request execute inside a database transaction using SET LOCAL?
Translator question.
The answers depend on operational constraints, throughput requirements, and deployment strategy.
Fortunately, these are evolutionary decisions.
The fundamental architecture remains unchanged.
Conclusion
Good architectures rarely emerge because someone discovered a clever trick. More often, they emerge because responsibilities are assigned with discipline.
Rails excels at expressing business intent. PostgreSQL excels at enforcing data integrity. Asking either to impersonate the other only increases complexity.
By allowing Rails to identify the tenant while PostgreSQL guarantees isolation, we move an important invariant to the place best equipped to enforce it. The application becomes easier to reason about, safer to evolve, and—perhaps most importantly—more readable to the next engineer who inherits it.
As Fowler reminded us, software is written for humans first and computers second. If our multi-tenant architecture makes the correct path the obvious path, we have already solved one of the hardest problems in software engineering.
I’m interested in hearing how other teams approach this problem. Have you relied on application-level filtering, schema-per-tenant designs, PostgreSQL Row Level Security, or a different model altogether? The trade-offs are rarely absolute, and thoughtful discussion is often where the best architectures begin.
