<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged kickjs</title>
<link>https://coderlegion.com/tag/kickjs</link>
<description>Powered by Question2Answer</description>
<item>
<title>Route flags, context decorators and typed assets in KickJS: declare it once, use it everywhere</title>
<link>https://coderlegion.com/28152/route-flags-context-decorators-and-typed-assets-in-kickjs-declare-it-once-use-everywhere</link>
<description>&lt;p&gt;Every API accumulates the same kind of glue. &quot;This endpoint is public&quot; gets written three times — once for auth, once for CSRF, once for rate limiting — and two of those are path strings that break the day someone renames a route. &quot;Who is the current user&quot; becomes &lt;code&gt;req.user as any&lt;/code&gt;. &quot;Where is the welcome email template&quot; becomes &lt;code&gt;__dirname&lt;/code&gt; arithmetic that works in dev and fails in the Docker image.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/&quot;&gt;KickJS&lt;/a&gt; has three features that remove that glue, and they are far more useful &lt;strong&gt;together&lt;/strong&gt; than apart:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Feature &lt;/th&gt;&lt;th&gt;What it answers &lt;/th&gt;&lt;th&gt;Mental model&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Route flags&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;What is true about this route?&lt;/em&gt; &lt;/td&gt;&lt;td&gt;A fact, written once on the route, read by anything. No behaviour.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Context decorators&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;What do we know about this request?&lt;/em&gt; &lt;/td&gt;&lt;td&gt;A typed value computed per request, before the handler runs.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Asset manager&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;Where is that file?&lt;/em&gt; &lt;/td&gt;&lt;td&gt;A typed path to a file shipped with your code, correct in dev and in the build.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This guide explains each one, then builds a small feature set that uses all three at once: public and private routes, a signed-in user, plan-gated endpoints, per-route rate limits, and locale-aware emails and reports rendered from typed templates.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Versions: &lt;code&gt;@forinda/kickjs&lt;/code&gt; 8.4+, &lt;code&gt;@forinda/kickjs-cli&lt;/code&gt; 8.2+, &lt;code&gt;@forinda/kickjs-testing&lt;/code&gt; 8.0+. All three features are in the core package — nothing extra to install.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-route-flags-facts-about-a-route&quot;&gt;1. Route flags: facts about a route&lt;/h3&gt;
&lt;p&gt;A route flag is a &lt;strong&gt;named, inheritable fact&lt;/strong&gt; about a route. It does nothing by itself; it records something any consumer can read.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/flags.ts
import { defineRouteFlag } from '@forinda/kickjs'

export const Public = defineRouteFlag('auth.public')
export const RateLimit = defineRouteFlag&amp;lt;{ rpm: number }&amp;gt;('rate.limit')
export const Feature = defineRouteFlag&amp;lt;string&amp;gt;('billing.feature')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put flags on a controller, a method, or a module mount. The most specific declaration wins — &lt;strong&gt;method &amp;gt; class &amp;gt; mount&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Public // every route in this controller is public…
@Controller()
export class DocsController {
  @Get('/')
  index(ctx: RequestContext) {} // public

