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().
defineModule
import { defineModule } from '@forinda/kickjs'
export const TodosModule = defineModule({
name: 'TodosModule',
build: () => ({
register(container) {
container.registerFactory(TODOS_REPOSITORY, () => container.resolve(InMemoryTodosRepository))
},
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 aBuildContextcarrying 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 viabuildRoutes().
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:
import { defineModule } from '@forinda/kickjs'
import { TODOS_REPOSITORY } from './domain/repositories/todos.repository'
import { InMemoryTodosRepository } from './infrastructure/repositories/inmemory-todos.repository'
import { TodosController } from './presentation/todos.controller'
// Eagerly load decorated classes so @Service()/@Repository() decorators register in the DI container
import.meta.glob(
['./domain/services/**/*.ts', './application/use-cases/**/*.ts', '!./**/*.test.ts'],
{ eager: true },
)
export const TodosModule = defineModule({
name: 'TodosModule',
build: () => ({
register(container) {
container.registerFactory(TODOS_REPOSITORY, () => container.resolve(InMemoryTodosRepository))
},
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 REST pattern uses a broader glob for its flat structure:
import.meta.glob(['./**/*.service.ts', './**/*.repository.ts', '!./**/*.test.ts'], { eager: true })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:
import.meta.glob(
['./domain/services/**/*.ts', './application/use-cases/**/*.ts', '!./**/*.test.ts'],
{ eager: true },
)Plain side-effect imports work too — the glob form is just convenient.
ModuleRoutes
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 // API version override (defaults to Application.defaultVersion)
}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.
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:
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.
DI registration patterns
Factory binding (interface to implementation)
build: () => ({
register(container) {
container.registerFactory(TODO_REPOSITORY, () => container.resolve(InMemoryTodoRepository))
},
// ...
})Swapping implementations
To switch from in-memory to a database, change the factory target:
build: () => ({
register(container) {
container.registerFactory(TODO_REPOSITORY, () => container.resolve(DrizzleTodoRepository))
},
// ...
})No other code changes are needed — use cases inject via the TODO_REPOSITORY symbol token.
Module config
defineModule supports typed config + defaults — same shape as defineAdapter. Adopters call the factory with overrides at the registration site:
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
// 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()
// 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:
defineModules(TodoModule()).mount(UserModule()) // both forms compose freelyEither 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.
// 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:
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:
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 returningnullfromroutes()and registering via.mount()(or staying in the static array).