Skip to content
carameldocs · 0.16.0
Browse documentation
Guides / Multi-tenancy

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.

ContractValue
NeedsAn application that has run frappe migrate, and a singular model name to own the data, such as Account
You will haveEach account under its own /SLUG path with its own rows, and queries that refuse to run outside a tenant
Proved byfrappe routes · frappe corretto

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.

Terminal · inside your application
frappe make tenancy Account
frappe migrate

Expect: 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.

Share a domain between tenants →

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:

config/routes.cr · after make tenancy and make resource Note
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
end

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

Terminal · inside your application
frappe make resource Note body:string
frappe make resource Tag label:string --central

Expect: 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:

app/models/note.cr · schema excerpt
schema "notes" do
  field id : Int64, primary: true
  tenant account : Account
  field body : String
  timestamps
end
ConditionOutcome
A tenanted query with no tenant boundraises SugarORM::Tenancy::Missing: “App::Note is tenanted, but no tenant is bound.”
Another tenant’s idanswers 404, like an unknown one
A belongs_to to a row of another tenant, even from raw SQLis refused by PostgreSQL’s composite foreign key
Creating a tenanted row inside Caramel::Tenancy.withoutraises
  • 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_to to another tenanted schema becomes a composite foreign key.
  • Caramel::Tenancy.without { … } reaches every tenant’s rows on purpose.
  • Raw SugarORM.sql is 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.

Inside a view’s blueprint · in acme
h1 { tenant.name }
a(href: tenant_path("/")) { "Home" }  # /acme
a(href: notes_path) { "Notes" }       # /acme/notes

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

A task or job · account is a loaded App::Account
Caramel::Tenancy.with(account) do
  App::Note.query.count
end

Caramel::Tenancy.each do |account|
  # Runs once per tenant, bound.
end

A 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>:.

ConditionOutcome
A Cold Brew job whose tenant was deleted before it ranfails with Caramel::Tenancy::Gone
A prefixed PubSub channel name over 63 charactersraises ArgumentError

Stage 4 · Operate

Validate slugs and check membership

validate_tenant_slug, which the generated Account changeset calls, checks the address a tenant gets:

ConditionOutcome
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 accountsadds has already been taken to the field
A slug equal to a locale prefix codeadds 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.

Read the ingress rules →

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.

  1. Add tenant account : Account to the schema and run frappe db diff.
Terminal · inside your application
frappe db diff --name tenant_notes

Expect: 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.

  1. Write a migration by hand that adds the column as nullable, backfills it and sets it NOT NULL.
  2. 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.
  3. Run frappe migrate, then frappe db diff again for the foreign keys.
  4. Drain every queue before deploying: a job enqueued without a tenant runs with none.
Terminal · before the first deploy with tenancy · APP is the application binary
APP jobs stats

Expect: 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.

ConditionOutcome
frappe db diff on a populated table that gains a NOT NULL columnhalts and writes no migration
A job enqueued without a tenant, run on tenanted rowsfails 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.

ConditionOutcome
A central route other than / that starts with a parameterfails compilation
A tenant route that starts with a central route’s first segmentfails compilation
A router with a second tenant blockfails compilation
A SweetTalk domain that does not name the tenant, in a program that requires caramel/tenancyfails compilation

Not covered yet

  • A tenant is selected by its /SLUG path 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.without and raw SugarORM.sql are 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.

spec/requests/notes_spec.cr · the lines make resource wrote
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)
  # …
end
Terminal · inside your application
frappe routes
frappe corretto spec/requests/notes_spec.cr

Passing: 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.