Beyond tenant_id: Treating Multi-Tenancy as a Database Invariant

Beyond tenant_id: Treating Multi-Tenancy as a Database Invariant
Beyond tenant_id: Treating Multi-Tenancy as a Database Invariant

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

  1. Conceptual model of a Tenant.
  2. Separation between application context and security policy.
  3. Design-time versus run-time responsibilities.
  4. PostgreSQL Row Level Security as the enforcement mechanism.
  5. Rails as the source of tenant context rather than the guardian of isolation.
  6. Rejected alternatives and their consequences.
  7. Practical implications for large Rails applications.

Tokyo Topographic Map
Built for Ruby on Rails

Build Maps Without
Google APIs

Generate beautiful production-ready maps directly from your Rails backend. Fast rendering, zero external dependencies, full control.

✓ No API fees ✓ Self-hosted ✓ Rails Native ✓ Fast Rendering
Why developers switch
Replace expensive map stacks.

Stop relying on third-party map billing and bloated JS libraries. Render static or dynamic maps directly in Ruby.

Try It Now
Tokyo MapView Demo

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:

  1. Resolve the Tenant from the incoming domain.
  2. Authenticate users.
  3. Maintain the execution context.
  4. Persist records.

Rails should not decide whether another tenant’s data may be returned.


3.2 PostgreSQL Responsibilities

PostgreSQL should:

  1. Store ownership information.
  2. Evaluate Row Level Security policies.
  3. 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:

  1. Hidden behavior complicates reasoning.
  2. Administrative queries become unnecessarily difficult.
  3. Scopes compose poorly.
  4. Unexpected interactions appear in reporting code.
  5. 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:

  1. schema migrations become operationally expensive;
  2. connection management becomes more complex;
  3. 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.

Article content

Leave a comment