  @Public.off // …except this one
  @Get('/drafts')
  drafts(ctx: RequestContext) {} // not public
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read them anywhere you hold a &lt;code&gt;RequestContext&lt;/code&gt; — handlers, &lt;code&gt;@Middleware()&lt;/code&gt;, guards, contributors:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;ctx.route?.flags.has('auth.public') // true / false
ctx.route?.flags.get('rate.limit') // { rpm: 10 } or undefined
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two rules make flags predictable:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Removal is &lt;code&gt;.off&lt;/code&gt;, never a falsy value.&lt;/strong&gt; A flag is either present or absent. &lt;code&gt;@Public.off&lt;/code&gt; removes it; there is no &quot;present but false&quot; state that a presence check could misread as &quot;public&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;false&lt;/code&gt; is a real value.&lt;/strong&gt; A flag declared with a boolean type can store &lt;code&gt;false&lt;/code&gt;, and &lt;code&gt;flags.get()&lt;/code&gt; returns it. Only &lt;code&gt;.off&lt;/code&gt; removes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;ctx.route&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; in global middleware, which runs before a route is matched (section 8 shows how pre-routing middleware reads flags anyway).&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-context-decorators-typed-values-about-a-request&quot;&gt;2. Context decorators: typed values about a request&lt;/h3&gt;
&lt;p&gt;A context decorator (also called a &lt;em&gt;contributor&lt;/em&gt;) computes a value from the request and stores it on the context &lt;strong&gt;before the handler runs&lt;/strong&gt;. It replaces middleware whose only job was &quot;compute X and stash it on the request&quot;.&lt;/p&gt;
&lt;p&gt;Scaffold one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kick g contributor locale --dry-run
kick g contributor locale -o src/contributors
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fill it in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/locale.contributor.ts
import { defineHttpContextDecorator } from '@forinda/kickjs'

// 1. Declare the key's type once, for the whole app.
declare module '@forinda/kickjs' {
  interface ContextMeta {
    locale: { language: string; region: string | null }
  }
}

// 2. Define how it's computed.
export const ResolveLocale = defineHttpContextDecorator({
  key: 'locale',
  resolve: (ctx) =&amp;gt; {
    const header = (ctx.req.headers['accept-language'] as string | undefined) ?? 'en'
    const [language, region] = header.split(',')[0].trim().split('-')
    return { language: language.toLowerCase(), region: region ?? null }
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@ResolveLocale
@Get('/home')
home(ctx: RequestContext) {
  const locale = ctx.require('locale') // { language: string; region: string | null }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What you get over hand-written middleware:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Concern &lt;/th&gt;&lt;th&gt;Middleware &lt;/th&gt;&lt;th&gt;Context decorator&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Type of the value &lt;/td&gt;&lt;td&gt;&lt;code&gt;(req as any).locale&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;ctx.require('locale')&lt;/code&gt;, typed from &lt;code&gt;ContextMeta&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Ordering &lt;/td&gt;&lt;td&gt;Array position in &lt;code&gt;bootstrap()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;dependsOn: ['user']&lt;/code&gt;, sorted at boot; cycles and missing producers &lt;strong&gt;fail startup&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Services &lt;/td&gt;&lt;td&gt;&lt;code&gt;Container.getInstance()&lt;/code&gt; inside the body &lt;/td&gt;&lt;td&gt;&lt;code&gt;deps: { repo: REPO_TOKEN }&lt;/code&gt;, resolved and typed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Per-route opt-out &lt;/td&gt;&lt;td&gt;Fork the stack or check paths &lt;/td&gt;&lt;td&gt;&lt;code&gt;skipWhen&lt;/code&gt; / &lt;code&gt;onlyWhen&lt;/code&gt; with route flags&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Errors &lt;/td&gt;&lt;td&gt;Throw → 500 &lt;/td&gt;&lt;td&gt;&lt;code&gt;optional: true&lt;/code&gt;, &lt;code&gt;onError&lt;/code&gt; fallback, or &lt;code&gt;HttpException&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Testing &lt;/td&gt;&lt;td&gt;Whole HTTP stack &lt;/td&gt;&lt;td&gt;&lt;code&gt;runContributor()&lt;/code&gt; against a fake context&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Registration sites, highest precedence first: &lt;strong&gt;method &amp;gt; class &amp;gt; module &lt;code&gt;contributors()&lt;/code&gt; &amp;gt; adapter/plugin &lt;code&gt;contributors()&lt;/code&gt; &amp;gt; &lt;code&gt;bootstrap({ contributors })&lt;/code&gt;&lt;/strong&gt;. A narrower registration of the same key silently replaces a broader one.&lt;/p&gt;
&lt;p&gt;Reading values:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ctx.require('key')&lt;/code&gt; — for values the route &lt;strong&gt;must&lt;/strong&gt; have. Throws &lt;code&gt;MissingContextValueError&lt;/code&gt; naming the key and route if the contributor didn't run. Never write &lt;code&gt;ctx.get('key')!&lt;/code&gt;: it compiles even after someone deletes the decorator.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ctx.get('key')&lt;/code&gt; — for optional extras. Returns &lt;code&gt;T | undefined&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getRequestValue('key')&lt;/code&gt; — from a &lt;strong&gt;service&lt;/strong&gt; with no &lt;code&gt;ctx&lt;/code&gt; in scope. Returns &lt;code&gt;undefined&lt;/code&gt; outside a request.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;Always &lt;strong&gt;return&lt;/strong&gt; the value from &lt;code&gt;resolve()&lt;/code&gt;. Assigning &lt;code&gt;ctx.locale = …&lt;/code&gt; writes to one internal context object that the handler never sees.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-the-asset-manager-typed-paths-to-your-files&quot;&gt;3. The asset manager: typed paths to your files&lt;/h3&gt;
&lt;p&gt;Templates, report layouts, JSON schemas and fixtures live next to your code in development and get copied somewhere else in the build. Without help, every call site does this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const path =
  process.env.NODE_ENV === 'production'
    ? join(__dirname, '../templates/mails/welcome.ejs')
    : join(__dirname, '../../src/templates/mails/welcome.ejs')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Typos compile, every call site branches on the environment, and &lt;code&gt;__dirname&lt;/code&gt; breaks under ESM and bundling.&lt;/p&gt;
&lt;h4 id=&quot;configure&quot;&gt;Configure&lt;/h4&gt;
&lt;p&gt;Tell the CLI which directories hold assets, in &lt;code&gt;kick.config.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { defineConfig } from '@forinda/kickjs-cli'

export default defineConfig({
  assetMap: {
    mails: { src: 'src/templates/mails', glob: '**/*.ejs' },
    reports: { src: 'src/templates/reports', glob: '**/*.{ejs,html}' },
    schemas: { src: 'src/schemas', glob: '**/*.json' },
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each entry takes &lt;code&gt;src&lt;/code&gt; (required), &lt;code&gt;dest&lt;/code&gt; (default &lt;code&gt;dist/&amp;lt;name&amp;gt;/&lt;/code&gt;), &lt;code&gt;glob&lt;/code&gt; (default &lt;code&gt;**/*&lt;/code&gt;) and &lt;code&gt;keys&lt;/code&gt; (&lt;code&gt;'auto'&lt;/code&gt;, &lt;code&gt;'strip'&lt;/code&gt; or &lt;code&gt;'with-extension'&lt;/code&gt; — how file names become property names).&lt;/p&gt;
&lt;h4 id=&quot;generate-types&quot;&gt;Generate types&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kick typegen   # kick dev runs this automatically on every change
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a file tree like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;src/templates/mails/en/welcome.ejs
src/templates/mails/fr/welcome.ejs
src/templates/reports/monthly.ejs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;you get a typed surface:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { assets } from '@forinda/kickjs'

assets.mails.en.welcome() // absolute path to the right file, dev or build
assets.reports.monthly()
assets.mails.en.welcom() // compile error
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;four-ways-to-read-an-asset&quot;&gt;Four ways to read an asset&lt;/h4&gt;
&lt;p&gt;All four go through the same cached resolver:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { assets, useAssets, Asset, resolveAsset } from '@forinda/kickjs'

assets.reports.monthly() // 1. ambient proxy — the default

class ReportService {
  private assets = useAssets() // 2. as a field — easy to mock in tests
}

@Service()
class InvoiceService {
  @Asset('reports/invoice') // 3. declarative field, resolved on each access
  private invoiceTemplate!: string
}

resolveAsset('mails', 'fr/welcome') // 4. dynamic — names known only at runtime; throws UnknownAssetError on a miss
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;in-the-build&quot;&gt;In the build&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;kick build&lt;/code&gt; copies every &lt;code&gt;assetMap&lt;/code&gt; entry into the output directory, writes a manifest (&lt;code&gt;.kickjs-assets.json&lt;/code&gt;) and runs typegen. At runtime the resolver uses that manifest when it exists, and walks your &lt;code&gt;src&lt;/code&gt; directories when it doesn't — so the same &lt;code&gt;assets.mails.en.welcome()&lt;/code&gt; call works in &lt;code&gt;kick dev&lt;/code&gt; and in production. After adding a template in dev, &lt;code&gt;kick build:assets&lt;/code&gt; refreshes the manifest without a full rebuild.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-how-they-fit-together&quot;&gt;4. How they fit together&lt;/h3&gt;
&lt;p&gt;The three features form a natural pipeline for any request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;route matched
   │
   ├─ route flags resolved (method &amp;gt; class &amp;gt; mount)          &quot;what is true about this route&quot;
   │
   ├─ contributors run, in dependsOn order                   &quot;what we know about this request&quot;
   │     • skipWhen / onlyWhen read the flags
   │     • resolve() can read ctx.route.flags values
   │     • guards read flags via exemptWhen
   │
   └─ handler / services
         • ctx.require(...) / getRequestValue(...)
         • assets.* picks the file, often chosen by a contributor value
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The division of labour that keeps codebases clean:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Flags describe&lt;/strong&gt; — &quot;public&quot;, &quot;needs the reports feature&quot;, &quot;limit 10 rpm&quot;. Declarative, inheritable, visible in the route list.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contributors decide&lt;/strong&gt; — load the user, load the plan, check the feature, compute the locale. Typed, ordered, testable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Assets deliver&lt;/strong&gt; — the template or schema the decision points at, with a path that is always right.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The recipes below build that pipeline piece by piece.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-recipe-one-public-flag-every-consumer&quot;&gt;5. Recipe: one &quot;public&quot; flag, every consumer&lt;/h3&gt;
&lt;p&gt;Declare the fact once:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/flags.ts
export const Public = defineRouteFlag('auth.public')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put it on routes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Public
@Controller()
export class AuthController {
  @Post('/login') login(ctx: RequestContext) {}
  @Post('/register') register(ctx: RequestContext) {}

  @Public.off // the only private route here
  @Get('/me')
  me(ctx: RequestContext) {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now &lt;strong&gt;every&lt;/strong&gt; consumer reads that one declaration instead of its own path list:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Auth: the user contributor steps aside on public routes (section 6).
skipWhen: 'auth.public'

// CSRF and rate limiting, as route guards:
@Middleware(csrfGuard({ exemptWhen: 'auth.public' }))
@Middleware(rateLimitGuard({ max: 60, exemptWhen: 'auth.public' }))

// OpenAPI: public routes show no lock icon.
SwaggerAdapter({ bearerAuth: true, publicFlag: 'auth.public' })

// The built-in health probes, which you don't own:
bootstrap({ health: { flags: ['auth.public'] } })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rename a route, change the API prefix, add &lt;code&gt;/users/:id&lt;/code&gt; — nothing breaks, because nothing matches on path strings. And &lt;strong&gt;DevTools&lt;/strong&gt; shows each route's resolved flags in its Routes tab, so &quot;why is this endpoint open?&quot; is answered from the route list.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The framework names no flags. &lt;code&gt;auth.public&lt;/code&gt; is a name you chose — pick a namespace per concern (&lt;code&gt;auth.*&lt;/code&gt;, &lt;code&gt;billing.*&lt;/code&gt;, &lt;code&gt;rate.*&lt;/code&gt;) so readers can tell who consumes a flag.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-recipe-the-signed-in-user-skipped-on-public-routes&quot;&gt;6. Recipe: the signed-in user, skipped on public routes&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/load-user.contributor.ts
import { defineHttpContextDecorator, HttpException } from '@forinda/kickjs'
import { SESSIONS } from '@/auth/sessions'

declare module '@forinda/kickjs' {
  interface ContextMeta {
    user: { id: string; accountId: string; roles: string[] }
  }
}

export const LoadUser = defineHttpContextDecorator({
  key: 'user',
  skipWhen: 'auth.public', // the flag from section 5
  beforeValidation: true, // answer 401 before 422
  deps: { sessions: SESSIONS },
  resolve: async (ctx, { sessions }) =&amp;gt; {
    const user = await sessions.fromToken(ctx.headers.authorization)
    if (!user) throw HttpException.unauthorized()
    return user
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register it &lt;strong&gt;once&lt;/strong&gt;, app-wide:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({ modules, contributors: [LoadUser.registration] })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every route now requires a user, except routes flagged public. Three details matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;skipWhen&lt;/code&gt; makes exemption work across ownership.&lt;/strong&gt; Without flags, the only way to opt a route out of a contributor is to register a permissive twin under the same key — which you can't do for a contributor a plugin shipped. A flag lives on the route, so you can exempt anything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;beforeValidation: true&lt;/code&gt;&lt;/strong&gt; moves the contributor ahead of body validation. Otherwise an anonymous request with a bad body gets a &lt;code&gt;422&lt;/code&gt; listing your schema. The order becomes &lt;code&gt;beforeValidation contributors → validation → @Middleware() → other contributors → handler&lt;/code&gt;. Read headers and cookies here, never the body — it isn't validated yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A skipped contributor sets no key.&lt;/strong&gt; On routes that can be public, read &lt;code&gt;ctx.get('user')&lt;/code&gt;; use &lt;code&gt;ctx.require('user')&lt;/code&gt; only where the route is guaranteed private.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Services that need the user don't take it as a parameter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Service()
export class AuditService {
  record(action: string) {
    const user = getRequestValue('user') // typed, undefined on public routes
    this.log.insert({ action, userId: user?.id ?? null })
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-recipe-plan-gated-routes-with-a-flag-value-and-dependson&quot;&gt;7. Recipe: plan-gated routes with a flag value and dependsOn&lt;/h3&gt;
&lt;p&gt;Goal: some routes require a paid feature. The route &lt;strong&gt;states&lt;/strong&gt; which feature; contributors &lt;strong&gt;enforce&lt;/strong&gt; it.&lt;/p&gt;
&lt;p&gt;The flag carries a value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const Feature = defineRouteFlag&amp;lt;string&amp;gt;('billing.feature')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Load the account's plan after the user:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/load-plan.contributor.ts
declare module '@forinda/kickjs' {
  interface ContextMeta {
    plan: { name: 'free' | 'pro' | 'enterprise'; features: string[] }
  }
}

export const LoadPlan = defineHttpContextDecorator({
  key: 'plan',
  dependsOn: ['user'], // runs after LoadUser; boot fails if nothing produces 'user'
  skipWhen: 'auth.public',
  deps: { billing: BILLING },
  resolve: async (ctx, { billing }) =&amp;gt; billing.planFor(ctx.require('user').accountId),
  onError: () =&amp;gt; ({ name: 'free', features: [] }), // billing down → treat as free, don't 500
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enforce the feature &lt;strong&gt;only where a route asks for one&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/require-feature.contributor.ts
declare module '@forinda/kickjs' {
  interface ContextMeta {
    feature: string
  }
}

export const RequireFeature = defineHttpContextDecorator({
  key: 'feature',
  onlyWhen: 'billing.feature', // routes without the flag skip this entirely
  dependsOn: ['plan'],
  resolve: (ctx) =&amp;gt; {
    const needed = ctx.route?.flags.get('billing.feature') // the flag's value, e.g. 'reports'
    const plan = ctx.require('plan')
    if (!needed || !plan.features.includes(needed)) {
      ctx.problem.forbidden({
        type: 'https://example.com/probs/upgrade-required',
        detail: `This needs the ${needed} feature, which is not on the ${plan.name} plan`,
      })
    }
    return needed
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the chain app-wide:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({
  modules,
  contributors: [LoadUser.registration, LoadPlan.registration, RequireFeature.registration],
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now gating a route is one line, and it reads like documentation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Controller()
export class ReportsController {
  @Feature('reports')
  @Get('/monthly')
  monthly(ctx: RequestContext) {} // pro and above

  @Get('/summary')
  summary(ctx: RequestContext) {} // everyone signed in
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why this shape works:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Order is declared, not arranged.&lt;/strong&gt; &lt;code&gt;dependsOn&lt;/code&gt; gives &lt;code&gt;user → plan → feature&lt;/code&gt;. Forget &lt;code&gt;LoadUser&lt;/code&gt; and the app refuses to start with &lt;code&gt;MissingContributorError&lt;/code&gt; instead of failing on the first request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost is paid only where needed.&lt;/strong&gt; &lt;code&gt;onlyWhen&lt;/code&gt; means routes without the flag never evaluate the check; a skipped contributor costs a map lookup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Errors keep their shape.&lt;/strong&gt; &lt;code&gt;ctx.problem.forbidden(...)&lt;/code&gt; answers RFC 9457 &lt;code&gt;application/problem+json&lt;/code&gt; from inside a contributor, so clients can branch on &lt;code&gt;type&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The fallback is explicit.&lt;/strong&gt; &lt;code&gt;onError&lt;/code&gt; returns a value; the runner stores it under the key.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-recipe-per-route-rate-limits-from-one-declaration&quot;&gt;8. Recipe: per-route rate limits from one declaration&lt;/h3&gt;
&lt;p&gt;A flag with a value, read by several consumers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const RateLimit = defineRouteFlag&amp;lt;{ rpm: number }&amp;gt;('rate.limit')

@RateLimit({ rpm: 10 })
@Post('/login')
login(ctx: RequestContext) {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Inside the route&lt;/strong&gt; — a guard can exempt unmetered routes with a predicate that reads the value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Middleware(
  rateLimitGuard({
    max: 60,
    exemptWhen: ({ flags }) =&amp;gt; flags.get('rate.limit')?.rpm === 0,
  }),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;A contributor&lt;/strong&gt; can derive the limiter key and window from the same flag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;declare module '@forinda/kickjs' {
  interface ContextMeta {
    rateLimit: { key: string; rpm: number }
  }
}

export const RateLimitKey = defineHttpContextDecorator({
  key: 'rateLimit',
  onlyWhen: 'rate.limit',
  resolve: (ctx) =&amp;gt; ({
    key: `${ctx.get('user')?.id ?? ctx.req.ip}:${ctx.route?.path}`,
    rpm: ctx.route?.flags.get('rate.limit')?.rpm ?? 60,
  }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Before routing&lt;/strong&gt; — app-wide middleware has no &lt;code&gt;ctx.route&lt;/code&gt;, so it reads a policy table the app builds at boot from every mounted route's method, path and flags:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({ middlewares: [rateLimit({ max: 60, exemptWhen: 'auth.public' })] })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your own pre-routing middleware can read the same table:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { bindRoutePolicy, type RoutePolicyTable } from '@forinda/kickjs'

export function accessLog() {
  let policy: RoutePolicyTable | undefined
  const handler = (req, _res, next) =&amp;gt; {
    const flags = policy?.lookup(req.method, req.url ?? '/')
    if (!flags?.has('audit.skip')) log.info({ method: req.method, url: req.url })
    next()
  }
  return bindRoutePolicy(handler, (table) =&amp;gt; {
    policy = table
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A request that matches no route matches no flags, and stays limited and logged — which is exactly what you want for scanners probing random paths.&lt;/p&gt;
&lt;h4 id=&quot;the-three-shapes-a-flag-test-can-take&quot;&gt;The three shapes a flag test can take&lt;/h4&gt;
&lt;p&gt;Every &lt;code&gt;skipWhen&lt;/code&gt;, &lt;code&gt;onlyWhen&lt;/code&gt; and &lt;code&gt;exemptWhen&lt;/code&gt; accepts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;'auth.public' // carries this flag
'!auth.public' // does NOT carry it
['auth.public', 'health.probe'] // carries ANY of these
['!auth.public', '!health.probe'] // carries NONE of these
({ flags, route }) =&amp;gt; flags.has('a') &amp;amp;&amp;amp; flags.has('b') // anything else
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A list is single-polarity: &lt;code&gt;['auth.public', '!metered']&lt;/code&gt; is a compile error, because &quot;any-of&quot; would read it as &quot;public &lt;strong&gt;or&lt;/strong&gt; not metered&quot; while most people read &quot;and&quot;. Use a predicate for mixed conditions, and keep predicates cheap — they run per request.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-recipe-flag-or-parameter-choosing-the-right-tool&quot;&gt;9. Recipe: flag or parameter? Choosing the right tool&lt;/h3&gt;
&lt;p&gt;Contributors can also take &lt;strong&gt;per-call parameters&lt;/strong&gt;, which overlaps with flags. The rule of thumb:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use a &lt;strong&gt;flag&lt;/strong&gt; when the fact is read by &lt;strong&gt;several consumers&lt;/strong&gt;, or by tooling (OpenAPI, DevTools), or must be set on routes you don't own.&lt;/li&gt;
&lt;li&gt;Use a &lt;strong&gt;parameter&lt;/strong&gt; when it only configures &lt;strong&gt;one contributor's behaviour&lt;/strong&gt; at that call site.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A permission check is a good parameter — only one contributor cares, and the action must be named at every use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;declare module '@forinda/kickjs' {
  interface ContextMeta {
    permission: string
  }
}

export const RequirePermission = defineHttpContextDecorator.withParams&amp;lt;{ action: string }&amp;gt;()({
  key: 'permission',
  dependsOn: ['user'],
  // no paramDefaults.action — every route must name its own action
  resolve: (ctx, _deps, { action }) =&amp;gt; {
    if (!ctx.require('user').roles.includes(action)) throw HttpException.forbidden()
    return action
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@RequirePermission({ action: 'reports:export' }) // ✓
@Get('/export')
export(ctx: RequestContext) {}

@RequirePermission // ✗ compile error: `action` is required
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Required parameters have no default on purpose: a placeholder default that one route forgets to override silently gates on the placeholder. For module, adapter or bootstrap registration, use &lt;code&gt;RequirePermission.with({ action: '…' }).registration&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Compare with &lt;code&gt;billing.feature&lt;/code&gt; from section 7: that one is a &lt;strong&gt;flag&lt;/strong&gt; because the billing contributor, the OpenAPI spec (to document &quot;requires Pro&quot;), and DevTools all benefit from reading it.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Need &lt;/th&gt;&lt;th&gt;Reach for&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;This route is public / metered / exempt&quot; &lt;/td&gt;&lt;td&gt;Flag&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Requires feature X&quot; (and docs should say so) &lt;/td&gt;&lt;td&gt;Flag with a value&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Check permission &lt;code&gt;reports:export&lt;/code&gt;&quot; &lt;/td&gt;&lt;td&gt;Parameterised contributor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Load the tenant from a header here, a subdomain there&quot; &lt;/td&gt;&lt;td&gt;Parameterised contributor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Mark routes on a controller from another package &lt;/td&gt;&lt;td&gt;Flag on the module mount&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;10-recipe-locale-aware-emails-from-typed-templates&quot;&gt;10. Recipe: locale-aware emails from typed templates&lt;/h3&gt;
&lt;p&gt;Now bring in assets. Keep templates per language:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;src/templates/mails/en/welcome.ejs
src/templates/mails/en/password-reset.ejs
src/templates/mails/fr/welcome.ejs
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// kick.config.ts
assetMap: {
  mails: { src: 'src/templates/mails', glob: '**/*.ejs' },
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After &lt;code&gt;kick typegen&lt;/code&gt;, &lt;code&gt;assets.mails.en.welcome()&lt;/code&gt; and &lt;code&gt;assets.mails['en']['password-reset']()&lt;/code&gt; are typed.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;locale contributor&lt;/strong&gt; (section 2) decides the language per request. The &lt;strong&gt;mail service&lt;/strong&gt; picks the matching template, falling back to English when a translation is missing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/mail/mail.service.ts
import { Service, assets, getRequestValue, resolveAsset, UnknownAssetError } from '@forinda/kickjs'
import ejs from 'ejs'

type MailName = 'welcome' | 'password-reset'

@Service()
export class MailService {
  async render(name: MailName, data: Record&amp;lt;string, unknown&amp;gt;) {
    const language = getRequestValue('locale')?.language ?? 'en'
    return ejs.renderFile(this.templateFor(name, language), data)
  }

  private templateFor(name: MailName, language: string): string {
    try {
      return resolveAsset('mails', `${language}/${name}`) // dynamic: language is runtime data
    } catch (error) {
      if (error instanceof UnknownAssetError) return assets.mails.en[name]() // typed fallback
      throw error
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Look at who does what:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The contributor&lt;/strong&gt; turns a header into a typed &lt;code&gt;locale&lt;/code&gt; once per request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The service&lt;/strong&gt; reads it with &lt;code&gt;getRequestValue&lt;/code&gt; — no &lt;code&gt;ctx&lt;/code&gt; threaded through three layers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The asset manager&lt;/strong&gt; turns &lt;code&gt;fr/welcome&lt;/code&gt; into a real path in dev and in the build, and the typed &lt;code&gt;assets.mails.en[name]()&lt;/code&gt; fallback means English templates are checked at compile time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Outside a request (a queue worker, a cron job) &lt;code&gt;getRequestValue&lt;/code&gt; returns &lt;code&gt;undefined&lt;/code&gt;, and the service falls back to English instead of throwing.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;11-recipe-reports-rendered-per-route&quot;&gt;11. Recipe: reports rendered per route&lt;/h3&gt;
&lt;p&gt;Report templates are a good fit for the &lt;code&gt;@Asset&lt;/code&gt; field decorator — fixed files, injected into a service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// kick.config.ts
assetMap: {
  reports: { src: 'src/templates/reports', glob: '**/*.ejs' },
},
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Service()
export class ReportService {
  @Asset('reports/monthly')
  private monthlyTemplate!: string

  @Asset('reports/invoice')
  private invoiceTemplate!: string

  monthly(data: MonthlyReport) {
    return ejs.renderFile(this.monthlyTemplate, data)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Combined with section 7, the controller carries all the policy as declarations:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Controller()
export class ReportsController {
  @Autowired() private readonly reports!: ReportService

  @Feature('reports') // flag: needs the reports feature
  @RateLimit({ rpm: 5 }) // flag: expensive, limit it
  @ResolveLocale // contributor: formats numbers and dates for the reader
  @Get('/monthly')
  async monthly(ctx: RequestContext) {
    const html = await this.reports.monthly({
      user: ctx.require('user'),
      plan: ctx.require('plan'),
      locale: ctx.require('locale'),
    })
    return ctx.html(html)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reading the decorators top to bottom tells you everything about the endpoint without opening a single middleware file: who can call it, how often, and what context it renders with.&lt;/p&gt;
&lt;p&gt;JSON schemas and fixtures work the same way — &lt;code&gt;assets.schemas.webhook()&lt;/code&gt; gives a validator the right file in every environment.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;12-recipe-flagging-controllers-you-don-t-own&quot;&gt;12. Recipe: flagging controllers you don't own&lt;/h3&gt;
&lt;p&gt;Decorators only work on code you can edit. A module can flag routes on &lt;strong&gt;any&lt;/strong&gt; controller it mounts — including one from a package:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/modules/webhooks/webhooks.module.ts
import { defineModule } from '@forinda/kickjs'
import { StripeWebhookController } from 'some-payments-package'

export const WebhooksModule = defineModule({
  name: 'WebhooksModule',
  build: () =&amp;gt; ({
    routes: () =&amp;gt; ({
      path: '/webhooks',
      controller: StripeWebhookController,
      flags: { 'auth.public': true, 'rate.limit': { rpm: 600 } },
    }),
  }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mount flags are the lowest precedence, so a class or method declaration of the same flag still wins. A list form (&lt;code&gt;flags: ['auth.public']&lt;/code&gt;) stores each flag as &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The same idea covers the framework's own health probes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({ health: { flags: ['auth.public'] } })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every consumer already reading &lt;code&gt;auth.public&lt;/code&gt; — &lt;code&gt;LoadUser&lt;/code&gt;, the guards, the OpenAPI spec — now exempts &lt;code&gt;/health/live&lt;/code&gt; and &lt;code&gt;/health/ready&lt;/code&gt;, with no path strings.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;13-type-safety-what-the-compiler-catches&quot;&gt;13. Type safety: what the compiler catches&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;kick typegen&lt;/code&gt; (automatic in &lt;code&gt;kick dev&lt;/code&gt;) generates three registries into &lt;code&gt;.kickjs/types/&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;You write &lt;/th&gt;&lt;th&gt;Typegen generates &lt;/th&gt;&lt;th&gt;The compiler then catches&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;defineRouteFlag('rate.limit')&lt;/code&gt; with a value type &lt;/td&gt;&lt;td&gt;&lt;code&gt;KickRouteFlags&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;skipWhen: 'auth.pubic'&lt;/code&gt; → &lt;em&gt;Did you mean 'auth.public'?&lt;/em&gt;; &lt;code&gt;flags.get('rate.limit')?.rpm&lt;/code&gt; typed, not &lt;code&gt;unknown&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;declare module … ContextMeta&lt;/code&gt; &lt;/td&gt;&lt;td&gt;— (you write it) &lt;/td&gt;&lt;td&gt;&lt;code&gt;ctx.require('locale')&lt;/code&gt; typed; &lt;code&gt;dependsOn: ['usr']&lt;/code&gt; is an error&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;assetMap&lt;/code&gt; in &lt;code&gt;kick.config.ts&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;KickAssets&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;assets.mails.en.welcom()&lt;/code&gt; is an error&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And one more that is easy to miss: &lt;strong&gt;a deleted decorator becomes a compile error.&lt;/strong&gt; When a handler is typed with a generated route type, typegen narrows &lt;code&gt;ctx.require()&lt;/code&gt; to the keys that route provably has:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@LoadTenant
@RequirePermission({ action: 'audit:read' }) // ← delete this line…
@Get('/audit')
audit(ctx: Ctx&amp;lt;KickRoutes.AuditController['audit']&amp;gt;) {
  return { perm: ctx.require('permission') } // …and this no longer compiles
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Typegen narrows only when it can prove the whole contributor set. Adapter or plugin &lt;code&gt;contributors()&lt;/code&gt; can't be read statically, so projects using them skip the narrowing — &lt;code&gt;ctx.require()&lt;/code&gt; still throws at runtime, which is why integration tests stay worth having.&lt;/p&gt;
&lt;p&gt;All three registries are opt-in: until you declare your first flag, &lt;code&gt;ContextMeta&lt;/code&gt; key or &lt;code&gt;assetMap&lt;/code&gt; entry, everything accepts plain strings and keeps compiling.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;14-testing-all-three&quot;&gt;14. Testing all three&lt;/h3&gt;
&lt;h4 id=&quot;contributors-in-isolation&quot;&gt;Contributors, in isolation&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;runContributor&lt;/code&gt; from &lt;code&gt;@forinda/kickjs-testing&lt;/code&gt; calls a contributor's &lt;code&gt;resolve()&lt;/code&gt; against a fake context — no container, no HTTP. Hand it mocked &lt;code&gt;deps&lt;/code&gt; and pre-seed upstream keys with &lt;code&gt;initial&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { describe, expect, it, vi } from 'vitest'
import { runContributor } from '@forinda/kickjs-testing'
import { LoadPlan } from './load-plan.contributor'

describe('LoadPlan', () =&amp;gt; {
  it('loads the plan for the signed-in account', async () =&amp;gt; {
    const billing = { planFor: vi.fn(async () =&amp;gt; ({ name: 'pro', features: ['reports'] })) }

    const { value } = await runContributor(LoadPlan, {
      deps: { billing },
      initial: { user: { id: 'u1', accountId: 'acc-9', roles: [] } }, // what LoadUser would have set
    })

    expect(billing.planFor).toHaveBeenCalledWith('acc-9')
    expect(value).toEqual({ name: 'pro', features: ['reports'] })
  })
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;runContributor&lt;/code&gt; calls &lt;code&gt;resolve()&lt;/code&gt; directly, so &lt;code&gt;optional&lt;/code&gt; and &lt;code&gt;onError&lt;/code&gt; paths need the pipeline: build one with &lt;code&gt;buildPipeline([...])&lt;/code&gt; and run it with &lt;code&gt;runContributors(...)&lt;/code&gt; from &lt;code&gt;@forinda/kickjs&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;flags-without-a-request&quot;&gt;Flags, without a request&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;getRouteFlags(controllerClass, handlerName)&lt;/code&gt; returns the same resolved flags a live request sees — method over class over mount:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { getRouteFlags } from '@forinda/kickjs'

it('keeps /me private while the rest of AuthController is public', () =&amp;gt; {
  expect(getRouteFlags(AuthController, 'login').has('auth.public')).toBe(true)
  expect(getRouteFlags(AuthController, 'me').has('auth.public')).toBe(false)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A test like this is cheap insurance on the one flag whose removal is a security bug.&lt;/p&gt;
&lt;h4 id=&quot;the-whole-pipeline&quot;&gt;The whole pipeline&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;createTestApp&lt;/code&gt; from &lt;code&gt;@forinda/kickjs-testing&lt;/code&gt; boots the app with real contributors, flags and ordering; drive it with Supertest and assert the outcomes: &lt;code&gt;401&lt;/code&gt; without a token, &lt;code&gt;200&lt;/code&gt; on a public route, &lt;code&gt;403&lt;/code&gt; with the upgrade problem type on a gated route for a free plan. &lt;code&gt;createTestApp&lt;/code&gt; also sets up the request scope, so services calling &lt;code&gt;getRequestValue&lt;/code&gt; work in these tests.&lt;/p&gt;
&lt;h4 id=&quot;assets-with-fixtures&quot;&gt;Assets, with fixtures&lt;/h4&gt;
&lt;p&gt;Point the resolver at a fixture manifest and clear its cache:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { join } from 'node:path'
import { clearAssetCache } from '@forinda/kickjs'

beforeEach(() =&amp;gt; {
  process.env.KICK_ASSETS_ROOT = join(__dirname, 'fixtures/assets') // contains .kickjs-assets.json
  clearAssetCache()
})

afterEach(() =&amp;gt; {
  delete process.env.KICK_ASSETS_ROOT
  clearAssetCache()
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Asset&lt;/code&gt; fields resolve on every access, so existing service instances pick up the fixtures without being recreated.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;15-pitfalls&quot;&gt;15. Pitfalls&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Symptom &lt;/th&gt;&lt;th&gt;Cause &lt;/th&gt;&lt;th&gt;Fix&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;A &quot;private&quot; route is treated as public &lt;/td&gt;&lt;td&gt;&lt;code&gt;@Public(false)&lt;/code&gt; style thinking &lt;/td&gt;&lt;td&gt;Remove with &lt;code&gt;@Public.off&lt;/code&gt;; flags have no false state&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@Public.off&lt;/code&gt; does nothing &lt;/td&gt;&lt;td&gt;The route never inherited the flag &lt;/td&gt;&lt;td&gt;Check where you expected it to come from&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Handler sees &lt;code&gt;undefined&lt;/code&gt; for a contributor value &lt;/td&gt;&lt;td&gt;Assigned &lt;code&gt;ctx.x = …&lt;/code&gt; in &lt;code&gt;resolve&lt;/code&gt;/&lt;code&gt;onError&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Return&lt;/strong&gt; the value; the runner stores it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ctx.require('user')&lt;/code&gt; throws on some routes &lt;/td&gt;&lt;td&gt;The contributor was skipped by &lt;code&gt;skipWhen&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Use &lt;code&gt;ctx.get('user')&lt;/code&gt; on routes that can be public&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Anonymous callers see validation errors &lt;/td&gt;&lt;td&gt;Auth contributor runs after validation &lt;/td&gt;&lt;td&gt;&lt;code&gt;beforeValidation: true&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MissingContributorError&lt;/code&gt; at boot &lt;/td&gt;&lt;td&gt;A &lt;code&gt;dependsOn&lt;/code&gt; key has no producer for that route &lt;/td&gt;&lt;td&gt;Register the producer at the same or broader site&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;beforeValidation&lt;/code&gt; contributor fails boot &lt;/td&gt;&lt;td&gt;It depends on a normal contributor &lt;/td&gt;&lt;td&gt;Early contributors may only depend on early ones&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Flag test typo matches nothing &lt;/td&gt;&lt;td&gt;Flags not declared yet &lt;/td&gt;&lt;td&gt;Declare with &lt;code&gt;defineRouteFlag&lt;/code&gt;; typegen narrows every name&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;['a', '!b']&lt;/code&gt; won't compile &lt;/td&gt;&lt;td&gt;Lists are single-polarity &lt;/td&gt;&lt;td&gt;Use a predicate&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Asset works in dev, missing in production &lt;/td&gt;&lt;td&gt;File not matched by &lt;code&gt;glob&lt;/code&gt;, or custom &lt;code&gt;build.outDir&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Widen the glob; set &lt;code&gt;build.outDir&lt;/code&gt; in &lt;code&gt;kick.config.ts&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Two files, one asset key &lt;/td&gt;&lt;td&gt;Same basename (&lt;code&gt;index.html&lt;/code&gt; + &lt;code&gt;index.pug&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;Default &lt;code&gt;keys: 'auto'&lt;/code&gt; keeps extensions for that group&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;New template not found in dev &lt;/td&gt;&lt;td&gt;Manifest is stale &lt;/td&gt;&lt;td&gt;&lt;code&gt;kick build:assets&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;welcome-email.ejs&lt;/code&gt; needs bracket access &lt;/td&gt;&lt;td&gt;Non-identifier file name &lt;/td&gt;&lt;td&gt;&lt;code&gt;assets.mails['welcome-email']()&lt;/code&gt;, or rename the file&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-cli-commands-used&quot;&gt;The CLI commands used&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Command &lt;/th&gt;&lt;th&gt;What it does here&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick g contributor &amp;lt;name&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Scaffolds a context decorator with its &lt;code&gt;ContextMeta&lt;/code&gt; stub (&lt;code&gt;--type bare&lt;/code&gt; for transport-agnostic, &lt;code&gt;--params &quot;action:string&quot;&lt;/code&gt; for the parameterised form, &lt;code&gt;-m &amp;lt;module&amp;gt;&lt;/code&gt; to place it in a module).&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick typegen&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Generates &lt;code&gt;KickRouteFlags&lt;/code&gt;, route types for &lt;code&gt;ctx.require()&lt;/code&gt; narrowing, and &lt;code&gt;KickAssets&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick dev&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Runs the app with hot reload and typegen on every change.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick build&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Builds, copies &lt;code&gt;assetMap&lt;/code&gt; files, writes the asset manifest and runs typegen.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick build:assets&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Refreshes the asset manifest without a JS rebuild.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--dry-run&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Previews any generator's files first.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;recap&quot;&gt;Recap&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Route flags&lt;/strong&gt; are facts on routes: declared once, inherited method &amp;gt; class &amp;gt; mount, removed with &lt;code&gt;.off&lt;/code&gt;, and read by contributors, guards, pre-routing middleware, OpenAPI and DevTools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context decorators&lt;/strong&gt; are typed per-request values: ordered with &lt;code&gt;dependsOn&lt;/code&gt;, wired to services with &lt;code&gt;deps&lt;/code&gt;, skipped per route with &lt;code&gt;skipWhen&lt;/code&gt; / &lt;code&gt;onlyWhen&lt;/code&gt;, run early with &lt;code&gt;beforeValidation&lt;/code&gt;, parameterised with &lt;code&gt;.withParams&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The asset manager&lt;/strong&gt; gives typed paths to shipped files: &lt;code&gt;assetMap&lt;/code&gt; in config, &lt;code&gt;assets.*&lt;/code&gt; in code, correct in dev and in the build.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Together:&lt;/strong&gt; flags describe the route, contributors decide about the request, assets deliver the file the decision points at — and typegen checks all three.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;links&quot;&gt;Links&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;KickJS on GitHub:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/forinda/kick-js&quot;&gt;github.com/forinda/kick-js&lt;/a&gt; — source, issues and discussions. A ⭐ helps others find it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Guides:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/route-flags.html&quot;&gt;Route flags&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/context-decorators.html&quot;&gt;Context decorators&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/asset-manager.html&quot;&gt;Asset manager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/&quot;&gt;kickjs.app&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28152/route-flags-context-decorators-and-typed-assets-in-kickjs-declare-it-once-use-everywhere</guid>
<pubDate>Tue, 22 Sep 2026 11:40:21 +0000</pubDate>
</item>
<item>
<title>I migrated a 593-route Express app to KickJS twice — here's what held up</title>
<link>https://coderlegion.com/28151/i-migrated-a-593-route-express-app-to-kickjs-twice-heres-what-held-up</link>
<description>&lt;p&gt;I spent a while moving a large multi-tenant SaaS platform off Express 4 +&lt;br&gt;
Sequelize. The numbers going in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;54 apps, ~593 routes&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;241 tables&lt;/strong&gt; across 434 migrations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;database-per-tenant&lt;/strong&gt; — one Postgres database per customer organisation&lt;/li&gt;
&lt;li&gt;one 3,046-line controller, and a route table with 26 endpoints that had never&lt;br&gt;
once executed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I did it twice. First as a strangler — KickJS mounted &lt;em&gt;inside&lt;/em&gt; the running&lt;br&gt;
Express app, serving &lt;code&gt;/api/v2&lt;/code&gt; beside the legacy &lt;code&gt;/api/v1&lt;/code&gt;, proven route by route&lt;br&gt;
against the original. Then, once that had taught me what the system actually&lt;br&gt;
does, as a clean rewrite on a fresh KickJS host with Drizzle.&lt;/p&gt;
&lt;p&gt;Both runs used the same framework, and that is the thing I want to write about.&lt;br&gt;
Most frameworks are good at one of those two jobs.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Versions throughout: &lt;code&gt;@forinda/kickjs@8.3.1&lt;/code&gt;, &lt;code&gt;@forinda/kickjs-cli@8.1.5&lt;/code&gt;,&lt;br&gt;
Express 5.1, zod 4, Drizzle 0.45. Code examples use a generic&lt;br&gt;
catalog/orders domain.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-feature-that-made-the-strangler-possible&quot;&gt;The feature that made the strangler possible&lt;/h3&gt;
&lt;p&gt;A KickJS module's &lt;code&gt;routes()&lt;/code&gt; can return a controller &lt;strong&gt;or&lt;/strong&gt; a raw Express router,&lt;br&gt;
and you can mount both under the same path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;routes() {
  return [
    { path: '/catalog', controller: CatalogController }, // the routes I'd converted
    { path: '/catalog', router: legacyCatalogRouter },   // the ones I hadn't
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Express takes the first match, so converted routes win and the legacy router&lt;br&gt;
serves the rest. &lt;strong&gt;No unconverted route registration has to be copied&lt;/strong&gt;, and a&lt;br&gt;
route moves across one at a time by adding it to the controller and deleting&lt;br&gt;
nothing.&lt;/p&gt;
&lt;p&gt;That is what let a 593-route app migrate incrementally instead of in a big bang.&lt;br&gt;
I haven't found another structured Node framework where &quot;here is an Express&lt;br&gt;
Router, serve it beside my controllers&quot; is a first-class mount.&lt;/p&gt;
&lt;p&gt;The inverse also worked, and is undocumented: &lt;code&gt;new Application()&lt;/code&gt; + &lt;code&gt;setup()&lt;/code&gt;&lt;br&gt;
gives you the pipeline without a listener, and &lt;code&gt;handle()&lt;/code&gt; takes raw Node&lt;br&gt;
&lt;code&gt;IncomingMessage&lt;/code&gt;/&lt;code&gt;ServerResponse&lt;/code&gt; — which is how a KickJS app ran &lt;em&gt;inside&lt;/em&gt; an&lt;br&gt;
Express 4 host despite the framework declaring an Express 5 peer.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;context-contributors-and-how-to-write-one&quot;&gt;Context contributors — and how to write one&lt;/h3&gt;
&lt;p&gt;This is the part I'd steal for any other project. A contributor computes one&lt;br&gt;
typed value per request and publishes it on the context under a key. Here is a&lt;br&gt;
complete one, in the three pieces it always has.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Declare the key and its type&lt;/strong&gt;, by augmenting the framework's registry.&lt;br&gt;
This is what makes &lt;code&gt;ctx.require('actor')&lt;/code&gt; typed everywhere downstream:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;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
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. Define the resolver.&lt;/strong&gt; &lt;code&gt;deps&lt;/code&gt; pulls services out of the DI container,&lt;br&gt;
&lt;code&gt;dependsOn&lt;/code&gt; declares which other contributors must run first, and &lt;code&gt;skipWhen&lt;/code&gt;&lt;br&gt;
names the route flags that switch it off entirely:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const Actor = defineHttpContextDecorator({
  key: 'actor',
  skipWhen: ['auth.public'],
  dependsOn: ['tenantDb'],
  deps: { repo: AUTH_REPOSITORY },
  async resolve(ctx, { repo }): Promise&amp;lt;Actor&amp;gt; {
    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)
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Throwing from &lt;code&gt;resolve&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the rejection — there's no separate guard step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Register it&lt;/strong&gt;, either globally in &lt;code&gt;bootstrap({ contributors })&lt;/code&gt;, on a module&lt;br&gt;
when every route in it shares the value, or per handler as a decorator:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;contributors() {
  return [Actor.registration, TenantDb.registration]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things earn their keep:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The dependency is declared, so it fails at boot.&lt;/strong&gt; The legacy app had two&lt;br&gt;
different producers of the same tenant-database key — one from an &lt;code&gt;x-tenant-id&lt;/code&gt;&lt;br&gt;
header, one from the bearer token. Declaring &lt;code&gt;dependsOn&lt;/code&gt; caught a real bug on the&lt;br&gt;
first run: two routes carried the token-based resolver with no authenticated&lt;br&gt;
user, which would have handed the service &lt;code&gt;undefined&lt;/code&gt; at runtime. Missing&lt;br&gt;
dependencies and cycles fail at startup, not per request.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Precedence is method &amp;gt; class &amp;gt; module &amp;gt; adapter &amp;gt; bootstrap.&lt;/strong&gt; Register once on&lt;br&gt;
the module when every route shares a value; override per handler when one&lt;br&gt;
doesn't. In the rewrite the whole tenant story is a chain the framework&lt;br&gt;
topo-sorts for you — order in the array is irrelevant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;tenantRoute → tenantDb → actor → permissions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compare with the Express version, which resolved the tenant in the auth&lt;br&gt;
middleware by &lt;em&gt;copying&lt;/em&gt; the tenant middleware's logic inline, then wrote the&lt;br&gt;
result to four differently-named request properties. Both are &quot;middleware&quot;. Only&lt;br&gt;
one of them tells you when you've wired it wrong.&lt;/p&gt;
&lt;h4 id=&quot;parameterised-decorators&quot;&gt;Parameterised decorators&lt;/h4&gt;
&lt;p&gt;The same primitive builds a decorator that takes arguments. &lt;code&gt;withParams&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;requiredParams&lt;/code&gt; are the whole trick:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;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&amp;lt;{
  action: ActionName
  module: ModuleName
}&amp;gt;()({
  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
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Used at the point it applies to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@RequirePermission({ action: 'create', module: 'orders' })
@Post('/')
create(ctx: Ctx&amp;lt;KickRoutes.OrderController['create']&amp;gt;) { … }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the &lt;code&gt;ContextMeta&lt;/code&gt; / &lt;code&gt;ContextKeys&lt;/code&gt; split: &lt;code&gt;ContextMeta&lt;/code&gt; when something&lt;br&gt;
downstream reads the value, &lt;code&gt;ContextKeys&lt;/code&gt; when the decorator exists only for its&lt;br&gt;
side effect. Both give you compile-time checking on &lt;code&gt;dependsOn&lt;/code&gt; strings.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;route-flags-and-inverting-the-default&quot;&gt;Route flags, and inverting the default&lt;/h3&gt;
&lt;p&gt;The single highest-value change in the rewrite was three lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;skipWhen: ['auth.public', 'tenant.controlPlane']
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Authentication now &lt;strong&gt;rejects by default&lt;/strong&gt;, and a route opts out explicitly. The&lt;br&gt;
legacy app worked the other way round: you added &lt;code&gt;preAuthorize()&lt;/code&gt; to each route&lt;br&gt;
that needed it.&lt;/p&gt;
&lt;p&gt;That difference is not theoretical. Porting turned up whole modules where someone&lt;br&gt;
had simply forgotten — every route open, nobody having decided they should be.&lt;br&gt;
Forgetting to add a guard is silent. Forgetting to add &lt;code&gt;auth.public&lt;/code&gt; is a 401 on&lt;br&gt;
your first request.&lt;/p&gt;
&lt;p&gt;Declaring a flag is one line, and it's worth putting them all in one file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;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')
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Public
@Post('/login')
login(ctx: Ctx&amp;lt;…&amp;gt;) { … }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;kick typegen&lt;/code&gt; collects every &lt;code&gt;defineRouteFlag&lt;/code&gt; call into a &lt;code&gt;KickRouteFlags&lt;/code&gt;&lt;br&gt;
registry, so a typo in a &lt;code&gt;skipWhen&lt;/code&gt; string is a compile error rather than a&lt;br&gt;
contributor that silently never skips. Flags resolve method &amp;gt; class &amp;gt; mount, same&lt;br&gt;
as contributors, and &lt;code&gt;@Flag.off&lt;/code&gt; removes an inherited one.&lt;/p&gt;
&lt;p&gt;A flag carries no behaviour of its own — it records a fact that any consumer can&lt;br&gt;
read. That's what makes them composable: the same &lt;code&gt;auth.public&lt;/code&gt; is read by the&lt;br&gt;
actor contributor, the audit log and the rate limiter, none of which know about&lt;br&gt;
each other.&lt;/p&gt;
&lt;h4 id=&quot;pre-match-middleware-can-read-them-too&quot;&gt;Pre-match middleware can read them too&lt;/h4&gt;
&lt;p&gt;I initially assumed flags were unavailable to connect-style middleware, since&lt;br&gt;
that runs before the router matches and there's no route yet. Wrong — there's an&lt;br&gt;
explicit hand-off:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { bindRoutePolicy, type RoutePolicyTable } from '@forinda/kickjs'

export function auditUnflagged() {
  let policy: RoutePolicyTable | undefined
  const handler = (req, _res, next) =&amp;gt; {
    const flags = policy?.lookup(req.method, req.url ?? '/')
    if (!flags?.has('audit.skip')) log(req.url)
    next()
  }
  return bindRoutePolicy(handler, (table) =&amp;gt; { policy = table })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;lookup()&lt;/code&gt; returns an empty map when the path matches &lt;strong&gt;no route at all&lt;/strong&gt; — which&lt;br&gt;
is precisely the traffic you want a rate limiter or an audit log to still see.&lt;br&gt;
The table is handed per-application rather than through a global, so two apps in&lt;br&gt;
one process never see each other's routes.&lt;/p&gt;
&lt;p&gt;The split is deliberate, and the pairing is instructive: &lt;code&gt;rateLimit()&lt;/code&gt; runs&lt;br&gt;
pre-match because an unmatched surface still needs protecting; &lt;code&gt;csrfGuard()&lt;/code&gt; is a&lt;br&gt;
guard because a token check on a route that doesn't exist is meaningless. Having&lt;br&gt;
both forms — and being pointed at the right one by which of them exposes the&lt;br&gt;
table — beats picking one and living with it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;typed-end-to-end-without-ceremony&quot;&gt;Typed end to end without ceremony&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;kick typegen&lt;/code&gt; walks the modules and generates a &lt;code&gt;KickRoutes&lt;/code&gt; namespace. In the&lt;br&gt;
handler you get params, body, query and response typed with no codegen artifacts&lt;br&gt;
in your source:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Post('/', { body: createOrderSchema, name: 'CreateOrder' })
@ApiTags('Orders')
@RequirePermission({ action: 'create', module: 'orders' })
async create(ctx: Ctx&amp;lt;KickRoutes.OrderController['create']&amp;gt;) {
  return reply.created(await this.orders.create(ctx.body))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One zod schema gives you runtime validation &lt;strong&gt;and&lt;/strong&gt; the OpenAPI request body.&lt;br&gt;
&lt;code&gt;ctx.body&lt;/code&gt; is inferred from it. The return type flows into&lt;br&gt;
&lt;code&gt;KickRoutes[...].response&lt;/code&gt;, which is what makes a generated client know what each&lt;br&gt;
route hands back.&lt;/p&gt;
&lt;p&gt;The rewrite currently registers &lt;strong&gt;459 routes and 295 services&lt;/strong&gt; this way.&lt;/p&gt;
&lt;p&gt;A related nicety: handlers return values rather than writing to the response.&lt;br&gt;
&lt;code&gt;reply(status, body)&lt;/code&gt; when the status isn't 200, &lt;code&gt;ctx.problem.notFound(...)&lt;/code&gt; for&lt;br&gt;
error branches — the latter sends immediately and returns nothing, so failure&lt;br&gt;
cases stay out of the inferred success type.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;adapters-as-the-composition-seam&quot;&gt;Adapters as the composition seam&lt;/h3&gt;
&lt;p&gt;An adapter bundles everything one concern needs — DI registrations, middleware,&lt;br&gt;
contributors, lifecycle hooks, a health probe:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const TenantAdapter = defineAdapter({
  name: 'TenantAdapter',
  build: (config) =&amp;gt; ({
    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 */ },
  }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the legacy app, that same concern was smeared across two middleware files, a&lt;br&gt;
connection registry and four request aliases. Here it's one file you can read top&lt;br&gt;
to bottom.&lt;/p&gt;
&lt;p&gt;Phases (&lt;code&gt;beforeGlobal&lt;/code&gt; / &lt;code&gt;afterGlobal&lt;/code&gt; / &lt;code&gt;beforeRoutes&lt;/code&gt; / &lt;code&gt;afterRoutes&lt;/code&gt;) and an&lt;br&gt;
optional path scope mean you rarely need to think about ordering.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;drizzle-setup-and-tenant-database-routing&quot;&gt;Drizzle setup and tenant database routing&lt;/h3&gt;
&lt;p&gt;Database-per-tenant is the part people usually ask about, so here is the whole&lt;br&gt;
mechanism. It is smaller than you'd expect, because the framework supplies the&lt;br&gt;
per-request plumbing and Drizzle supplies the rest.&lt;/p&gt;
&lt;h4 id=&quot;two-schemas-two-migration-sets&quot;&gt;Two schemas, two migration sets&lt;/h4&gt;
&lt;p&gt;The schema splits in two. A &lt;strong&gt;control&lt;/strong&gt; database holds routing and identity —&lt;br&gt;
which organisations exist, which hosts point at them, which physical database&lt;br&gt;
each one lives in. A &lt;strong&gt;tenant&lt;/strong&gt; database, one per organisation, holds everything&lt;br&gt;
else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;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, …
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two &lt;code&gt;drizzle-kit&lt;/code&gt; configs, because they migrate independently — the control&lt;br&gt;
database once, the tenant migrations across every provisioned tenant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 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 ?? '' },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Conventions worth locking in early, because retrofitting them across 240 tables&lt;br&gt;
is miserable: one table per file, enums in their own files, and named migrations&lt;br&gt;
only (&lt;code&gt;drizzle-kit generate --name=add-order-table&lt;/code&gt;) so git history reads as&lt;br&gt;
something other than timestamps.&lt;/p&gt;
&lt;h4 id=&quot;column-mixins&quot;&gt;Column mixins&lt;/h4&gt;
&lt;p&gt;Every table is built from the same mixins, which is how you get audit columns&lt;br&gt;
and soft delete without 240 opportunities to forget one. The audit foreign key&lt;br&gt;
is a parameter, since control-side tables point at operator users and tenant-side&lt;br&gt;
tables at tenant users:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;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'),
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A table then reads:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;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: () =&amp;gt; users.id }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;fkColumn&lt;/code&gt; accepts a thunk so a self-referential table — &lt;code&gt;users.created_by&lt;/code&gt;&lt;br&gt;
pointing at &lt;code&gt;users.id&lt;/code&gt; — needs no forward declaration.&lt;/p&gt;
&lt;p&gt;For the record, the legacy schema carried audit columns in two spellings&lt;br&gt;
(&lt;code&gt;created_by&lt;/code&gt; on 175 tables, &lt;code&gt;createdBy&lt;/code&gt; on 46) and soft delete on 187 of 241.&lt;br&gt;
Normalising that is a mixin, not a migration project.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keys are ULIDs&lt;/strong&gt;, generated client-side with a monotonic factory so ids minted&lt;br&gt;
in the same millisecond still sort in creation order:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const pk = () =&amp;gt;
  char('id', { length: 26 }).primaryKey().$defaultFn(() =&amp;gt; newId())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Postgres has no ULID type, so it's &lt;code&gt;char(26)&lt;/code&gt; — ten bytes wider than a uuid, and&lt;br&gt;
readable in a log line without decoding. Generating client-side means children&lt;br&gt;
can link to a parent before either is flushed.&lt;/p&gt;
&lt;h4 id=&quot;one-pool-per-instance-not-per-tenant&quot;&gt;One pool per instance, not per tenant&lt;/h4&gt;
&lt;p&gt;The connection layer is about forty lines. The important detail is that pools are&lt;br&gt;
cached &lt;strong&gt;by connection URL&lt;/strong&gt;, so a hundred tenants sharing one Postgres instance&lt;br&gt;
share one pool rather than opening a hundred:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const pools = new Map&amp;lt;string, postgres.Sql&amp;gt;()

function poolFor(url: string): postgres.Sql {
  let sql = pools.get(url)
  if (!sql) {
    sql = postgres(url, { max: 10, onnotice: () =&amp;gt; {} })
    pools.set(url, sql)
  }
  return sql
}

export function createTenantDb(url: string) {
  return drizzle(poolFor(url), { schema: tenantSchema })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The routing table stores an instance URL and a database name separately, so&lt;br&gt;
moving a tenant to another instance is a row update rather than a migration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export function tenantUrl(instanceUrl: string, dbName: string): string {
  const url = new URL(instanceUrl)
  url.pathname = `/${dbName}`
  return url.toString()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;routing-a-request-to-its-database&quot;&gt;Routing a request to its database&lt;/h4&gt;
&lt;p&gt;Two contributors, and this is where the framework earns its place. The first&lt;br&gt;
answers &lt;em&gt;which organisation&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const ResolveTenantRoute = defineHttpContextDecorator({
  key: 'tenantRoute',
  skipWhen: 'tenant.controlPlane',
  deps: { router: TENANT_ROUTER },
  async resolve(ctx, { router }): Promise&amp;lt;TenantRoute | null&amp;gt; {
    const headers = ctx.req.headers as Record&amp;lt;string, string | string[] | undefined&amp;gt;
    return router.resolve({
      host: pickHost(headers, { trustProxy: getEnv('TRUST_PROXY') === true }),
      tenantHeader: firstHeader(headers['x-tenant-id']),
    })
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second answers &lt;em&gt;which connection&lt;/em&gt;, and depends on the first:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const LoadTenantDb = defineHttpContextDecorator({
  key: 'tenantDb',
  skipWhen: 'tenant.controlPlane',
  dependsOn: ['tenantRoute'],
  deps: { resolver: TENANT_DB_RESOLVER },
  async resolve(ctx, { resolver }): Promise&amp;lt;TenantDb | null&amp;gt; {
    const route = ctx.get('tenantRoute')
    if (!route) return null
    return resolver.forOrg(route.orgId)
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four decisions in there are worth stealing:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Host-first, header as a fallback.&lt;/strong&gt; A host is set by the connection; a header&lt;br&gt;
is set by whoever is calling. The legacy app was header-first, which meant the&lt;br&gt;
tenant selector was fully client-controlled. Keeping &lt;code&gt;x-tenant-id&lt;/code&gt; behind a&lt;br&gt;
feature flag is what lets existing clients keep working while hosts roll out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The header is a selector, not a credential.&lt;/strong&gt; It says which tenant to route to,&lt;br&gt;
never who is calling. The actor contributor separately checks that the&lt;br&gt;
authenticated user's organisation matches the resolved one — without that, a&lt;br&gt;
header alone would be a tenant-hopping primitive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Both resolve to &lt;code&gt;null&lt;/code&gt; rather than throwing.&lt;/strong&gt; Control-plane routes legitimately&lt;br&gt;
have no tenant, and they say so with the &lt;code&gt;tenant.controlPlane&lt;/code&gt; flag, which&lt;br&gt;
&lt;code&gt;skipWhen&lt;/code&gt; reads. A route that genuinely needs a connection is the thing that&lt;br&gt;
turns &lt;code&gt;null&lt;/code&gt; into a 400. The legacy middleware answered such requests itself with&lt;br&gt;
a 400, which is why &quot;public&quot; routes there could still be refused for tenant&lt;br&gt;
reasons nobody had asked for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;tenantDb&lt;/code&gt; depends on &lt;code&gt;tenantRoute&lt;/code&gt;, not on the actor.&lt;/strong&gt; Unauthenticated tenant&lt;br&gt;
routes — login, password reset — still need their tenant's database. Get this&lt;br&gt;
edge wrong and login is impossible on a per-tenant database.&lt;/p&gt;
&lt;p&gt;Downstream, a repository just asks for it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const db = ctx.require('tenantDb')
return db.select().from(orders).where(eq(orders.organization_id, orgId))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For comparison, the Express version registered a Sequelize connection into a&lt;br&gt;
global model registry per request, keyed by a database name formatted from the&lt;br&gt;
organisation id, in a middleware that also did authentication.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-sharp-edges-honestly&quot;&gt;The sharp edges, honestly&lt;/h3&gt;
&lt;p&gt;A post with no criticism is marketing, so:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;@Middleware()&lt;/code&gt; is not where Express middleware goes.&lt;/strong&gt; It's engine-neutral and&lt;br&gt;
calls its handler as &lt;code&gt;(ctx, next)&lt;/code&gt;. Hand it a connect-style &lt;code&gt;(req, res, next)&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;next&lt;/code&gt; binds to the response object — you get &lt;code&gt;TypeError: next is not a function&lt;/code&gt;,&lt;br&gt;
a 500 whose stack points at your middleware rather than at the mismatch. Express&lt;br&gt;
middleware belongs in an adapter's &lt;code&gt;middleware()&lt;/code&gt;. The CLI's generated template&lt;br&gt;
used to get this backwards; it now says so explicitly and points you at&lt;br&gt;
&lt;code&gt;kick g guard&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;routes()&lt;/code&gt; paths must be literals.&lt;/strong&gt; Typegen parses them statically, so hoisting&lt;br&gt;
a path into a &lt;code&gt;const&lt;/code&gt; leaves the module unbound, every handler generating with&lt;br&gt;
&lt;code&gt;contextKeys: never&lt;/code&gt; — and the type errors point at the &lt;em&gt;controller&lt;/em&gt;, not at the&lt;br&gt;
module file that caused it. Recognise the symptom:&lt;br&gt;
&lt;code&gt;Property 'x' does not exist on type 'never'&lt;/code&gt; on every handler at once.&lt;/p&gt;
&lt;p&gt;Both are the same class of problem: the framework has the right answer, but the&lt;br&gt;
shortest path from the symptom doesn't lead to it. Worth knowing up front, which&lt;br&gt;
is most of why I'm writing this.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-the-migration-itself-taught-me&quot;&gt;What the migration itself taught me&lt;/h3&gt;
&lt;p&gt;Two things I'd carry to any port, framework aside.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prove parity with a test, not with a compile.&lt;/strong&gt; Every module in the strangler&lt;br&gt;
run shipped with a test hitting the same route on both API versions and asserting&lt;br&gt;
identical status, body and content-type. It caught genuinely subtle things: a&lt;br&gt;
route registered twice, a &lt;code&gt;PUT /remove/&lt;/code&gt; where all its siblings were &lt;code&gt;DELETE&lt;/code&gt;, a&lt;br&gt;
handler returning a different response &lt;em&gt;shape&lt;/em&gt; depending on whether a query&lt;br&gt;
string was present.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Beware the heuristic you invented three modules ago.&lt;/strong&gt; I had a rule for&lt;br&gt;
predicting whether a module was portable — does the handler &lt;em&gt;return&lt;/em&gt; a value or&lt;br&gt;
&lt;em&gt;write&lt;/em&gt; to the response? It was wrong three times, always in the same direction,&lt;br&gt;
and cost two modules a second pass each. What actually predicts cost is whether&lt;br&gt;
the handler holds its own queries, which is one grep:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -c &quot;getDb(\|dbRepo\[&quot; &amp;lt;controllers&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rewriting rather than transcribing also surfaces far more. The strangler run&lt;br&gt;
accumulated about 30 recorded findings; the rewrite found &lt;strong&gt;194&lt;/strong&gt; — because&lt;br&gt;
writing behaviour fresh forces you to read every branch, where transcription&lt;br&gt;
lets you copy straight past them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;would-i-pick-it-again&quot;&gt;Would I pick it again&lt;/h3&gt;
&lt;p&gt;For this shape of problem — a structured multi-tenant API with real per-request&lt;br&gt;
scoping — yes. The composition layer is the differentiator: contributors,&lt;br&gt;
adapters, flags and mixed mounting are what let the same framework host both an&lt;br&gt;
incremental strangler and a clean rewrite without fighting either.&lt;/p&gt;
&lt;p&gt;It is not batteries-included. You bring your own ORM and validation, the&lt;br&gt;
ecosystem is small, and some of the family packages move at different speeds. If&lt;br&gt;
you want an opinionated end-to-end stack, that's a different tool.&lt;/p&gt;
&lt;p&gt;But the primitives are the right primitives, and after 593 routes I never once&lt;br&gt;
wanted to route around them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;links-1&quot;&gt;Links&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;KickJS&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/&quot;&gt;Homepage&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/getting-started.html&quot;&gt;Getting started&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/@forinda/kickjs&quot;&gt;&lt;code&gt;@forinda/kickjs&lt;/code&gt; on npm&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The features in this post&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/route-flags.html&quot;&gt;Route flags&lt;/a&gt; — &lt;code&gt;defineRouteFlag&lt;/code&gt;, &lt;code&gt;skipWhen&lt;/code&gt;, the policy table&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/modules.html&quot;&gt;Modules&lt;/a&gt; — &lt;code&gt;routes()&lt;/code&gt;, mounting controllers and routers&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/controllers.html&quot;&gt;Controllers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/dependency-injection.html&quot;&gt;Dependency injection&lt;/a&gt; — tokens, &lt;code&gt;deps&lt;/code&gt;, &lt;code&gt;@Autowired&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/validation.html&quot;&gt;Validation&lt;/a&gt; — zod/valibot/yup schemas on the route&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/typegen.html&quot;&gt;Typegen&lt;/a&gt; — the &lt;code&gt;KickRoutes&lt;/code&gt; namespace&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/configuration.html&quot;&gt;Configuration&lt;/a&gt; — the env schema and &lt;code&gt;@Value&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/swagger.html&quot;&gt;Swagger&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/testing.html&quot;&gt;Testing&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/generators.html&quot;&gt;Generators&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/http-runtimes.html&quot;&gt;HTTP runtimes&lt;/a&gt; — the Express / Fastify / h3 seam&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Migration&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/migration-from-express.html&quot;&gt;Migrating from Express&lt;/a&gt; — the official guide. Note it documents the &lt;em&gt;inverse&lt;/em&gt; of what I did first: KickJS as host with Express routers mounted inside.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/adapters.html&quot;&gt;Adapters&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28151/i-migrated-a-593-route-express-app-to-kickjs-twice-heres-what-held-up</guid>
<pubDate>Tue, 22 Sep 2026 11:38:03 +0000</pubDate>
</item>
</channel>
</rss>