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

Prefer returning the payload

Returning the object is the recommended way to write a handler, and what the CLI scaffolds. Reach for these ctx.* helpers when you need imperative control — streaming, custom headers, or a branch that writes and exits early.

These helpers terminate the response: they write immediately, which is why a ctx.* call always wins over a return value. They remain fully supported.

What they cost you is the response type. A handler that ends return ctx.json(user) — or uses a helper and returns nothing — infers as response: unknown, because the helper hands back the engine's response object rather than the body, so the typed client has no payload to offer.

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.redirect(url, status?)302Redirect (works on every runtime)
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. It both sends the response and returns the payload, so return ctx.paginate(...) carries PaginatedResponse<T> through to KickRoutes and the typed client:

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

The default way to write a handler. Return the response payload and 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), what keeps KickRoutes and the typed client exact, and it 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. It terminates the response — the bytes are already on the wire — so the runtimes only auto-send a returned value when nothing was written (if (!res.headersSent)). This is the original, Express-shaped path and it stays authoritative; return values are additive on top of it, never a replacement.
  • return ctx.json(user) types the response as unknown. The helper hands back the engine's response object, which says nothing about the body — so the typed client gets no payload type. Return the value (return user) or wrap it (return reply(201, user)) to keep inference exact.
  • 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)unknown — the helper reports no payload type
return ctx.paginate(fetcher)PaginatedResponse<T> — sends AND returns its payload

The two ctx.json rows are the same case: ctx.json() returns the runtime's response driver for fluent chaining, and a driver says nothing about the body — so inference has no payload to report either way. Return x directly to type the route.

(That row used to read RuntimeResponse, and it was accurate: the driver object itself leaked into KickRoutes and the typed client, offering .status() / .setHeader() where a payload belonged. It degrades to unknown now — no type rather than a confidently wrong one.)

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` — the `undefined` branch is dropped
  }
  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.