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 } from '@forinda/kickjs-core'
import { RequestContext } from '@forinda/kickjs-http'
@Controller()
export class TodoController {
@Autowired() private createTodoUseCase!: CreateTodoUseCase
@Post('/', { body: createTodoSchema })
async create(ctx: RequestContext) {
const result = await this.createTodoUseCase.execute(ctx.body)
ctx.created(result)
}
@Get('/')
async list(ctx: RequestContext) {
ctx.json(await this.listTodosUseCase.execute())
}
@Get('/:id')
async getById(ctx: RequestContext) {
const result = await this.getTodoUseCase.execute(ctx.params.id)
if (!result) return ctx.notFound('Todo not found')
ctx.json(result)
}
@Delete('/:id')
async remove(ctx: RequestContext) {
await this.deleteTodoUseCase.execute(ctx.params.id)
ctx.noContent()
}
}@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('/admin')
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
| Method | Status | Description |
|---|---|---|
ctx.json(data, status?) | 200 | JSON response |
ctx.created(data) | 201 | Created resource |
ctx.noContent() | 204 | No body |
ctx.notFound(message?) | 404 | Not found error |
ctx.badRequest(message) | 400 | Bad request error |
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 |
Middleware on Controllers
Use @Middleware() at the class or method level. See Middleware for the full guide.
import { Controller, Get, Middleware } from '@forinda/kickjs-core'
@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) {
ctx.json({ 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,
) {}