Skip to content

Routing

The three macros

Installed by the multitenancy provider on app boot. Idempotent.

MacroWraps withUse for
router.tenant(cb)TenantGuardMiddlewareRoutes that REQUIRE a resolved tenant. Throws if missing, suspended, or not-ready.
router.central(cb)CentralOnlyMiddlewareRoutes that REQUIRE no tenant in scope. Signup, marketing, central admin.
router.universal(cb)UniversalMiddlewareRoutes that work in both contexts. Resolves the tenant when present, never fails when absent.
ts
// start/routes.ts
import router from '@adonisjs/core/services/router'

// Tenant-only: every route here goes through TenantGuardMiddleware
router.tenant(() => {
  router.get('/api/users', '#controllers/users.index')
  router.post('/api/orders', '#controllers/orders.create')
})

// Central-only: tenant context must be absent
router.central(() => {
  router.get('/signup', '#controllers/onboarding.signup')
  router.post('/signup', '#controllers/onboarding.create_tenant')
})

// Universal: works either way (login page, status endpoint)
router.universal(() => {
  router.get('/health', '#controllers/health.show')
  router.get('/login', '#controllers/auth.login')
})

The macros return the underlying RouteGroup so you can chain .prefix(), .use(), .where(), etc.

Custom domain mapping

Custom domains (e.g. acme.com resolving to a tenant) are wired up by CustomDomainMiddleware, registered as a server middleware so it runs before route matching:

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

The middleware queries your tenant repository's findByDomain(host) and rewrites the request to the canonical tenant header before router.tenant() / router.universal() blocks resolve.

Strict mode

By default the middleware lets an explicit x-tenant-id header win over the Host-resolved tenant. That preserves backwards compatibility, but in a multi-tenant SaaS it lets a caller who knows your custom domain shape a request that hops tenants. Opt into strict: true to reject conflicting signals with HTTP 400 (E_TENANT_HEADER_DOMAIN_MISMATCH):

ts
// start/kernel.ts
server.use([
  () => import('@adonisjs-lasagna/saas-tenancy/middleware')
    .then((m) => ({ default: m.CustomDomainMiddleware })),
])
// then attach with options on the route group / kernel that uses
// it, e.g. via a named middleware:
//   middleware.customDomain({ strict: true })

When both Host matches a registered custom domain AND x-tenant-id is present:

ModeHeader agreesHeader disagreesHeader only (no domain match)
Defaultheader winsheader wins (vector!)header wins
strict: trueheader winsreject 400header wins

Imperative API

For non-HTTP code (queue jobs, scripts, ace commands), there is no route. Wrap the work in tenancy.run():

ts
import { tenancy } from '@adonisjs-lasagna/saas-tenancy/services'

await tenancy.run(tenant, async () => {
  // any package code that reads tenancy.currentId() sees tenant.id.
})

This activates the bootstrapper registry around fn. The bundled InstallTenant and UninstallTenant jobs already do this. Your custom jobs should too.

Reverse routing

ts
// Generate a per-tenant URL
const url = router.makeUrl('orders.show', { id: orderId }, {
  prefixUrl: tenant.customDomain
    ? `https://${tenant.customDomain}`
    : `https://${tenant.id}.app.example.com`,
})

When to use which

ScenarioUse
HTTP request requiring a tenantrouter.tenant(() => …)
HTTP request that must NOT have a tenantrouter.central(() => …)
HTTP request that adapts when a tenant is presentrouter.universal(() => …)
Background jobtenancy.run(tenant, fn) inside the handler
Ace command iterating tenantstenant:exec <command> (does the wrap for you)
Test setupsetRequestTenant(tenant) from /testing

Was this page helpful?

Released under the MIT License.