Skip to content

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

ts
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.

ts
@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:

ts
// 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:

ts
@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:

ts
@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:

ts
class RequestContext<TBody = any, TParams = any, TQuery = any>

Request data

PropertyTypeDescription
bodyTBodyParsed request body
paramsTParamsRoute parameters (e.g. /:id)
queryTQueryQuery string parameters
headersIncomingHttpHeadersRequest headers
requestIdstring | undefinedValue of x-request-id header
fileanySingle uploaded file (with @FileUpload)
filesany[] | undefinedArray of uploaded files

Query string parsing

The qs() method parses structured query parameters (filters, sort, pagination):

ts
@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.

MethodStatusDescription
ctx.json(data, status?)200JSON response
ctx.created(data)201Created resource
ctx.noContent()204No body
ctx.problem.notFound(input?)404Not found, as RFC 9457 problem+json
ctx.problem.badRequest(input?)400Bad request, as RFC 9457 problem+json
ctx.notFound(message?)404Deprecated — use ctx.problem.notFound()
ctx.badRequest(message)400Deprecated — use ctx.problem.badRequest()
ctx.html(content, status?)200HTML response
ctx.download(buffer, filename, type?)--File download
ctx.render(template, data?)200Render a template (requires ViewAdapter)

Pagination

ctx.paginate() parses query params, calls your fetcher, and returns a standardized paginated response:

ts
@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:

json
{
  "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):

ts
@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:

ts
@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:

MethodDescription
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):

ts
@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:

ts
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/void changes 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 styleInferred response
return payloadthe payload's type
return reply.created(payload)the payload's type (Reply<S, T>T)
ctx.json(payload) then no returnunknown
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:

ts
@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.

ts
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:

ts
@Controller()
export class TodoController {
  @Autowired() private todoService!: TodoService
  @Autowired() private logger!: AppLogger
}

For constructor injection with interface tokens, use @Inject():

ts
constructor(
  @Inject(TODO_REPOSITORY) private readonly repo: ITodoRepository,
) {}

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