Serve many accounts from one app.
Give each account its own /SLUG path and its own rows, with queries that refuse to run outside a tenant.
| Contract | Value |
|---|---|
| Needs | An application that has run frappe migrate, and a singular model name to own the data, such as Account |
| You will have | Each account under its own /SLUG path with its own rows, and queries that refuse to run outside a tenant |
| Proved by | frappe routes · frappe corretto |
The guide in five stages
10 sections · read in order the first time
- 1 · Start
- 2 · Model
- 3 · Use
- 4 · Operate
- 5 · Limits
Stage 1 · Start
Enable tenancy
Multi-tenancy is opt-in, and a plain application does not compile it. Name the model that owns the data; any singular model name works.
frappe make tenancy Account
frappe migrateExpect: frappe make tenancy prints a Generated line for each file it writes and ends with a Next: line that names frappe migrate and the sign-up page at /accounts/new. With a SweetTalk domain present, the Next: line adds that each domain in app/domain/ now names App::Account. frappe migrate applies create_accounts.
The command writes the Account model (name, slug, timestamps, a unique slug index), its changeset, the create_accounts migration, a sign-up page at /accounts/new, a home page at /SLUG and a request spec. It requires caramel/tenancy after caramel in config/application.cr, adds the tenant block to config/routes.cr and Caramel.resource_paths :accounts, :account to config/paths.cr, and adds the account and tenant_session spec helpers. In every app/domain/*.cr it adds tenant App::Account after include SweetTalk, because a program that requires caramel/tenancy refuses a domain that does not name its tenant. Sign up at /accounts/new; a new account redirects to its home.
Stage 2 · Model
Route inside the tenant
The tenant block names the tenant model and the String field whose value is the first path segment. Routes inside it live under the slug, so /notes answers at /acme/notes. This is the block frappe make tenancy Account writes, with the Note resource of the next section inside it:
Caramel::Router.draw do
get "/", App::Home::Show
get "/health", App::Health::Show
get "/accounts/new", App::Accounts::New
post "/accounts", App::Accounts::Create
# Frappé resource routes
tenant App::Account, by: :slug do
get "/", App::Accounts::Home
get "/notes", App::Notes::Index
# Frappé tenant routes
end
endfrappe routes lists tenant routes as /:tenant/notes. A tenant is chosen by its path prefix only: acme.example.com selects nothing.
Generate tenant resources
After tenancy is enabled, frappe make resource writes resources that belong to the tenant: routes inside the block, tenant account : Account in the model, and a request spec. Pass --central for a resource every tenant shares; it is identical to a plain application’s.
frappe make resource Note body:string
frappe make resource Tag label:string --centralExpect: Note’s routes sit inside the tenant block, so frappe routes lists them as /:tenant/notes; Tag’s stay central at /tags.
Declare tenanted data
The tenant statement adds the account_id column and scopes the schema to the bound tenant. The column is never a changeset param. This is the schema frappe make resource Note body:string wrote:
schema "notes" do
field id : Int64, primary: true
tenant account : Account
field body : String
timestamps
end| Condition | Outcome |
|---|---|
| A tenanted query with no tenant bound | raises SugarORM::Tenancy::Missing: “App::Note is tenanted, but no tenant is bound.” |
| Another tenant’s id | answers 404, like an unknown one |
A belongs_to to a row of another tenant, even from raw SQL | is refused by PostgreSQL’s composite foreign key |
Creating a tenanted row inside Caramel::Tenancy.without | raises |
- Queries,
find,count,exists?,delete_all, preloads, updates and deletes see only the bound tenant’s rows, and inserts are stamped with it. - A unique index on a tenanted schema is unique per tenant, so two accounts may hold the same value.
- A
belongs_toto another tenanted schema becomes a composite foreign key. Caramel::Tenancy.without { … }reaches every tenant’s rows on purpose.- Raw
SugarORM.sqlis not scoped.
Stage 3 · Use
Use the tenant in actions and views
Actions and views expose tenant, the request’s tenant record, and tenant_path, a path under its prefix. Generated resource path helpers already add the prefix.
h1 { tenant.name }
a(href: tenant_path("/")) { "Home" } # /acme
a(href: notes_path) { "Notes" } # /acme/notesWith locale prefixes, the tenant comes first: /acme/fr/notes. The language switcher keeps the tenant prefix.
Carry the tenant into background work
Outside a request, bind a tenant explicitly. Caramel::Tenancy.current and current? read the bound one.
Caramel::Tenancy.with(account) do
App::Note.query.count
end
Caramel::Tenancy.each do |account|
# Runs once per tenant, bound.
endA Cold Brew job enqueued in a tenant runs in it. A spawned fiber starts with no tenant, so wrap its work in with. Cache keys and PubSub channels are prefixed per tenant with t<id>:.
| Condition | Outcome |
|---|---|
| A Cold Brew job whose tenant was deleted before it ran | fails with Caramel::Tenancy::Gone |
| A prefixed PubSub channel name over 63 characters | raises ArgumentError |
Stage 4 · Operate
Validate slugs and check membership
validate_tenant_slug, which the generated Account changeset calls, checks the address a tenant gets:
| Condition | Outcome |
|---|---|
| A slug that is not a lowercase DNS label (letters, digits and hyphens, 1 to 63 characters, no leading or trailing hyphen) | adds has invalid format to the field |
A slug equal to a central route’s first segment, such as health or accounts | adds has already been taken to the field |
| A slug equal to a locale prefix code | adds has already been taken to the field |
Tenancy decides which rows a request sees, not who may act in a tenant. Check membership in the application: ingress authenticate: :member? on an action names an instance method returning Bool, and false answers 401 before the contract binds.
Add tenancy to existing data
Adding tenant to a populated table needs five steps, because the new column is NOT NULL and its index must exist before another tenanted table can reference it.
- Add
tenant account : Accountto the schema and runfrappe db diff.
frappe db diff --name tenant_notesExpect: Halts with NOT NULL column without a default cannot be added to the existing notes table: existing rows have no value for it. and writes no migration.
- Write a migration by hand that adds the column as nullable, backfills it and sets it NOT NULL.
- In a second migration, build the index:
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "index_notes_on_account_id_and_id" ON "notes" ("account_id", "id"). When another tenanted table’s key references the table, the differ halts until the index exists and prints this SQL. - Run
frappe migrate, thenfrappe db diffagain for the foreign keys. - Drain every queue before deploying: a job enqueued without a tenant runs with none.
APP jobs statsExpect: Prints one row per queue with its running, retrying, queued, scheduled and failed counts. Deploy once every row’s queued, scheduled and running are 0.
| Condition | Outcome |
|---|---|
frappe db diff on a populated table that gains a NOT NULL column | halts and writes no migration |
| A job enqueued without a tenant, run on tenanted rows | fails with SugarORM::Tenancy::Missing |
Read the multi-tenancy design ↗
Stage 5 · Limits
What does not compile
A router has one tenant block. Each mistake below stops the build.
| Condition | Outcome |
|---|---|
A central route other than / that starts with a parameter | fails compilation |
| A tenant route that starts with a central route’s first segment | fails compilation |
| A router with a second tenant block | fails compilation |
A SweetTalk domain that does not name the tenant, in a program that requires caramel/tenancy | fails compilation |
Not covered yet
- A tenant is selected by its
/SLUGpath prefix only. There is no host or subdomain tenancy. - Membership and authorization are the application’s: tenancy answers 404 for another tenant’s rows, and nothing else.
Caramel::Tenancy.withoutand rawSugarORM.sqlare unscoped; the application adds the tenant predicate itself.- Removing tenancy follows the design’s “Plugging out” steps by hand; no command does it.
Prove it
Test isolation
tenant_session("acme") opens a Corretto session in a new account, and the example’s queries see only its rows. account(db, "globex") creates a second account to prove isolation. frappe make resource Note already wrote these lines into its spec; nothing to add.
tenant_session("acme") do |client, db|
created = client.post("/acme/notes", params: {"body" => "Example <body>"})
record = App::Note.query.order_by(:id, :desc).first!(db)
# …
account(db, "globex")
client.get("/globex/notes/#{record.id}").should have_status(404)
# …
endfrappe routes
frappe corretto spec/requests/notes_spec.crPassing: frappe routes lists /:tenant/notes beside the central /tags. frappe corretto ends with Corretto: 1 of 1 workers passed: a request from acme for globex’s note answers 404.