Skip to content

Modules ​

Every feature in a KickJS application is organized as a module. Modules register DI bindings and declare HTTP routes. Build them with the defineModule() factory — the same define* pattern used by defineAdapter(), definePlugin(), and defineContextDecorator().

Scaffold one

kick g module <name> writes the whole module — controller, service, repository, DTOs, tests — in the layout shown below.

pnpm exec kick g module todos

--pattern minimal for just a controller + module, -m and --repo for placement and the repository stub. Full flag list: Generators.

defineModule ​

ts
import { defineModule } from '@forinda/kickjs'

export const TodosModule = defineModule({
  name: 'TodosModule',
  build: () => ({
    register(container) {
      container.registerFactory(TODOS_REPOSITORY, () => createTodosRepository())
    },
    routes() {
      return { path: '/todos', controller: TodosController }
    },
  }),
})
  • name — stable identity surfaced in diagnostics, route logs, and .scoped() namespacing. Required.
  • build(config, ctx) — returns the module shape (register / routes / contributors). Receives the merged config (defaults + call-site overrides) and a BuildContext carrying the resolved instance name + scoped flag.
  • register(container) — optional. Bind interface tokens to concrete implementations. Modules whose classes are entirely decorator-managed (@Service, @Controller, @Repository) skip this hook.
  • routes() — return one route set or an array of route sets. The framework derives the Express Router from the controller via buildRoutes().

The legacy class TodosModule implements AppModule { ... } form keeps working — bootstrap accepts either shape and the loader discriminates at boot. defineModule is the recommended form for new code.

Generated module ​

This is the structure generated by kick g module todos:

ts
import { defineModule } from '@forinda/kickjs'
import { TODOS_REPOSITORY, createTodosRepository } from './todos.repository'
import { TodosController } from './todos.controller'

// Eagerly load decorated classes so @Controller()/@Service()/@Repository()
// decorators register in the DI container
import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./**/*.d.ts'], { eager: true })

export const TodosModule = defineModule({
  name: 'TodosModule',
  build: () => ({
    register(container) {
      container.registerFactory(TODOS_REPOSITORY, () => createTodosRepository())
    },
    routes() {
      return {
        path: '/todos',
        controller: TodosController,
      }
    },
  }),
})

The import.meta.glob call ensures all decorated classes in the module are imported at module load time. Without this, @Service() and @Repository() decorators would never fire, and the DI container wouldn't know about those classes. The glob is deliberately broad rather than a list of suffixes. A suffix list only covers the filenames the generator emits, so a hand-written *.usecase.ts or *.policy.ts never registered and failed later as No provider for X — the exact problem the eager load exists to prevent. It is recursive too, so nesting files does not break registration.

Eager loading with import.meta.glob ​

Classes decorated with @Service(), @Repository(), or @Component() must be imported so their decorators execute and register them in the DI container. The generated modules use import.meta.glob for this:

ts
import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./**/*.d.ts'], { eager: true })

Plain side-effect imports work too — the glob form is just convenient.

ModuleRoutes ​

ts
interface ModuleRoutes {
  path: string // URL prefix, e.g. '/todos'
  controller?: any // Controller class — framework derives the router via buildRoutes(controller)
  router?: any // Express Router — only when you need to hand-build the router
  version?: number | false // API version override (false = no /v{n} segment)
  prefix?: false // drop apiPrefix — pair with version: false to mount at `path` exactly
  flags?: readonly RouteFlagName[] | RouteFlagRecord // route flags for every route in this mount
}

Either controller or router is required. Pass controller for the common case — the framework calls buildRoutes(controller) internally to produce the Express Router and uses the same controller for OpenAPI spec generation through SwaggerAdapter. Pass router directly only when you need to compose multiple controllers under one path or hand-build the router yourself.

Routes mount at /{apiPrefix}/v{version}{path}. With the defaults (apiPrefix: '/api', defaultVersion: 1), a module returning path: '/todos' mounts at /api/v1/todos.

Flagging every route in a mount ​

flags is the module-level declaration site for route flags — the one place you can flag routes on a controller you don't own, since a decorator has to be written on the class:

ts
routes: () => ({
  path: '/webhooks',
  controller: WebhooksController,
  flags: ['auth.public'], // bare flags, each stored as `true`
})

Use the record form for flags that carry a value:

ts
flags: { 'auth.public': true, 'rate.limit': { rpm: 10 } }

Precedence is method > class > mount, so a @Flag on the controller or handler wins and @Flag.off on a method still drops an inherited one. A name starting with ! throws at boot — a declaration has no negative form; remove the flag instead.

The built-in health module is the framework's own use of this: bootstrap({ health: { flags: ['auth.public'] } }) puts your flag on both probes, whose controller belongs to the framework.

