AdonisJS
Wire Lucid tenancy into the AdonisJS request lifecycle - all three isolation strategies.
tenancyjs-integration-adonis connects tenancy to the AdonisJS 7 request lifecycle. AdonisJS uses its
own ORM, so this pairs with the Lucid adapter - and because Lucid supports
all three isolation strategies, AdonisJS gets the full range out of the box.
ORM: Lucid (AdonisJS's ORM). Row-level, schema-per-tenant, and database-per-tenant are all supported.
tenancyjs-cli init scaffolds AdonisJS + Lucid end to end, so the fastest path is
npx tenancyjs-cli init. This page covers the manual wiring.
Install
npm install tenancyjs-core tenancyjs-adapter-lucid tenancyjs-integration-adonis tenancyjs-identifiersConfigure
defineAdonisTenancyConfig takes a TenancyManager, a resolver (a
TenantResolutionChain), and the Lucid tenancy
as a factory - AdonisJS loads config before providers boot, so the Lucid service is resolved lazily:
import db from "@adonisjs/lucid/services/db";
import { TenancyManager } from "tenancyjs-core";
import { createLucidTenancy } from "tenancyjs-adapter-lucid";
import {
HeaderTenantResolver,
TenantResolutionChain,
} from "tenancyjs-identifiers";
import { defineAdonisTenancyConfig } from "tenancyjs-integration-adonis";
// import Post from "#models/post";
export interface Tenant {
id: string;
name: string;
}
const manager = new TenancyManager<Tenant>();
const resolver = new TenantResolutionChain<Tenant>({
resolvers: [new HeaderTenantResolver({ headerName: "x-tenant-id" })],
store: {
async find(identifier) {
// Look the identifier up in YOUR store and return the match(es).
// Returning [] means "not found" → the request 404s (nothing is a tenant),
// so wire this to your real tenant table:
const row = await lookupTenant(identifier.value); // your DB lookup
return row
? [{ tenant: { id: row.id, name: row.name }, status: "active" }]
: [];
},
},
// Resolving a tenant is not authorizing it — verify membership (or opt out
// with trustResolution). See /docs/guides/resolving-tenants.
authorize: ({ tenant, principal }) =>
(principal as { teamIds: string[] }).teamIds.includes(tenant.id),
});
export default defineAdonisTenancyConfig<Tenant>({
manager,
resolver,
principal: (ctx) => ctx.auth.user,
tenancy: () =>
createLucidTenancy<Tenant>({
manager,
database: db,
strategy: "rowLevel", // or "schemaPerTenant" | "databasePerTenant"
tenantModels: [/* Post, ... */],
}),
});Register the provider and middleware
Two edits (the init scaffold writes the middleware file for you; the two registrations are manual):
1. Register the provider in adonisrc.ts:
providers: [
// ...existing providers
() => import('tenancyjs-integration-adonis/provider'),
],2. Add the middleware file, register it as a named middleware, and apply it to tenant route groups only - central routes omit it:
export { TenancyMiddleware as default } from "tenancyjs-integration-adonis";export const middleware = router.named({
// ...existing named middleware
tenant: () => import('#middleware/tenant_middleware'),
});router
.group(() => {
// your tenant-scoped routes
})
.use(middleware.tenant());Each request in a tenant group then resolves its tenant and runs scoped, so your Lucid models are isolated automatically - no per-query tenant filters:
// inside a controller - already tenant-scoped
const orders = await Order.all();How it behaves
- The middleware opens a tenant scope for the request; Lucid queries run scoped through the adapter.
- Resolution failures fail closed with the right status before your controller runs.
- Central-scope work (cross-tenant admin) is opened explicitly, never by accident.
In Ace commands and scripts, call tenancy.validate() yourself. The provider validates the adapter
automatically only in the web environment (so migration:run isn't blocked by the very policies
it creates). In an Ace command or a standalone script, run const { valid } = await tenancy.validate()
(and check it) before your first tenancy.run(...) - otherwise the adapter refuses with
LucidPolicyValidationError ("validate() must pass before protected execution"). This is fail-closed
working as designed; it's just a step the request path does for you and a script doesn't.