You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs(docs): ship wheels 4.0 blog series and deploy architecture reference (#2265)
Converts eight skeleton outlines into finished blog posts, adds a ninth covering
the wheels deploy Kamal port, and sets concrete publish dates across the series
from 2026-04-27 through 2026-05-11. Expands social-announcements.md with the
full 10-post calendar and companion blog pairings running to GA on 2026-05-09.
Adds a public-facing architecture reference for wheels deploy under the v4
guides so the blog can cite it instead of internal superpowers/ specs. Refreshes
audit counts (260+ PRs, ~15 weeks, +@MukundaKatta) in the 4.0 audit and 3.0→4.0
comparison docs.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wheels 4.0 lifts multi-tenancy out of plugin territory and into the framework
14
+
core. Per-request datasource switching catches every query and every job on
15
+
the way to the database, and tenant-aware background jobs come along for the
16
+
ride.
17
+
coverImage: null
5
18
---
6
19
7
20
# Multi-Tenancy Built In
8
21
9
-
**Subhead / dek:***Per-request datasource switching in core — no gem, no package, no plugin. And tenant-aware background jobs come along for the ride.*
22
+
_Peter Amiri, Wheels Core Team_
10
23
11
-
**Target audience:**
12
-
- SaaS founders and architects evaluating CFML frameworks for a multi-tenant product
13
-
- Existing Wheels teams who've been rolling their own tenant switching via `application.wheels.dataSourceName = ...`
14
-
- Rails/Laravel/Django devs curious what "built-in multi-tenancy" actually looks like
24
+
---
25
+
26
+
If you've been rolling your own tenant switching — a `beforeFilter` here, a thread-local there, a prayer that nobody writes a raw `cfquery` in a view — Wheels 4.0 is the release where you get to throw that scaffolding away. Per-request datasource switching now lives in the framework core. Every model call, every association load, every background job picks up the active tenant's datasource automatically.
27
+
28
+
This is not a plugin. There is nothing to install. If you can resolve "who is this request for?" you can write a multi-tenant Wheels app.
15
29
16
-
**Lead paragraph intent:**
17
-
- Multi-tenancy in Rails is `apartment` gem. In Laravel it's `stancl/tenancy`. In Django it's `django-tenants`. In Wheels 4.0 it's *the framework*.
18
-
- That sounds boastful — but the practical difference is real: tenant resolution, datasource switching, and tenant-aware job queues are one coherent system, not three plugins duct-taped together.
19
-
- PR [#1951](https://github.com/wheels-dev/wheels/pull/1951) landed the core. This post walks a realistic SaaS scenario to show why that matters.
30
+
## The seam you need to cut cleanly
20
31
21
-
## Sections
32
+
Multi-tenancy is a seam problem, not a feature problem. You need *one* seam that catches every query, every job, every background task. Miss one, and a stray `cfquery` or a job that forgot to resolve tenant context leaks data across tenants. That is the incident-on-a-Sunday bug — the one where customer A sees customer B's invoice and your week evaporates.
22
33
23
-
### 1. "The seam you need to cut cleanly"
24
-
- Multi-tenancy is a seam problem, not a feature problem. You need *one* seam that catches every query, every job, every background task.
25
-
- Plugin-based multi-tenancy catches 80% — then a stray `cfquery` or a job that forgot to resolve tenant context leaks data across tenants. That's a "write the incident report on Sunday" bug.
26
-
- Framework-level tenancy catches 100% because every data path goes through the datasource resolver.
34
+
Plugin-based tenancy catches about eighty percent of the paths. The hard ones are the straggler query in a report action, the `CreateObject("java", ...)` that goes around the ORM, and the background job enqueued from a web request that never asked "whose job is this?" The plugin only sees the places where it was wired in.
27
35
28
-
### 2. Three models, one framework
29
-
-**Separate database per tenant** — cleanest isolation, per-customer backup/restore. Wheels 4.0 supports it via datasource switching.
30
-
-**Shared database, separate schema** — supported via datasource naming that points to the same DB but different schema.
31
-
-**Shared database, row-level** — scoped queries. Less cleanly isolated but fine for many products. (Note: this model requires discipline regardless of framework.)
36
+
Framework-level tenancy catches the other twenty percent because every data path is routed through the datasource resolver. The seam is in the framework, not the consumer code. You can forget about it, and that is the point.
32
37
33
-
### 3. Tenant resolution — from request to datasource
- No "tenant ID as payload field" ceremony. No `with_tenant(tenant) { ... }` wrapper at the top of every job's `perform`.
42
-
- Tease post #4 (jobs).
40
+
Wheels 4.0 supports the three established SaaS data patterns. You pick based on isolation requirements, not framework limitations.
43
41
44
-
### 5. When NOT to use framework-level multi-tenancy
45
-
- Admin / ops console that needs cross-tenant queries. Plan for it deliberately — exit the tenant context explicitly, or use a "system" datasource for admin reads.
46
-
- Cross-tenant reports. Same rule — these are a deliberate exception, not a framework fight.
42
+
**Separate database per tenant** gives the cleanest isolation. Per-customer backup and restore, per-customer performance tuning, per-customer encryption at rest. The datasource resolver switches the entire connection per request. This is the default story for anyone who has had a regulated tenant ask hard questions about data segregation.
47
43
48
-
### 6. Compared to the alternatives
49
-
-**Rails + apartment** — similar model; requires gem + config. Framework-level in Wheels.
50
-
-**Laravel + stancl/tenancy** — rich feature set but adds middleware and per-request initialization overhead from a plugin. Wheels inlines this.
51
-
-**Django + django-tenants** — solid but schema-only by default. Wheels supports separate DB out of the box.
44
+
**Shared database, separate schema** — supported via datasource naming. One physical database, one connection pool, logical separation through schema prefixes. Middle ground between operational simplicity and isolation.
52
45
53
-
### 7. Migration and seeding per tenant
54
-
- Each tenant DB gets the same migration set run against it.
55
-
-`wheels dbmigrate latest` can target per-tenant datasources.
56
-
- Seeding via `wheels db:seed` respects the active tenant context.
57
-
- (Short section — deep multi-tenant migration tactics is a follow-on post.)
46
+
**Shared database, row-level** — every table gets a `tenantId` column and every query gets scoped. Wheels supports it through default scopes, but honestly: 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. Pick this and you're signing up for code review on every data access.
58
47
59
-
### 8. Operational story
60
-
- Adding a tenant = creating a datasource + running migrations + optional seed.
61
-
- Removing a tenant = destroying the datasource. Clean delete, no row-leak risk.
62
-
- Backup/restore is per-datasource — operationally clean.
63
-
- Rate limiter database storage lives on the *app* DB, not per-tenant — one less thing to provision.
48
+
## Tenant resolution — from request to datasource
64
49
65
-
## Code / config snippets to include (pick 2)
50
+
Tenant resolution happens early, in middleware, before your controller ever instantiates. The resolved tenant is attached to the request, and the datasource resolver picks it up from there.
66
51
67
52
```cfm
68
53
// config/settings.cfm — tenant resolution via middleware
69
54
set(middleware = [
70
-
new app.middleware.TenantResolver() // sets request-scoped tenant ctx
public any function handle(required struct request, required any next) {
76
61
var subdomain = ListFirst(arguments.request.cgi.server_name, ".");
77
62
arguments.request.tenant = subdomain;
78
-
// Framework datasource switching hook — exact API in docs
79
63
$setTenantDatasource(arguments.request.tenant);
80
64
return arguments.next(arguments.request);
81
65
}
82
66
}
83
67
```
84
68
69
+
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`). The middleware contract is the same — pull the identifier, call `$setTenantDatasource`, pass the request along.
70
+
71
+
From that point on, every `model("Order").findAll()` in the request lifecycle hits the right database. You don't pass the tenant around. You don't remember to scope. The resolver already did it.
72
+
73
+
## Tenant-aware background jobs
74
+
75
+
This is the part that separates a framework-level solution from a plugin-level one. When you enqueue a job from tenant A's request, the tenant context is persisted with the job. When the worker picks that job up later — in a different process, on a different host, hours later — the framework restores the tenant context before `perform` runs.
No "tenant ID as payload field" ceremony. No `with_tenant(tenant) { ... }` wrapper wrapped around every `perform` body. No unit test that accidentally passes because it happened to run against the right default datasource. The `model("Order").findAll()` call inside the job behaves exactly the way it would inside the controller that enqueued the job. You get tenant-aware jobs without ceremony, and that is the feature.
92
+
93
+
## When NOT to use framework-level multi-tenancy
94
+
95
+
Honesty clause. There are places where you *want* to escape the tenant context, and pretending otherwise makes the feature worse, not better.
96
+
97
+
**Admin and ops consoles.** The whole point of an internal admin console is cross-tenant visibility. "Which tenants signed up this week?" "Which tenants are approaching their rate limit?" Those queries cannot run inside a tenant's datasource. Exit the tenant context explicitly, or wire a dedicated "system" datasource for admin reads. Don't try to make the framework solve a problem it's deliberately preventing.
98
+
99
+
**Cross-tenant reports and billing.** Same rule. Monthly invoicing, aggregate usage metrics, platform-wide analytics — these live in a system-level datasource with deliberate, audited queries. Treat the escape hatch as a deliberate exception, not a framework fight.
100
+
101
+
The framework makes the right thing easy. It doesn't make the cross-cutting thing impossible. Both matter.
102
+
103
+
## Compared to the alternatives
104
+
105
+
If you're coming from another framework: Rails + [apartment](https://github.com/influitive/apartment) is the closest analogue — similar mental model, added via gem and config. Laravel + [stancl/tenancy](https://tenancyforlaravel.com/) is rich but adds middleware and per-request init overhead from a package. Django + [django-tenants](https://github.com/django-tenants/django-tenants) is solid but schema-only by default.
106
+
107
+
None of this is revolutionary. The contribution is that Wheels ships it in the core and ties it into the background job system, so the hard-to-catch paths are caught by default.
108
+
109
+
## Migration and seeding per tenant
110
+
111
+
Each tenant database gets the same migration set. `wheels dbmigrate latest` targets per-tenant datasources — either one at a time, or across all registered tenants in a loop. Seeding respects the active tenant context, so your `seeds.cfm` runs against the right database without special casing.
112
+
113
+
Adding a tenant looks like: create the datasource, run migrations, run seeds. Removing a tenant is destroying the datasource. There is no row-leak risk to audit, because there are no rows to leave behind.
114
+
115
+
## Operational story
116
+
117
+
Per-datasource backup and restore. Per-datasource scaling. A tenant hammering your CPU doesn't starve the others. A tenant requesting GDPR erasure is a `DROP DATABASE` away from complete.
118
+
119
+
One note: 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.
120
+
121
+
## Where this lands in 4.0
100
122
101
-
-**Architecture diagram:** request arrives → TenantResolver middleware → datasource resolved → controller/model → (on enqueue) job stored with tenant context → worker picks up → tenant context restored → model queries land on correct DB. Highlight that "tenant context" is one concept threaded through.
102
-
-**Comparison table:** Wheels 4.0 vs Rails+apartment vs Laravel+stancl vs Django-tenants — axis: framework-native? separate-DB model? tenant-aware jobs? schema model? Row-level model?
123
+
Multi-tenancy was one of the themes we pushed hardest on during the sprint to 4.0 — roughly 260 pull requests across 15 weeks, with contributions from @bpamiri, @zainforbjs, @chapmandu, @mlibbe, @MukundaKatta, and Dependabot keeping the dependency graph honest.
103
124
104
-
## Outro / CTA
125
+
If you've been thinking "we'll deal with multi-tenancy later," 4.0 makes "now" a lot cheaper than "later." The seam is in place. Middleware, datasource resolution, and background job context restoration are all wired together. The expensive part — retrofitting tenant awareness onto an app that wasn't designed for it — is the part you avoid by picking it up early.
105
126
106
-
- "If you've been thinking 'we'll deal with multi-tenancy later,' 4.0 makes 'now' a lot cheaper than 'later.'"
107
-
- Link to multi-tenancy docs once they land in `docs/src/`.
108
-
- Invite feedback on real SaaS patterns — this is a feature the team wants to harden based on production use.
127
+
## Where to go next
109
128
110
-
## Citations (must link in final post)
129
+
-[Multi-tenancy guide](https://guides.wheels.dev/v4-0-0-snapshot/digging-deeper/multi-tenancy/) — the user-facing reference for tenant resolution, datasource switching, and migrations per tenant.
130
+
-[Background jobs guide](https://guides.wheels.dev/v4-0-0-snapshot/digging-deeper/background-jobs/) — how the tenant context is restored when a worker picks up a job.
131
+
-[Middleware pipeline](https://guides.wheels.dev/v4-0-0-snapshot/core-concepts/middleware-pipeline/) — where the tenant resolver lives.
132
+
-[Full audit § Multi-tenancy](https://github.com/wheels-dev/wheels/blob/develop/docs/releases/wheels-4.0-audit.md) — the PR-level receipts.
- Multi-tenancy docs page (to be confirmed / written if absent)
134
+
We'd love to hear from teams running production SaaS on this — especially the pattern of tenant identity source (subdomain vs. path vs. header vs. JWT claim) and how it interacts with your auth layer. That's the area where we expect the 4.0.x polish cycle to want the most feedback.
0 commit comments