Multiple route sets + versioning ​

routes() can return an array to mount multiple route sets under the same module — useful when one feature spans several controllers, or when you want a v1 and v2 surface of the same controller live side-by-side. Each entry can override the API version with a version field:

ts
import { defineModule } from '@forinda/kickjs'
import { TodoController } from './todos.controller'
import { TodoV2Controller } from './todos.v2.controller'
import { TodoAdminController } from './admin/todo-admin.controller'

export const TodosModule = defineModule({
  name: 'TodosModule',
  build: () => ({
    routes() {
      return [
        // /api/v1/todos — legacy surface for older clients
        { path: '/todos', controller: TodoController },
        // /api/v2/todos — current surface, same path different version
        { path: '/todos', version: 2, controller: TodoV2Controller },
        // /api/v1/admin/todos — admin surface, same module, different mount
        { path: '/admin/todos', controller: TodoAdminController },
      ]
    },
  }),
})

This mounts /api/v1/todos, /api/v2/todos, and /api/v1/admin/todos — three controllers, one module, one DI registration block.

Opting out of versioning ​

URL versioning is a default, not a requirement. Set defaultVersion: false to drop the /v{n} segment app-wide:

ts
bootstrap({
  modules,
  defaultVersion: false, // /api/todos
})

Pair it with an empty prefix to mount at the root:

ts
bootstrap({
  modules,
  apiPrefix: '',
  defaultVersion: false, // /todos
})

The per-mount version still wins over the app default, in both directions — that's the escape hatch. An unversioned app can carry one versioned module:

ts
bootstrap({ modules, defaultVersion: false })

routes() {
  return [
    { path: '/todos', controller: TodoController },              // /api/todos
    { path: '/payments', version: 2, controller: PayController }, // /api/v2/payments
  ]
}

…and a versioned app can carry one unversioned module — useful for a webhook receiver or a health surface a third party has already hardcoded:

ts
bootstrap({ modules }) // defaultVersion: 1

routes() {
  return [
    { path: '/todos', controller: TodoController },                // /api/v1/todos
    { path: '/webhooks', version: false, controller: HookController }, // /api/webhooks
  ]
}

createWebApp (edge deployment) takes the same two options and computes mount paths through the same helper, so the node and web entries cannot disagree.

Changing this moves every URL

defaultVersion: false is not a compatibility shim — it rewrites the mount path of every module that doesn't set its own version. On a deployed API that means every existing client 404s. Flipping it is a breaking change to your own consumers; the typed client's baseUrl has to move with it.

DI registration patterns ​

Factory binding (interface to implementation) ​

ts
build: () => ({
  register(container) {
    container.registerFactory(TODO_REPOSITORY, () => container.resolve(InMemoryTodoRepository))
  },
  // ...
})

Swapping implementations ​

To switch from in-memory to a database, change the factory target:

ts
build: () => ({
  register(container) {
    container.registerFactory(TODO_REPOSITORY, () => container.resolve(DbTodoRepository))
  },
  // ...
})

No other code changes are needed — use cases inject via the TODO_REPOSITORY symbol token.

Sharing services between modules ​

The container is global, so a module can inject any registered token — including one that another module registers. That works until the other module is not mounted: nothing fails at boot, and the first request that resolves the missing token answers 500. Modules deliberately have no dependsOn; which modules an app mounts is the app's decision.

Adapters and plugins are the layer built for this. Both register into the same container, both declare dependsOn, and a missing dependency fails boot with MissingMountDepError, not a request.

Move what is shared out of the module that happens to use it first. Register it from a plugin (or adapter), and let every module that needs it inject the token:

ts
// src/finance/finance.plugin.ts
export const FinancePlugin = definePlugin({
  name: 'FinancePlugin',
  build: () => ({
    register(container) {
      container.registerFactory(POSTING_ENGINE, () => container.resolve(PostingEngine))
    },
    modules: () => [FinanceModule()],
  }),
})

Declare the requirement where it can be checked. A group of modules that needs finance becomes a plugin that depends on it:

ts
// src/procurement/procurement.plugin.ts
export const ProcurementPlugin = definePlugin({
  name: 'ProcurementPlugin',
  build: () => ({
    dependsOn: ['FinancePlugin'],
    modules: () => [ProcurementModule()],
  }),
})

bootstrap({ plugins: [FinancePlugin(), ProcurementPlugin()] })

Drop FinancePlugin() from that list and boot stops with Missing plugin dependency 'FinancePlugin' required by 'ProcurementPlugin', instead of POST /procurement/payment-vouchers/:id/pay failing in production.

