exjs-controllers

Controllers & Routes

Class-based controllers, JSON controllers and HTTP method decorators with route-level metadata.

A controller is a class decorated with @Controller or @JsonController. Each method decorated with @Get, @Post, @Put, @Patch or @Delete is registered as an Express route under the controller's prefix.

import { JsonController } from 'exjs-controllers/decorators/Controller'
import { Get, Post } from 'exjs-controllers/decorators/HttpMethod'

@JsonController('/users')
class UsersController {
  @Get('/')
  list() {
    return [{ id: '1' }]
  }

  @Post('/')
  create() {
    return { created: true }
  }
}

@Controller(prefix, options?)

Registers a class as a controller. All routes inside the class are mounted under prefix.

@Controller('/users')
class UsersController { /* ... */ }

The handler return value is sent with res.send(result). Strings are sent as text/plain.

options.group assigns every route in the controller to an OpenAPI documentation group. It has no effect on routing — see Multiple documents.

@Controller('/admin/reports', { group: 'admin' })
class AdminReportsController { /* ... */ }

@JsonController(prefix, options?)

Like @Controller, but the framework forces application/json for every handler return:

  • Object/array results are sent with res.json(result).
  • String results that happen to be valid JSON are parsed and re-serialized.
  • A request-scoped JSON body parser is also mounted automatically on the controller's prefix.
@JsonController('/api/v1/users')
class UsersController { /* ... */ }

It accepts the same options as @Controller.

Route decorators

@Get(path, options?)
@Post(path, options?)
@Put(path, options?)
@Patch(path, options?)
@Delete(path, options?)

Express 5 path syntax applies — including :param and * segments. The resulting registered path is controllerPrefix + routePath, normalized to remove duplicate slashes.

@Controller('/orders')
class OrdersController {
  @Get('/:id')
  findOne() { /* GET /orders/:id */ }

  @Patch('/:id/status')
  updateStatus() { /* PATCH /orders/:id/status */ }
}

Duplicate routes

Registering the same METHOD path twice — even from different controllers — throws at bootstrap time. The framework keeps an internal Set of registered routes to surface conflicts early.

RouteOptions

PropertyTypeDescription
inputClasstypeof BaseSchemaDTO class used to document the request body (requestBody.content['application/json'].schema).
outputClasstypeof BaseSchemaDTO class used to document the 200 response body.
outputIsArraybooleanWhen true, the OpenAPI response schema is { type: 'array', items: <outputClass> }.
summarystringShort, single-line description shown in the OpenAPI docs / Scalar UI.
descriptionstringLong-form description.
tagsstring[]OpenAPI tags. Useful for grouping operations in Scalar.
groupstringOpenAPI documentation group. Overrides the controller's group; routes without one belong to default. See Multiple documents.
@Get('/items', {
  outputClass: ItemOutput,
  outputIsArray: true,
  summary: 'List all items',
  description: 'Returns every item available to the caller, paginated by query string.',
  tags: ['items']
})
listItems(): ItemOutput[] {
  return items.map((item) => ItemOutput.create(item))
}

Response handling

The route handler return value is what eventually reaches the wire. You can also write to the response directly via the raw Response — once headers are sent the framework will skip the auto-response step.

@Post('/files', { outputClass: FileOutput })
async upload(@Res() res: HttpResponse, @Body() body: unknown): Promise<void> {
  res.status(201)
  res.setHeader('Location', '/files/abc')
  res.json({ id: 'abc' })
}

Error handling

Any exception thrown inside a handler is forwarded to Express through next(error). Use a custom errorHandler middleware on configureApplication to convert framework errors (e.g. UnauthorizedError, ForbiddenError, UseCaseInputValidationError) into HTTP responses.

await configureApplication(app, {
  controllers,
  errorHandler: (error, _request, response, _next) => {
    const status = (error as { status?: number; statusCode?: number }).status
      ?? (error as { statusCode?: number }).statusCode
      ?? 500
    response.status(status).json({ message: (error as Error).message })
  }
})

On this page