Skip to content

Commit 9181705

Browse files
bpamiriclaude
andauthored
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>
1 parent 22e05a1 commit 9181705

13 files changed

Lines changed: 2728 additions & 754 deletions

docs/releases/blog-skeletons/01-closing-the-maturity-gap.md

Lines changed: 111 additions & 86 deletions
Large diffs are not rendered by default.

docs/releases/blog-skeletons/02-upgrading-from-3x.md

Lines changed: 141 additions & 95 deletions
Large diffs are not rendered by default.

docs/releases/blog-skeletons/03-security-hardening.md

Lines changed: 128 additions & 102 deletions
Large diffs are not rendered by default.

docs/releases/blog-skeletons/04-background-jobs.md

Lines changed: 117 additions & 87 deletions
Large diffs are not rendered by default.

docs/releases/blog-skeletons/05-lucli-zero-docker.md

Lines changed: 117 additions & 112 deletions
Large diffs are not rendered by default.

docs/releases/blog-skeletons/06-testing.md

Lines changed: 108 additions & 91 deletions
Large diffs are not rendered by default.
Lines changed: 81 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,79 @@
11
---
2-
status: skeleton
3-
slot: post 7 (week 3; SaaS-audience focus)
4-
target_length: 900–1200 words
2+
title: 'Multi-Tenancy Built In'
3+
slug: multi-tenancy-built-in
4+
publishedAt: '2026-05-06T07:00:00.000Z'
5+
updatedAt: null
6+
author: Peter Amiri
7+
tags:
8+
- wheels-4
9+
- multi-tenancy
10+
- saas
11+
categories: []
12+
excerpt: >-
13+
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
518
---
619

720
# Multi-Tenancy Built In
821

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_
1023

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.
1529

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
2031

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.
2233

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.
2735

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.
3237

33-
### 3. Tenant resolution — from request to datasource
34-
- Typical sources: subdomain (`acme.myapp.com`), path prefix (`/t/acme`), header (`X-Tenant: acme`), auth claim (JWT `tid`).
35-
- Resolution happens early — as middleware — and stores the tenant context on the request.
36-
- Models pick up the datasource automatically for the rest of the request lifecycle.
38+
## Three models, one framework
3739

38-
### 4. Tenant-aware background jobs
39-
- Enqueue from tenant A's request context → job lands in tenant A's queue.
40-
- Worker picks up job → tenant context restored → `model("Order").findAll()` queries tenant A's DB.
41-
- 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.
4341

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.
4743

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.
5245

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.
5847

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
6449

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.
6651

6752
```cfm
6853
// config/settings.cfm — tenant resolution via middleware
6954
set(middleware = [
70-
new app.middleware.TenantResolver() // sets request-scoped tenant ctx
55+
new app.middleware.TenantResolver()
7156
]);
7257
7358
// app/middleware/TenantResolver.cfc
7459
component implements="wheels.middleware.MiddlewareInterface" {
7560
public any function handle(required struct request, required any next) {
7661
var subdomain = ListFirst(arguments.request.cgi.server_name, ".");
7762
arguments.request.tenant = subdomain;
78-
// Framework datasource switching hook — exact API in docs
7963
$setTenantDatasource(arguments.request.tenant);
8064
return arguments.next(arguments.request);
8165
}
8266
}
8367
```
8468

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.
76+
8577
```cfm
8678
// Job that implicitly runs in tenant context
8779
component extends="wheels.Job" {
@@ -96,21 +88,47 @@ component extends="wheels.Job" {
9688
}
9789
```
9890

99-
## Suggested visuals
91+
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
100122

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.
103124

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.
105126

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
109128

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.
111133

112-
- [Multi-tenancy PR #1951](https://github.com/wheels-dev/wheels/pull/1951)
113-
- [Job worker daemon PR #1934](https://github.com/wheels-dev/wheels/pull/1934) (for the jobs-are-tenant-aware claim)
114-
- [Middleware pipeline PR #1924](https://github.com/wheels-dev/wheels/pull/1924) (for the resolver pattern)
115-
- [Feature audit § Multi-tenancy](https://github.com/wheels-dev/wheels/blob/develop/docs/releases/wheels-4.0-audit.md#9-multi-tenancy)
116-
- 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

Comments
 (0)