Controllers
Controllers are the presentation layer in KickJS. They handle HTTP requests, delegate to use cases or services, and send responses. A controller is a class decorated with @Controller() that defines route handlers using method decorators.
Defining a Controller
import {
Controller,
Get,
Post,
Put,
Delete,
Patch,
Autowired,
reply,
type Ctx,
} from '@forinda/kickjs'
@Controller()
export class TodoController {
@Autowired() private readonly createTodoUseCase!: CreateTodoUseCase
@Post('/', { body: createTodoSchema })
async create(ctx: Ctx<KickRoutes.TodoController['create']>) {
return reply.created(await this.createTodoUseCase.execute(ctx.body))
}
@Get('/')
async list(ctx: Ctx<KickRoutes.TodoController['list']>) {
return this.listTodosUseCase.execute()
}
@Get('/:id')
async getById(ctx: Ctx<KickRoutes.TodoController['getById']>) {
const result = await this.getTodoUseCase.execute(ctx.params.id)
if (!result) {
ctx.problem.notFound({ detail: `Todo ${ctx.params.id} not found` })
return
}
return result
}
@Delete('/:id')
async remove(ctx: Ctx<KickRoutes.TodoController['remove']>) {
await this.deleteTodoUseCase.execute(ctx.params.id)
return reply.noContent()
}
}Handlers return their payload — the runtime sends it, and kick typegen reads the return type to fill in KickRoutes[...].response. That inferred type is what a typed client consumes, so the response style is not just cosmetic: a handler that calls ctx.json() and returns nothing infers as unknown. See Return-Value Handlers for the full rules, and keep error branches on ctx.problem.* — those send immediately and return nothing, so a 404 never widens the success type.
Ctx<KickRoutes.X['method']> is the type-safe handler signature. The KickRoutes global namespace is generated by kick typegen (auto-run on kick dev and after kick g module/kick g controller/kick g scaffold) and gives you fully-typed ctx.params, ctx.body, and ctx.query from the URL pattern, the Zod schema in the route decorator, and @ApiQueryParams respectively. See Type Generation for the full picture.
The loose RequestContext type still works for backward compatibility — Ctx<> is opt-in per handler.
@Controller Decorator
@Controller(path?) registers the class in the DI container as a singleton and marks it as a controller. The optional path serves as metadata only (used by adapters like Swagger for OpenAPI spec generation) — it is not baked into the Express router.
@Controller()
export class AdminController { ... }Route Prefix: Module, Not Controller
The route prefix for a controller comes from the module's routes().path, not from @Controller(). This is the single source of truth for where routes are mounted:
// Module defines the mount prefix
class AdminModule implements AppModule {
register(container: Container) { ... }
routes() {
return { path: '/admin', router: buildRoutes(AdminController) }
}
}
@Controller() // no path needed — module handles the prefix
export class AdminController {
@Get('/stats') // resolves to /api/v1/admin/stats
async stats(ctx: RequestContext) { ... }
}WARNING
Do not set the same path on both the module and the controller. The module path is the mount prefix — the controller path is metadata only. Setting both would have previously caused path doubling (e.g. /api/v1/admin/admin/stats).
Route Decorators
Five HTTP method decorators are available, each accepting an optional path and an optional validation schema:
@Get(path?, validation?)
@Post(path?, validation?)
@Put(path?, validation?)
@Delete(path?, validation?)
@Patch(path?, validation?)The validation argument accepts Zod schemas for body, query, and params:
@Post('/', { body: createTodoSchema })
@Put('/:id', { body: updateTodoSchema })
@Get('/search', { query: searchQuerySchema })When validation is provided, the framework runs the validate() middleware before the handler. See the Validation page for details.
RequestContext
Every handler receives a RequestContext instance that wraps the raw Express request and response. It is generic over body, params, and query types:
class RequestContext<TBody = any, TParams = any, TQuery = any>Request data
| Property | Type | Description |
|---|---|---|
body | TBody | Parsed request body |
params | TParams | Route parameters (e.g. /:id) |
query | TQuery | Query string parameters |
headers | IncomingHttpHeaders | Request headers |
requestId | string | undefined | Value of x-request-id header |
file | any | Single uploaded file (with @FileUpload) |
files | any[] | undefined | Array of uploaded files |
Query string parsing
The qs() method parses structured query parameters (filters, sort, pagination):
@Get('/')
async list(ctx: RequestContext) {
const parsed = ctx.qs({
filterable: ['status', 'priority'],
sortable: ['createdAt', 'title'],
})
// parsed.filters, parsed.sort, parsed.pagination, parsed.search
}Metadata store
ctx.set(key, value) and ctx.get<T>(key) provide a per-request key-value store. Middleware can attach data (e.g. authenticated user) for handlers to read.
Response helpers
These write the response imperatively. They remain fully supported, but a handler that uses them and returns nothing infers as response: unknown — for a typed response, return the payload instead.
| Method | Status | Description |
|---|---|---|
ctx.json(data, status?) | 200 | JSON response |
ctx.created(data) | 201 | Created resource |
ctx.noContent() | 204 | No body |
ctx.problem.notFound(input?) | 404 | Not found, as RFC 9457 problem+json |
ctx.problem.badRequest(input?) | 400 | Bad request, as RFC 9457 problem+json |
ctx.notFound(message?) | 404 | Deprecated — use ctx.problem.notFound() |
ctx.badRequest(message) | 400 | Deprecated — use ctx.problem.badRequest() |
ctx.html(content, status?) | 200 | HTML response |
ctx.download(buffer, filename, type?) | -- | File download |
ctx.render(template, data?) | 200 | Render a template (requires ViewAdapter) |
Pagination
ctx.paginate() parses query params, calls your fetcher, and returns a standardized paginated response:
@Get('/')
@ApiQueryParams({ filterable: ['status'], sortable: ['createdAt'] })
async list(ctx: RequestContext) {
return ctx.paginate(
async (parsed) => {
const data = await this.repo.findPaginated(parsed)
return data // { data: T[], total: number }
},
{ filterable: ['status'], sortable: ['createdAt'] },
)
}Response shape:
{
"data": [...],
"meta": {
"page": 1,
"limit": 10,
"total": 42,
"totalPages": 5,
"hasNext": true,
"hasPrev": false
}
}Template Rendering
Render server-side templates using the configured view engine (requires ViewAdapter):
@Get('/dashboard')
async dashboard(ctx: RequestContext) {
ctx.render('dashboard', { user: ctx.req.user, title: 'Dashboard' })
}Server-Sent Events
ctx.sse() starts an SSE stream for real-time updates:
@Get('/events')
async stream(ctx: RequestContext) {
const sse = ctx.sse()
const interval = setInterval(() => {
sse.send({ time: new Date().toISOString() }, 'tick')
}, 1000)
sse.onClose(() => clearInterval(interval))
}SSE helpers:
| Method | Description |
|---|---|
sse.send(data, event?, id?) | Send an event to the client |
sse.comment(text) | Send a keep-alive comment |
sse.onClose(fn) | Register disconnect callback |
sse.close() | End the stream |
Return-Value Handlers
Handlers return the response payload instead of calling ctx.json — the runtime auto-sends it as 200 application/json when the handler wrote nothing. This is what the CLI scaffolds (kick g module, kick g controller, kick g scaffold) and works on every runtime (Express, Fastify, h3, h3-web, and the edge fetch entry):
@Get('/:id')
async get(ctx: RequestContext) {
return this.users.find(ctx.params.id) // → 200 json
}For a non-200 status, wrap with reply() — the wrapper carries the status in its type, so response inference stays exact:
import { reply } from '@forinda/kickjs'
@Post('/')
async create(ctx: RequestContext) {
return reply(201, await this.users.create(ctx.body)) // → 201 json
}
@Delete('/:id')
async remove(ctx: RequestContext) {
await this.users.remove(ctx.params.id)
return reply.noContent() // → empty 204
}Rules of precedence:
- A
ctx.*response (e.g.ctx.json) always wins — a return value after it is ignored. - Returning
undefined/voidchanges nothing — pure imperative handlers behave exactly as before. - Sugars:
reply.created(body)(201),reply.accepted(body)(202),reply.noContent()(204).
Returning values is what makes the handler's response type statically inferable — the foundation for typed-client generation. kick typegen fills KickRoutes[...].response with InferHandlerResponse<Controller['method']>, which reads the method's return type and nothing else:
| Handler style | Inferred response |
|---|---|
return payload | the payload's type |
return reply.created(payload) | the payload's type (Reply<S, T> → T) |
ctx.json(payload) then no return | unknown |
return ctx.json(payload) | RuntimeResponse — the driver object, not your data |
The last row is the trap: ctx.json() returns the runtime's response driver for fluent chaining, so return ctx.json(x) types the route as an internal framework object. Either return x directly, or call ctx.json(x) and return nothing.
Error branches
Send errors through ctx.problem.* (RFC 9457 problem+json) and return nothing. Because the branch contributes undefined, which inference drops, the route's success type stays clean:
@Get('/:id')
async get(ctx: Ctx<KickRoutes.UserController['get']>) {
const user = await this.users.find(ctx.params.id)
if (!user) {
ctx.problem.notFound({ detail: `User ${ctx.params.id} not found` })
return // response stays `User`, not `User | RuntimeResponse`
}
return user
}Non-2xx responses reach the typed client as a KickClientError carrying the problem body, so they belong on the error channel rather than in the success type.
Middleware on Controllers
Use @Middleware() at the class or method level. See Middleware for the full guide.
import { Controller, Get, Middleware } from '@forinda/kickjs'
@Controller()
@Middleware(authMiddleware) // runs on all routes in this controller
export class SecureController {
@Get('/public')
@Middleware(rateLimitMiddleware) // runs only on this route
async publicEndpoint(ctx: RequestContext) {
return { ok: true }
}
}Dependency Injection
Use @Autowired() for property injection. Dependencies are resolved lazily from the DI container:
@Controller()
export class TodoController {
@Autowired() private todoService!: TodoService
@Autowired() private logger!: AppLogger
}For constructor injection with interface tokens, use @Inject():
constructor(
@Inject(TODO_REPOSITORY) private readonly repo: ITodoRepository,
) {}