Ordering, for when a register() reads another binding:

  • plugin register() hooks run, in dependsOn order, before any module's register();
  • adapter beforeMount also runs before modules register; adapter beforeStart runs after, which is fine for anything injected lazily but too late for a module's register() to read.

Use an adapter instead of a plugin when the shared piece also owns a lifecycle — a connection opened in beforeStart and closed in shutdown(). See Plugins → Ordering and Adapters → Ordering.

Module config ​

defineModule supports typed config + defaults — same shape as defineAdapter. Adopters call the factory with overrides at the registration site:

ts
interface TodosConfig {
  scope: 'public' | 'admin'
}

const TodosModule = defineModule<TodosConfig>({
  name: 'TodosModule',
  defaults: { scope: 'public' },
  build: (config, { name }) => ({
    register(container) {
      container.registerInstance(`todos:scope:${name}`, config.scope)
    },
    routes() {
      return { path: `/${config.scope}/todos`, controller: TodosController }
    },
  }),
})

// src/modules/index.ts
export const modules = [
  TodosModule(), // public scope (defaults)
  TodosModule.scoped('admin', { scope: 'admin' }), // namespaced clone
]

Use .scoped(scopeName, config?) when you need two instances of the same module under different DI namespaces — the build context's name becomes ${moduleName}:${scopeName} so adopters can derive distinct token keys.

Composing modules ​

Modules are collected into an array and passed to bootstrap(). Two equivalent ways to build that array — pick whichever reads better at the call site:

Plain array ​

ts
// src/modules/index.ts
import type { AppModuleEntry } from '@forinda/kickjs'
import { TodoModule } from './todos'
import { UserModule } from './users'

// defineModule factories are called at the registration site; the
// invocation produces the AppModule instance bootstrap registers.
export const modules: AppModuleEntry[] = [TodoModule(), UserModule()]

Fluent factory — defineModules() ​

ts
// src/modules/index.ts
import { defineModules } from '@forinda/kickjs'
import { TodoModule } from './todos'
import { UserModule } from './users'

export const modules = defineModules().mount(TodoModule()).mount(UserModule())

defineModules() returns a ModuleList (an AppModuleEntry[] subclass with a chainable .mount()) so the value drops into bootstrap({ modules }) directly — no extra unwrap step. Optional vararg seeds the list inline:

ts
defineModules(TodoModule()).mount(UserModule()) // both forms compose freely

Either form ends up the same shape inside the framework — bootstrap iterates an array of AppModuleEntry. Fluent reads more naturally as the list grows; plain-array is fine for small projects.

ts
// src/index.ts
import { bootstrap } from '@forinda/kickjs'
import { modules } from './modules'

bootstrap({ modules })

The bootstrap() function loads each entry (instantiating classes, using factory output as-is), calls register() to set up DI bindings, bootstraps the container, then mounts all routes.

Conditional registration — setup(registry) ​

The static modules: [...] array covers the common case but can't express "register this module only if an env flag is set" or "register one module per tenant in this list." For that, bootstrap accepts a setup(registry) callback that receives a ModuleRegistry. Call .mount(module) on it for every module you want loaded:

ts
import { bootstrap } from '@forinda/kickjs'
import { HelloModule } from './modules/hello/hello.module'
import { AdminModule } from './modules/admin/admin.module'
import { TenantModule } from './modules/tenant/tenant.module'

await bootstrap({
  modules: [HelloModule()], // static — always mounted

  setup(registry) {
    if (process.env.ENABLE_ADMIN === 'true') {
      registry.mount(AdminModule())
    }
    for (const tenant of process.env.TENANTS!.split(',')) {
      registry.mount(TenantModule.scoped(tenant, { id: tenant }))
    }
  },
})

The static array and the setup callback both feed into the same registry; bootstrap mounts everything in declared order (static array entries first, then setup-mounted entries). Use whichever fits each module's intent — purely-static modules stay in the array; conditional / dynamic ones live in setup.

Plugins get the same hook. A multi-tenant plugin that needs to mount one module per tenant in its config can drop the static array entirely:

ts
import { definePlugin } from '@forinda/kickjs'

interface MultiTenantConfig {
  tenants: { id: string; region: string }[]
}

export const MultiTenantPlugin = definePlugin<MultiTenantConfig>({
  name: 'MultiTenantPlugin',
  defaults: { tenants: [] },
  build: (config) => ({
    setup(registry) {
      for (const tenant of config.tenants) {
        registry.mount(TenantModule.scoped(tenant.id, tenant))
      }
    },
  }),
})

Currently the registry exposes only .mount(module). A future .use(module) is planned for non-HTTP modules (queues, cron, workers, DI-only seeds) — until it lands, non-HTTP modules continue returning null from routes() and registering via .mount() (or staying in the static array).

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.