Skip to content

Installation

Requirements

  • Node.js 24 or newer
  • AdonisJS 7
  • @adonisjs/lucid configured against PostgreSQL 14+
  • @adonisjs/redis (cache + counters)
  • @adonisjs/queue (background jobs that provision schemas)
  • @aws-sdk/client-s3 (optional, only for S3 backup uploads)
  • jose (optional, only when SSO is enabled)

1. Install and configure

bash
npm install @adonisjs-lasagna/saas-tenancy
node ace configure @adonisjs-lasagna/saas-tenancy

The configure command does three things:

  1. Registers MultitenancyProvider in adonisrc.ts.
  2. Publishes config/multitenancy.ts from a typed defineConfig({...}) stub.
  3. Scaffolds app/models/backoffice/tenant.ts.

By default it also publishes migration stubs for every satellite (audit, feature_flags, webhooks, branding, sso, metrics). You usually want to be selective:

bash
# Only audit logs and webhooks
node ace configure @adonisjs-lasagna/saas-tenancy --with=audit,webhooks

# Interactive (prompts you with a checkbox list)
node ace configure @adonisjs-lasagna/saas-tenancy

# CI-friendly: explicit list, no prompt
node ace configure @adonisjs-lasagna/saas-tenancy --no-interaction --with=audit,branding,feature_flags

2. Set up your database connections

Three connection contexts live side by side. Add them to config/database.ts:

ts
// config/database.ts
export default defineConfig({
  connections: {
    public: {
      client: 'pg',
      connection: { ...baseConn, searchPath: 'public' },
    },
    backoffice: {
      client: 'pg',
      connection: { ...baseConn, searchPath: 'backoffice' },
    },
    // Tenant connections are created at runtime, no entry needed here.
  },
})
ConnectionSchemaPurpose
publicpublicShared global data
backofficebackofficeTenant registry + satellite features
tenant_<uuid>tenant_<uuid>Per-tenant data, created on demand

Why three connections?

Three lifecycles, three schemas. Data owned by your app (public), data owned by your operators (backoffice), and data owned by individual customers (per-tenant). Mixing them eventually bites; tenant exports leak admin rows, backups balloon, migrations target the wrong schema.

3. Bootstrap the backoffice

bash
node ace backoffice:setup

Creates the backoffice schema and runs all satellite-table migrations in one shot. Idempotent; re-run any time.

4. Bind the tenant repository

The package never imports your Tenant model; it asks the IoC container for a TenantRepositoryContract. Wire it once in your app provider:

ts
// providers/app_provider.ts
import { TENANT_REPOSITORY } from '@adonisjs-lasagna/saas-tenancy'

export default class AppProvider {
  async boot() {
    this.app.container.singleton(TENANT_REPOSITORY, async () => {
      const { default: Tenant } = await import('#models/backoffice/tenant')
      return {
        findById: (id) =>
          Tenant.query().whereNull('deleted_at').where('id', id).first(),

        findByDomain: (host) =>
          Tenant.query().whereNull('deleted_at').where('custom_domain', host).first(),

        all: (filters = {}) => {
          const q = Tenant.query().whereNull('deleted_at')
          if (filters.status) q.where('status', filters.status)
          return q
        },
      }
    })
  }
}

5. Register middleware

ts
// start/kernel.ts

router.use([
  () => import('@adonisjs-lasagna/saas-tenancy/middleware')
    .then(m => ({ default: m.TenantGuardMiddleware })),
])

server.use([
  () => import('@adonisjs-lasagna/saas-tenancy/middleware')
    .then(m => ({ default: m.CustomDomainMiddleware })),
])

router.use([
  () => import('@adonisjs-lasagna/saas-tenancy/middleware')
    .then(m => ({ default: m.RateLimitMiddleware })),
])

RateLimitMiddleware is fail-closed by default: if the Redis backend is unreachable, the middleware throws RateLimitUnavailableException (HTTP 503) rather than silently letting traffic through. Opt into the legacy fail-open behaviour on a per-route basis only if you'd rather risk abuse than degraded availability:

ts
// per-route options
.use(middleware.rateLimit({ limit: 100, windowSeconds: 60, failOpen: true }))

The middleware also short-circuits when app.inTest === true, so the rest of your integration suite isn't gated on Redis. Tests that target the rate-limit codepath itself must opt in with bypassInTestEnv: true.

6. Create your first tenant

bash
node ace tenant:create "Acme Corp" "admin@acme.example.com"
node ace queue:work    # in another terminal — provisions the schema

Once the InstallTenant job finishes, the row flips to status: 'active' and tenant-scoped routes light up.

  • Tenant identification; pick a resolver strategy.
  • Data isolation; choose between schema-per-tenant, database-per-tenant, and row scoping.
  • Routing. The router.tenant(), router.central(), and router.universal() macros plus custom domain mapping.

Was this page helpful?

Released under the MIT License.