I spent a while moving a large multi-tenant SaaS platform off Express 4 +
Sequelize. The numbers going in:
- 54 apps, ~593 routes
- 241 tables across 434 migrations
- database-per-tenant — one Postgres database per customer organisation
- one 3,046-line controller, and a route table with 26 endpoints that had never once executed
I did it twice. First as a strangler — KickJS mounted inside the running
Express app, serving /api/v2 beside the legacy /api/v1, proven route by route
against the original. Then, once that had taught me what the system actually
does, as a clean rewrite on a fresh KickJS host with Drizzle.
Both runs used the same framework, and that is the thing I want to write about.
Most frameworks are good at one of those two jobs.
Versions throughout:
@forinda/kickjs@8.3.1,@forinda/kickjs-cli@8.1.5,
Express 5.1, zod 4, Drizzle 0.45. Code examples use a generic
catalog/orders domain.
The feature that made the strangler possible
A KickJS module's routes() can return a controller or a raw Express router,
and you can mount both under the same path:
routes() {
return [
{ path: '/catalog', controller: CatalogController }, // the routes I'd converted
{ path: '/catalog', router: legacyCatalogRouter }, // the ones I hadn't
]
}
Express takes the first match, so converted routes win and the legacy router
serves the rest. No unconverted route registration has to be copied, and a
route moves across one at a time by adding it to the controller and deleting
nothing.
That is what let a 593-route app migrate incrementally instead of in a big bang.
I haven't found another structured Node framework where "here is an Express
Router, serve it beside my controllers" is a first-class mount.
The inverse also worked, and is undocumented: new Application() + setup()
gives you the pipeline without a listener, and handle() takes raw Node
IncomingMessage/ServerResponse — which is how a KickJS app ran inside an
Express 4 host despite the framework declaring an Express 5 peer.
Context contributors — and how to write one
This is the part I'd steal for any other project. A contributor computes one
typed value per request and publishes it on the context under a key. Here is a
complete one, in the three pieces it always has.
1. Declare the key and its type, by augmenting the framework's registry.
This is what makes ctx.require('actor') typed everywhere downstream:
import { defineHttpContextDecorator, HttpException } from '@forinda/kickjs'
export interface Actor {
readonly userId: string
readonly organizationId: string
readonly isAdmin: boolean
}
declare module '@forinda/kickjs' {
interface ContextMeta {
actor: Actor
}
}
2. Define the resolver. deps pulls services out of the DI container,
dependsOn declares which other contributors must run first, and skipWhen
names the route flags that switch it off entirely:
export const Actor = defineHttpContextDecorator({
key: 'actor',
skipWhen: ['auth.public'],
dependsOn: ['tenantDb'],
deps: { repo: AUTH_REPOSITORY },
async resolve(ctx, { repo }): Promise<Actor> {
const token = bearerToken(ctx.req.headers.authorization)
if (!token) throw new HttpException(401, 'Authentication required')
const claims = await verifyAccessToken(token)
if (!claims) throw new HttpException(401, 'Authentication required')
return repo.loadActor(claims.sub)
},
})
Throwing from resolve is the rejection — there's no separate guard step.
3. Register it, either globally in bootstrap({ contributors }), on a module
when every route in it shares the value, or per handler as a decorator:
contributors() {
return [Actor.registration, TenantDb.registration]
}
Two things earn their keep:
The dependency is declared, so it fails at boot. The legacy app had two
different producers of the same tenant-database key — one from an x-tenant-id
header, one from the bearer token. Declaring dependsOn caught a real bug on the
first run: two routes carried the token-based resolver with no authenticated
user, which would have handed the service undefined at runtime. Missing
dependencies and cycles fail at startup, not per request.
Precedence is method > class > module > adapter > bootstrap. Register once on
the module when every route shares a value; override per handler when one
doesn't. In the rewrite the whole tenant story is a chain the framework
topo-sorts for you — order in the array is irrelevant:
tenantRoute → tenantDb → actor → permissions
Compare with the Express version, which resolved the tenant in the auth
middleware by copying the tenant middleware's logic inline, then wrote the
result to four differently-named request properties. Both are "middleware". Only
one of them tells you when you've wired it wrong.
Parameterised decorators
The same primitive builds a decorator that takes arguments. withParams and
requiredParams are the whole trick:
declare module '@forinda/kickjs' {
interface ContextKeys {
// Key-only: the value is the decision, and nothing downstream reads it.
permissionGranted: true
}
}
export const RequirePermission = defineHttpContextDecorator.withParams<{
action: ActionName
module: ModuleName
}>()({
key: 'permissionGranted',
dependsOn: ['permissions'],
// Makes a bare `@RequirePermission` a boot error rather than a route that
// silently checks nothing.
requiredParams: ['action', 'module'],
resolve(ctx, _deps, params): true {
const decision = can(ctx.get('permissions'), params.action, params.module)
if (!decision.allowed) {
throw new HttpException(403, `Forbidden: ${decision.reason}`)
}
return true
},
})
Used at the point it applies to:
@RequirePermission({ action: 'create', module: 'orders' })
@Post('/')
create(ctx: Ctx<KickRoutes.OrderController['create']>) { … }
Note the ContextMeta / ContextKeys split: ContextMeta when something
downstream reads the value, ContextKeys when the decorator exists only for its
side effect. Both give you compile-time checking on dependsOn strings.
Route flags, and inverting the default
The single highest-value change in the rewrite was three lines:
skipWhen: ['auth.public', 'tenant.controlPlane']
Authentication now rejects by default, and a route opts out explicitly. The
legacy app worked the other way round: you added preAuthorize() to each route
that needed it.
That difference is not theoretical. Porting turned up whole modules where someone
had simply forgotten — every route open, nobody having decided they should be.
Forgetting to add a guard is silent. Forgetting to add auth.public is a 401 on
your first request.
Declaring a flag is one line, and it's worth putting them all in one file:
import { defineRouteFlag } from '@forinda/kickjs'
/** Reachable without a token — login, password reset, health. */
export const Public = defineRouteFlag('auth.public')
/** Deliberately not tenant-scoped — operator console, provisioning. */
export const ControlPlane = defineRouteFlag('tenant.controlPlane')
@Public
@Post('/login')
login(ctx: Ctx<…>) { … }
kick typegen collects every defineRouteFlag call into a KickRouteFlags
registry, so a typo in a skipWhen string is a compile error rather than a
contributor that silently never skips. Flags resolve method > class > mount, same
as contributors, and @Flag.off removes an inherited one.
A flag carries no behaviour of its own — it records a fact that any consumer can
read. That's what makes them composable: the same auth.public is read by the
actor contributor, the audit log and the rate limiter, none of which know about
each other.
Pre-match middleware can read them too
I initially assumed flags were unavailable to connect-style middleware, since
that runs before the router matches and there's no route yet. Wrong — there's an
explicit hand-off:
import { bindRoutePolicy, type RoutePolicyTable } from '@forinda/kickjs'
export function auditUnflagged() {
let policy: RoutePolicyTable | undefined
const handler = (req, _res, next) => {
const flags = policy?.lookup(req.method, req.url ?? '/')
if (!flags?.has('audit.skip')) log(req.url)
next()
}
return bindRoutePolicy(handler, (table) => { policy = table })
}
lookup() returns an empty map when the path matches no route at all — which
is precisely the traffic you want a rate limiter or an audit log to still see.
The table is handed per-application rather than through a global, so two apps in
one process never see each other's routes.
The split is deliberate, and the pairing is instructive: rateLimit() runs
pre-match because an unmatched surface still needs protecting; csrfGuard() is a
guard because a token check on a route that doesn't exist is meaningless. Having
both forms — and being pointed at the right one by which of them exposes the
table — beats picking one and living with it.
Typed end to end without ceremony
kick typegen walks the modules and generates a KickRoutes namespace. In the
handler you get params, body, query and response typed with no codegen artifacts
in your source:
@Post('/', { body: createOrderSchema, name: 'CreateOrder' })
@ApiTags('Orders')
@RequirePermission({ action: 'create', module: 'orders' })
async create(ctx: Ctx<KickRoutes.OrderController['create']>) {
return reply.created(await this.orders.create(ctx.body))
}
One zod schema gives you runtime validation and the OpenAPI request body.
ctx.body is inferred from it. The return type flows into
KickRoutes[...].response, which is what makes a generated client know what each
route hands back.
The rewrite currently registers 459 routes and 295 services this way.
A related nicety: handlers return values rather than writing to the response.
reply(status, body) when the status isn't 200, ctx.problem.notFound(...) for
error branches — the latter sends immediately and returns nothing, so failure
cases stay out of the inferred success type.
Adapters as the composition seam
An adapter bundles everything one concern needs — DI registrations, middleware,
contributors, lifecycle hooks, a health probe:
export const TenantAdapter = defineAdapter({
name: 'TenantAdapter',
build: (config) => ({
middleware() { /* connect-style, phase-scoped, path-scoped */ },
contributors() { return [TenantRoute.registration, TenantDb.registration] },
beforeStart(ctx) { /* late DI wiring */ },
async onHealthCheck() { return { name: 'tenant-db', status: 'up' } },
async shutdown() { /* close what you own */ },
}),
})
In the legacy app, that same concern was smeared across two middleware files, a
connection registry and four request aliases. Here it's one file you can read top
to bottom.
Phases (beforeGlobal / afterGlobal / beforeRoutes / afterRoutes) and an
optional path scope mean you rarely need to think about ordering.
Drizzle setup and tenant database routing
Database-per-tenant is the part people usually ask about, so here is the whole
mechanism. It is smaller than you'd expect, because the framework supplies the
per-request plumbing and Drizzle supplies the rest.
Two schemas, two migration sets
The schema splits in two. A control database holds routing and identity —
which organisations exist, which hosts point at them, which physical database
each one lives in. A tenant database, one per organisation, holds everything
else.
src/db/schema/control/ organizations, tenant_domains, tenant_db_assignment, …
src/db/schema/tenant/ the actual domain — 240 tables
migrations/control/ 0000_control-baseline.sql, …
migrations/tenant/ 0000_tenant-baseline.sql, …
Two drizzle-kit configs, because they migrate independently — the control
database once, the tenant migrations across every provisioned tenant:
// drizzle.tenant.config.ts
export default defineConfig({
schema: './src/db/schema/tenant/index.ts',
out: './migrations/tenant',
dialect: 'postgresql',
casing: 'snake_case',
dbCredentials: { url: process.env.TENANT_DATABASE_URL ?? '' },
})
Conventions worth locking in early, because retrofitting them across 240 tables
is miserable: one table per file, enums in their own files, and named migrations
only (drizzle-kit generate --name=add-order-table) so git history reads as
something other than timestamps.
Column mixins
Every table is built from the same mixins, which is how you get audit columns
and soft delete without 240 opportunities to forget one. The audit foreign key
is a parameter, since control-side tables point at operator users and tenant-side
tables at tenant users:
export function withAudit(opts: AuditMixinOpts) {
const target = resolveFk(opts)
return {
created_by: auditFk('created_by', target),
created_at: timestamp('created_at', { withTimezone: true })
.notNull().default(sql`now()`),
updated_by: auditFk('updated_by', target),
updated_at: timestamp('updated_at', { withTimezone: true })
.notNull().default(sql`now()`),
}
}
export function withAuditAndSoftDelete(opts: AuditMixinOpts) {
return {
...withAudit(opts),
deleted_at: timestamp('deleted_at', { withTimezone: true }),
deleted_by: auditFk('deleted_by', target),
deletion_reason: text('deletion_reason'),
}
}
A table then reads:
export const orders = pgTable('orders', {
id: pk(),
organization_id: fk('organization_id').notNull(),
total_minor: bigint('total_minor', { mode: 'bigint' }).notNull(),
currency: char('currency', { length: 3 }).notNull(),
...withAuditAndSoftDelete({ fkColumn: () => users.id }),
})
The fkColumn accepts a thunk so a self-referential table — users.created_by
pointing at users.id — needs no forward declaration.
For the record, the legacy schema carried audit columns in two spellings
(created_by on 175 tables, createdBy on 46) and soft delete on 187 of 241.
Normalising that is a mixin, not a migration project.
Keys are ULIDs, generated client-side with a monotonic factory so ids minted
in the same millisecond still sort in creation order:
export const pk = () =>
char('id', { length: 26 }).primaryKey().$defaultFn(() => newId())
Postgres has no ULID type, so it's char(26) — ten bytes wider than a uuid, and
readable in a log line without decoding. Generating client-side means children
can link to a parent before either is flushed.
One pool per instance, not per tenant
The connection layer is about forty lines. The important detail is that pools are
cached by connection URL, so a hundred tenants sharing one Postgres instance
share one pool rather than opening a hundred:
const pools = new Map<string, postgres.Sql>()
function poolFor(url: string): postgres.Sql {
let sql = pools.get(url)
if (!sql) {
sql = postgres(url, { max: 10, onnotice: () => {} })
pools.set(url, sql)
}
return sql
}
export function createTenantDb(url: string) {
return drizzle(poolFor(url), { schema: tenantSchema })
}
The routing table stores an instance URL and a database name separately, so
moving a tenant to another instance is a row update rather than a migration:
export function tenantUrl(instanceUrl: string, dbName: string): string {
const url = new URL(instanceUrl)
url.pathname = `/${dbName}`
return url.toString()
}
Routing a request to its database
Two contributors, and this is where the framework earns its place. The first
answers which organisation:
export const ResolveTenantRoute = defineHttpContextDecorator({
key: 'tenantRoute',
skipWhen: 'tenant.controlPlane',
deps: { router: TENANT_ROUTER },
async resolve(ctx, { router }): Promise<TenantRoute | null> {
const headers = ctx.req.headers as Record<string, string | string[] | undefined>
return router.resolve({
host: pickHost(headers, { trustProxy: getEnv('TRUST_PROXY') === true }),
tenantHeader: firstHeader(headers['x-tenant-id']),
})
},
})
The second answers which connection, and depends on the first:
export const LoadTenantDb = defineHttpContextDecorator({
key: 'tenantDb',
skipWhen: 'tenant.controlPlane',
dependsOn: ['tenantRoute'],
deps: { resolver: TENANT_DB_RESOLVER },
async resolve(ctx, { resolver }): Promise<TenantDb | null> {
const route = ctx.get('tenantRoute')
if (!route) return null
return resolver.forOrg(route.orgId)
},
})
Four decisions in there are worth stealing:
Host-first, header as a fallback. A host is set by the connection; a header
is set by whoever is calling. The legacy app was header-first, which meant the
tenant selector was fully client-controlled. Keeping x-tenant-id behind a
feature flag is what lets existing clients keep working while hosts roll out.
The header is a selector, not a credential. It says which tenant to route to,
never who is calling. The actor contributor separately checks that the
authenticated user's organisation matches the resolved one — without that, a
header alone would be a tenant-hopping primitive.
Both resolve to null rather than throwing. Control-plane routes legitimately
have no tenant, and they say so with the tenant.controlPlane flag, which
skipWhen reads. A route that genuinely needs a connection is the thing that
turns null into a 400. The legacy middleware answered such requests itself with
a 400, which is why "public" routes there could still be refused for tenant
reasons nobody had asked for.
tenantDb depends on tenantRoute, not on the actor. Unauthenticated tenant
routes — login, password reset — still need their tenant's database. Get this
edge wrong and login is impossible on a per-tenant database.
Downstream, a repository just asks for it:
const db = ctx.require('tenantDb')
return db.select().from(orders).where(eq(orders.organization_id, orgId))
For comparison, the Express version registered a Sequelize connection into a
global model registry per request, keyed by a database name formatted from the
organisation id, in a middleware that also did authentication.
The sharp edges, honestly
A post with no criticism is marketing, so:
@Middleware() is not where Express middleware goes. It's engine-neutral and
calls its handler as (ctx, next). Hand it a connect-style (req, res, next) and
next binds to the response object — you get TypeError: next is not a function,
a 500 whose stack points at your middleware rather than at the mismatch. Express
middleware belongs in an adapter's middleware(). The CLI's generated template
used to get this backwards; it now says so explicitly and points you at
kick g guard.
routes() paths must be literals. Typegen parses them statically, so hoisting
a path into a const leaves the module unbound, every handler generating with
contextKeys: never — and the type errors point at the controller, not at the
module file that caused it. Recognise the symptom:
Property 'x' does not exist on type 'never' on every handler at once.
Both are the same class of problem: the framework has the right answer, but the
shortest path from the symptom doesn't lead to it. Worth knowing up front, which
is most of why I'm writing this.
What the migration itself taught me
Two things I'd carry to any port, framework aside.
Prove parity with a test, not with a compile. Every module in the strangler
run shipped with a test hitting the same route on both API versions and asserting
identical status, body and content-type. It caught genuinely subtle things: a
route registered twice, a PUT /remove/ where all its siblings were DELETE, a
handler returning a different response shape depending on whether a query
string was present.
Beware the heuristic you invented three modules ago. I had a rule for
predicting whether a module was portable — does the handler return a value or
write to the response? It was wrong three times, always in the same direction,
and cost two modules a second pass each. What actually predicts cost is whether
the handler holds its own queries, which is one grep:
grep -c "getDb(\|dbRepo\[" <controllers>
Rewriting rather than transcribing also surfaces far more. The strangler run
accumulated about 30 recorded findings; the rewrite found 194 — because
writing behaviour fresh forces you to read every branch, where transcription
lets you copy straight past them.
Would I pick it again
For this shape of problem — a structured multi-tenant API with real per-request
scoping — yes. The composition layer is the differentiator: contributors,
adapters, flags and mixed mounting are what let the same framework host both an
incremental strangler and a clean rewrite without fighting either.
It is not batteries-included. You bring your own ORM and validation, the
ecosystem is small, and some of the family packages move at different speeds. If
you want an opinionated end-to-end stack, that's a different tool.
But the primitives are the right primitives, and after 593 routes I never once
wanted to route around them.
Links
KickJS
The features in this post
-
Route flags —
defineRouteFlag,skipWhen, the policy table -
Modules —
routes(), mounting controllers and routers - Controllers
-
Dependency injection — tokens,
deps,@Autowired - Validation — zod/valibot/yup schemas on the route
-
Typegen — the
KickRoutesnamespace -
Configuration — the env schema and
@Value - Swagger · Testing · Generators
- HTTP runtimes — the Express / Fastify / h3 seam
Migration
- Migrating from Express — the official guide. Note it documents the inverse of what I did first: KickJS as host with Express routers mounted inside.
- Adapters
Top comments (0)