Samples
A copy-pasteable tour of the patterns KickJS leans on. Each sample is the recommended way to do the thing — short enough to read in one screen, with a one-line note on why it's the pattern and a link to the full guide when you want the detail.
If you're new, read What is KickJS and Getting Started first, then skim this page top-to-bottom — it doubles as the mental model.
The mental model in one breath: decorate classes (
@Controller,@Service), let the DI container wire them, group them into modules, andbootstrap().kick typegenscans your source and feeds the editor types (routes, env, DI tokens, context keys) so the framework stays "just TypeScript" with no codegen you hand-maintain.
1. Bootstrap + a controller
The smallest useful app: a typed controller and a bootstrap().
// src/modules/users/user.controller.ts
import { Controller, Get, Post, type Ctx } from '@forinda/kickjs'
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
@Controller()
export class UserController {
@Get('/')
async list(ctx: Ctx<KickRoutes.UserController['list']>) {
ctx.json([{ id: '1', name: 'Alice' }])
}
@Post('/', { body: createUserSchema })
async create(ctx: Ctx<KickRoutes.UserController['create']>) {
ctx.created({ id: '2', ...ctx.body }) // ctx.body is typed { name; email }
}
}Best pattern: type each handler with Ctx<KickRoutes.X['method']> — kick typegen infers params / body / query (including the Zod shape on the route decorator) so you never hand-annotate them. → Controllers, Type Generation
2. Dependency injection
Decorate a class with @Service() and inject it — by property (@Autowired) or by token (@Inject).
import { createToken, Inject, Service, Autowired } from '@forinda/kickjs'
interface IOrderRepository {
findById(id: string): Promise<Order | null>
}
// Token = the seam for an interface. Reference-equal, type-safe, collision-proof.
export const ORDER_REPO = createToken<IOrderRepository>('OrderRepository')
@Service()
class OrderService {
// Constructor injection by token (explicit binding):
constructor(@Inject(ORDER_REPO) private repo: IOrderRepository) {}
}
@Controller()
class OrderController {
// Property injection by type (lazy — resolved on first access):
@Autowired() private readonly orders!: OrderService
}Best pattern: bind interfaces through createToken<T>('Name') (not string literals); use @Autowired() for concrete classes and @Inject(TOKEN) when you need a specific binding. → Dependency Injection
3. Modules
Group controllers + their DI bindings into a 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 }
},
}),
})Best pattern: prefer the defineModule() factory form with a stable name; do per-module DI binding in register(container) and mount controllers from routes(). → Modules
4. Configuration & env
Validate process.env once, read it type-safely everywhere.
// src/config/index.ts — define + validate the schema
import { loadEnvFromSchema } from '@forinda/kickjs/config'
import { fromZod } from '@forinda/kickjs-schema/zod'
import { z } from 'zod'
const envSchema = fromZod(
z.object({
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
}),
)
export const env = loadEnvFromSchema(envSchema)
export default envSchema// src/index.ts — import the schema as a side effect BEFORE bootstrap
import 'reflect-metadata'
import './config'
import { bootstrap } from '@forinda/kickjs'
export const app = await bootstrap({ modules })// Inject a single value anywhere with @Value (lazy, typed via Env<K>):
import { Service, Value, type Env } from '@forinda/kickjs'
@Service()
class MailService {
@Value('JWT_SECRET') private readonly secret!: Env<'JWT_SECRET'>
}Best pattern: keep the schema in src/config/index.ts and import it as a side effect from src/index.ts before bootstrap() — otherwise ConfigService.get() silently falls back to process.env. → Configuration
5. Context decorators (typed ctx population)
The typed, ordered alternative to @Middleware() when the only job is to compute a value other code reads off ctx.
import { defineHttpContextDecorator, Controller, Get, type RequestContext } from '@forinda/kickjs'
// 1. Declare the value's type so `ctx.get('locale')` is typed.
declare module '@forinda/kickjs' {
interface ContextMeta {
locale: { language: string; region: string | null }
}
}
// 2. Resolve it. `defineHttpContextDecorator` pre-binds ctx to RequestContext.
const ResolveLocale = defineHttpContextDecorator({
key: 'locale',
resolve: (ctx) => {
const header = (ctx.req.headers['accept-language'] as string | undefined) ?? 'en'
const [language, region] = header.split(',')[0].trim().split('-')
return { language, region: region ?? null }
},
})
@Controller()
class HomeController {
@ResolveLocale
@Get('/')
home(ctx: RequestContext) {
return ctx.json(ctx.get('locale')) // typed
}
}Parameterised contributors use the curried .withParams<T>() form (apply as @LoadTenant({ source: 'subdomain' })):
type LoadTenantParams = { source: 'header' | 'subdomain' }
export const LoadTenant = defineHttpContextDecorator.withParams<LoadTenantParams>()({
key: 'tenant',
deps: { registry: TENANT_REGISTRY }, // typed DI, resolved before resolve()
dependsOn: ['locale'], // topo-sorted at startup; missing/cyclic deps fail boot
paramDefaults: { source: 'header' },
resolve: (ctx, { registry }, params) => registry.findFor(ctx, params.source),
})Best pattern: declare the ContextMeta type first; reach for dependsOn to order contributors (it's typo-checked); scaffold one with kick g contributor <name> --type http|bare [--params …]. Keep @Middleware() for short-circuiting responses or mutating the stream. → Context Decorators
6. Error handling
Throw structured errors; the framework renders them.
import { HttpException } from '@forinda/kickjs'
throw new HttpException(400, 'Invalid input')
throw HttpException.notFound('User not found')
throw HttpException.fromZodError(result.error) // 422 + field errorsFor RFC 9457 Problem Details, use ctx.problem.* in handlers and Problems.* in services (no ctx in scope):
// in a handler
if (!project) {
return ctx.problem.notFound({ detail: `Project ${ctx.params.id} not found` })
}
// in a service
import { Problems } from '@forinda/kickjs'
if (account.balance < amount) {
throw Problems.forbidden({ detail: `Balance ${account.balance} < ${amount}` })
}Best pattern: HttpException for quick errors, ctx.problem.* / Problems.* when you want machine-readable Problem Details. → Error Handling
7. Adapters (cross-cutting lifecycle)
Bolt framework-wide behaviour (auth, tracing, multi-tenancy) onto the app via lifecycle hooks.
import { defineAdapter, type AdapterContext, type AdapterMiddleware } from '@forinda/kickjs'
export const RequestLogger = defineAdapter({
name: 'RequestLoggerAdapter',
build: () => ({
dependsOn: ['OtelAdapter'], // topo-sorted; typo-checked against the plugin registry
middleware(): AdapterMiddleware[] {
return [
/* express middleware */
]
},
beforeStart({ container }: AdapterContext) {
/* warm caches, open pools */
},
async shutdown() {
/* drain, close */
},
}),
})
// bootstrap({ adapters: [RequestLogger()] })Best pattern: use defineAdapter() for app-wide concerns and dependsOn: ['OtherAdapter'] for ordering; mount as Adapter(config?) in bootstrap({ adapters }). → Adapters
8. Extending the CLI — the full defineCliPlugin surface
The kick CLI is itself a composition of plugins — every built-in command ships internally as a KickCliPlugin, and adopters extend the same surface from kick.config.ts > plugins[]. A plugin is an object (or a factory returning one); defineCliPlugin is the identity helper for inference.
import { defineCliPlugin } from '@forinda/kickjs-cli'
export const myPlugin = defineCliPlugin({
name: 'my-org-cli', // required — stable id, used for de-dup + conflict errors
commands: [
/* declarative shell commands */
],
register(program, ctx) {
/* programmatic Commander commands */
},
generators: [
/* `kick g <name>` scaffolders */
],
// typegens: [ … ] // ← `kick typegen` plugins — see Type Generation
})
// kick.config.ts
// export default defineConfig({ plugins: [myPlugin] })Everything you can put on a plugin, field by field (typegens aside — those live in Type Generation):
name (required)
A stable identifier. Used to de-dup plugins and to name the offender in conflict errors (two plugins shipping the same command / generator name throw KickPluginConflictError). Convention: the package name ('kickjs-cli-db').
commands[] — declarative shell commands
The lowest-ceremony extension: a name, a description, and shell steps. Each becomes a kick <name> command.
defineCliPlugin({
name: 'db-tools',
commands: [
{
name: 'db:migrate', // → `kick db:migrate`
description: 'Apply pending migrations', // shown in --help
steps: 'kick db migrate', // string … or string[] for sequential steps
aliases: ['migrate'], // optional — `kick migrate` also works
},
{
name: 'proto:gen',
description: 'Codegen protobufs',
steps: ['npx buf generate', 'echo done'], // runs in order; stops on first failure
},
],
})steps may use {args} as a placeholder for trailing CLI arguments. Same shape as the top-level kick.config.ts > commands[] — a plugin just bundles them for reuse.
register(program, ctx) — programmatic Commander
When declarative commands aren't enough (subcommands, options/flags, async actions), get the raw Commander program. Called once at CLI startup.
defineCliPlugin({
name: 'reporter',
register(program, ctx) {
program
.command('report <kind>')
.description('Emit a project report')
.option('--json', 'machine-readable output')
.action(async (kind: string, opts: { json?: boolean }) => {
ctx.log(`project root: ${ctx.projectRoot}`)
const pattern = ctx.config?.pattern ?? 'rest'
// … build the report; write to ctx.projectRoot
})
},
})The second arg is the plugin context (KickCliPluginContext) — so the callback never re-loads config or guesses paths:
| Field | What it is |
|---|---|
cwd | Directory the command was invoked from (may be a nested subdir). |
projectRoot | Resolved project root (dir owning kick.config.*, else nearest package.json, else cwd). Prefer this for writing files. |
config | The loaded kick.config.ts (KickConfig | null). |
log(msg) | Plugin-friendly logger. |
generators? | Merged plugin generators (built-ins + adopter) — lets a register() surface them as real subcommands. |
generators[] — custom kick g <name> scaffolders
Teach kick g your own scaffolds with defineGenerator. kick g <name> matches name (after built-ins), runs files(ctx), and writes each returned { path, content }.
import { defineCliPlugin, defineGenerator } from '@forinda/kickjs-cli'
const actionGen = defineGenerator({
name: 'action', // → `kick g action Order`
description: 'Generate a service action + handler', // shown in `kick g --list`
args: [{ name: 'name', required: true, description: 'Action name' }], // informational (help)
flags: [{ name: 'sync', description: 'Synchronous handler', takesValue: false }], // informational
files: (ctx) => [
{
// relative paths resolve against ctx.cwd; parent dirs auto-created
path: `${ctx.modulesDir}/${ctx.kebab}/create-${ctx.kebab}.action.ts`,
content: `export class Create${ctx.pascal}Action {}\n`,
},
],
})
export const actionPlugin = defineCliPlugin({ name: 'my-action-plugin', generators: [actionGen] })The files(ctx) factory receives a GeneratorContext with the name pre-cased + project paths + raw input:
| Field | Example for kick g command UserPost extra --sync |
|---|---|
name / pascal / kebab / camel / snake | UserPost / UserPost / user-post / userPost / user_post |
pluralPascal? / pluralKebab? / pluralCamel? | UserPosts / user-posts / userPosts (when pluralize is on) |
modulesDir | 'src/modules' (from kick.config.ts) |
cwd / projectRoot | invocation dir / resolved project root (prefer projectRoot for stable writes) |
args | ['extra'] (positional args after the name) |
flags | { sync: true } (booleans for switches, strings for --key value) |
files() may be async and return a Promise<GeneratorFile[]>.
Best pattern: start with commands[] for one-off shell steps; reach for register() only when you need options/subcommands/async; ship generators[] to standardise scaffolds across a team. Promote a one-project kick.config.ts > commands[] to a published defineCliPlugin when you want to reuse it. → CLI Plugins, Custom Commands, Plugin Generators
9. Scaffolding with kick g
Let the CLI write the boilerplate to the right place, in your project's pattern (REST / minimal).
kick g module user # full module (controller, DTOs, service, repo)
kick g scaffold Post title:string published:boolean:optional # CRUD module from fields
kick g controller auth # standalone controller
kick g service payment # @Service singleton
kick g contributor tenant --type http # context contributor (RequestContext)
kick g contributor session --type bare --params "source:string" # withParams<T>() form
kick g --list # every built-in + plugin generatorBest pattern: scaffold rather than hand-write — generators emit the typed Ctx<> pattern, the right folder layout, and (for contributors) the ContextMeta stub, then kick typegen wires the editor types. → Code Generators
The "bring-your-own" philosophy
KickJS ships primitives (defineContextDecorator, defineAdapter, definePlugin, DI, typegen) rather than opinionated domain packages — so you compose the auth / multi-tenancy / observability layer you actually need instead of fighting one that almost fits. The BYO Recipes guide walks a complete auth layer built entirely from these primitives.
Where to go next
- Step-by-step tutorials: REST module architecture, JWT auth, query + pagination, realtime, typed client, custom CLI.
- Reference: Decorators, Type Generation, Project Structure.
- Gotchas worth reading early: DI gotchas, HMR + decorators.