Wheels 4.0 — multi-tenancy as a framework feature #2553
bpamiri
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Multi-tenancy in 4.0 is in the framework, not in a plugin or community package. This post walks through the seam framing, how per-request datasource switching composes with the rest of the framework, the three deployment models supported, the deliberate escape hatches, and how 4.0 differs from the Rails / Laravel / Django approaches.
Multi-tenancy is a seam problem
You need exactly one seam that catches every query, every job, every background task. Miss one — the straggler query in a report action, the
CreateObject("java", ...)that goes around the ORM, the background job enqueued from a request that never asked whose tenant it was — and customer A sees customer B's invoice on a Sunday afternoon.Plugin-based tenancy catches about eighty percent of the data paths. The hard twenty percent only gets caught when the seam lives below the consumer code, in the framework itself.
How resolution works
#1951 — per-request datasource switching at the framework core. A tenant resolver runs in middleware before the controller instantiates. The resolved tenant attaches to the request, and the datasource resolver picks it up.
// config/settings.cfm set(middleware = [new app.middleware.TenantResolver()]); // app/middleware/TenantResolver.cfc component implements="wheels.middleware.MiddlewareInterface" { public any function handle(required struct request, required any next) { var subdomain = ListFirst(arguments.request.cgi.server_name, "."); arguments.request.tenant = subdomain; $setTenantDatasource(arguments.request.tenant); return arguments.next(arguments.request); } }The resolution source is up to you: subdomain (
acme.myapp.com), path prefix (/t/acme), request header (X-Tenant: acme), or a claim out of a JWT (tid). Same middleware contract — pull the identifier, call$setTenantDatasource, pass the request along.Three SaaS data patterns supported
You pick by isolation requirement, not by framework limit.
DROP DATABASEaway from complete.tenantIdcolumn and every query gets scoped. Wheels supports it via default scopes, but this model requires the most discipline regardless of framework. The framework can't protect you from a hand-rolled join that forgets the where clause.Why "in the framework" matters
The resolver lives at the datasource layer, which means every other in-core feature composes with it by construction. The background job queue, association loading, the DI request-scoped service container — none of them needed extra integration work to be tenant-aware. They share the same datasource resolution.
Compare:
apartmentgem (repo) — community package with a long history of breaking against ORM internals across releases.stancl/tenancy(site) — middleware and per-request init overhead from a package.django-tenants(repo) — schema-only by default.Each works, none is part of the framework. Wheels 4.0 puts the resolver in core because tenant-awareness is a property of the request, not a feature you opt into.
Tenant-aware background jobs
This is the part that separates a framework-level solution from a plugin-level one. When a job is enqueued from tenant A's request, the tenant context is persisted with the job. When the worker picks it up — different process, different host, hours later — the framework restores tenant context before
perform()runs.component extends="wheels.Job" { function config() { super.config(); this.queue = "reports"; } public void function perform(struct data = {}) { var orders = model("Order").findAll(); // tenant A's orders, automatically generateMonthlyReport(orders); } }No "tenant ID as payload field" ceremony. No
with_tenant(tenant) { ... }wrapper around everyperformbody. No unit test that accidentally passes because it happened to run against the right default datasource. Themodel("Order").findAll()call inside the job behaves exactly the way it would inside the controller that enqueued it.Pairs naturally with yesterday's post on the DB-backed job queue.
When NOT to use framework-level multi-tenancy
Honesty clause. There are places where you want to escape the tenant context, and pretending otherwise makes the feature worse, not better.
The framework makes the right thing easy. It doesn't make the cross-cutting thing impossible. Both matter.
Migrations and seeding per tenant
Each tenant database gets the same migration set.
wheels dbmigrate latesttargets per-tenant datasources — either one at a time or across all registered tenants in a loop. Seeding respects the active tenant context, soseeds.cfmruns against the right database without special casing.Adding a tenant: create the datasource, run migrations, run seeds. Removing a tenant: destroy the datasource. There is no row-leak risk to audit because there are no rows to leave behind.
Operational story
Per-datasource backup and restore. Per-datasource scaling. A tenant hammering CPU doesn't starve the others. A tenant requesting GDPR erasure is a
DROP DATABASEaway from complete.One detail: the rate limiter's database storage lives on the application datasource, not per-tenant. That's deliberate — you want cross-tenant rate-limit visibility for abuse detection, and you don't want to provision that table inside every customer database.
Links
Question for the thread
If you're running production multi-tenant SaaS today, what's your tenant identity source — subdomain, path prefix, header, JWT claim, or something else? And how does it interact with your auth layer? That's the area we expect 4.0.x to want the most polish.
Read the full post: https://blog.wheels.dev/posts/multi-tenancy-built-in/
Beta Was this translation helpful? Give feedback.
All reactions