Skip to content

Controller Guide

This guide explains how controllers work in Vona within the Cabloy monorepo.

Why controllers matter

Controllers are the HTTP-facing contract surface of the backend.

They define how routes, request parameters, validation, response typing, and OpenAPI metadata meet the rest of the backend thread.

A useful contract-loop mental model is:

  • controller defines the route and request/response surface
  • service owns orchestration
  • model and entity shape persistence behavior
  • DTOs and validation shape explicit request/response contracts
  • OpenAPI emits the machine-readable contract that frontend SDK generation can consume

Create a controller

Example: create a controller named student in module demo-student.

CLI command

bash
npm run vona :create:bean controller student -- --module=demo-student

Controller definition

Representative pattern:

typescript
@Controller<IControllerOptionsStudent>('student')
export class ControllerStudent extends BeanBase {
  @Web.post('')
  @Api.body(v.tableIdentity())
  async create(@Arg.body() student: DtoStudentCreate): Promise<TableIdentity> {
    return (await this.scope.service.student.create(student)).id;
  }
}

The most important things to notice are:

  • the controller path is declared in @Controller(...)
  • the action path and request method are declared with @Web.*
  • request parameters are declared with @Arg.*
  • response metadata can be enriched for validation and OpenAPI generation

Route composition

Vona uses automatic route registration for controller actions that use @Web decorators.

The general route model is:

text
Route Path = GlobalPrefix + Module Url + Controller Path + Action Path

Important pieces:

  • GlobalPrefix: from project config, commonly /api
  • Module Url: derived from the module name
  • controller path and action path: defined in the controller itself

Route simplification rules

Three useful simplification rules apply here.

1. Remove duplicate module path fragments

If the controller path matches the module name, the duplicate fragment is removed from the final route.

2. Leading / removes the module URL

If the controller path or action path starts with /, the module URL is removed.

3. Leading // removes both global prefix and module URL

If the controller path or action path starts with //, both the global prefix and module URL are removed.

This is useful for special routes such as the project homepage or a shared non-module-prefixed API path.

Request methods

Vona groups HTTP method decorators under @Web, which helps reduce mental overhead.

Representative methods include:

  • @Web.post
  • @Web.get
  • @Web.delete
  • @Web.put
  • @Web.patch
  • @Web.options
  • @Web.head

Request parameters

Vona groups request-parameter decorators under @Arg.

Representative parameter decorators include:

  • @Arg.param
  • @Arg.query
  • @Arg.body
  • @Arg.headers
  • @Arg.fields
  • @Arg.field
  • @Arg.files
  • @Arg.file
  • @Arg.user

These decorators are also the main request-parameter surface for multipart upload flows; see Upload Guide.

Parameter extraction patterns

A useful distinction is:

  • specify a field name when you want one parameter only
  • omit the field name when you want the whole structured object

Representative patterns:

typescript
findOne(@Arg.query('id') id: number) {}
typescript
class DtoStudentInfo {
  id: number;
  name: string;
}

findOne(@Arg.query() query: DtoStudentInfo) {
  console.log(query.id, query.name);
}

This matters because controller signatures can express both simple and structured request shapes without leaving the framework’s contract surface.

Compact action-signature patterns

A practical controller signature often combines route params, request body typing, and response metadata in one place.

Representative pattern:

typescript
@Web.patch('updateUser/:id')
updateUser(
  @Arg.param('id') id: TableIdentity,
  @Arg.body(v.object(DtoUserUpdate)) user: DtoUserUpdate,
) {
  return this.scope.model.user.update(user, { where: { id } });
}

When the response contract should follow inferred DTO shape directly, a pattern like this is also common:

typescript
@Web.get('getUserDynamic')
@Api.body($Dto.get('test-vona:post'))
getPostDynamic() {}

A practical rule is:

  • use @Arg.param(...) and @Arg.body(...) together when the action mixes route identity and structured payload input
  • use return-type inference when the response contract is obvious
  • use @Api.body(...) when the response should expose a more specific inferred or customized contract shape

Validation, OpenAPI, and Controller AOP

Controllers are strongly connected to three related capabilities:

  • parameter validation based on Zod-oriented patterns
  • Swagger/OpenAPI generation
  • middleware, guards, interceptors, pipes, and filters around the request path

In practice, that means controllers are not only request handlers. They are also a key place where request and response shape become machine-readable for tooling and frontend integration, and where request-path policies are composed through controller AOP.

For a dedicated explanation of middleware, guards, interceptors, pipes, and filters, see Controller AOP Guide.

Response body typing and schema declaration

Vona can often infer response schema from the declared return type.

Representative automatically inferred cases include:

  • basic types such as string, number, and boolean
  • DTO classes
  • Entity classes

When inference is not enough, use explicit schema declaration.

Representative pattern:

typescript
@Api.body(v.array(String))
findOne(): string[] {
  return ['Tom'];
}

A practical rule is:

  • use return-type inference when the contract is obvious and simple
  • use explicit @Api.body(...) when the response shape needs more control or the inference boundary becomes unclear

Response wrapper behavior

By default, Vona wraps the response body in a standard wrapper object.

That means a plain response value conceptually becomes a response shape like:

typescript
{
  code: string;
  message: string;
  data: string;
}

Disable the wrapper

Use @Api.bodyCustom(false) when the endpoint should return the body directly.

Representative pattern:

typescript
@Api.bodyCustom(false)
findOne(): string {
  return 'Tom';
}

Provide a custom wrapper

Use @Api.bodyCustom(...) with a wrapper function when the project needs a different response envelope.

Representative pattern:

typescript
@Api.bodyCustom(bodySchemaWrapperCustom)
findOne(): string {
  return 'Tom';
}

This is one of the most important response-contract choices because it affects backend OpenAPI output and frontend SDK consumption.

Other response/action metadata patterns can also be expressed at the controller surface, including representative cases such as:

  • contentType
  • httpCode
  • headers
  • setHeaders
  • exclude
  • tags

Use those when the runtime behavior and the machine-readable contract should stay aligned in one place.

Action options

Vona actions can carry additional metadata directly in @Web.* options.

Representative pattern:

typescript
@Web.get(':id', {
  tags: ['Student'],
  description: 'Find a student',
})
findOne(@Arg.param('id') id: number): EntityStudent {}

Representative action-option areas include:

  • description
  • summary
  • httpCode
  • contentType
  • bodySchema
  • bodySchemaWrapper
  • exclude
  • tags
  • operationId
  • headers
  • setHeaders

This matters because controller actions are one of the main places where runtime behavior and machine-readable API description meet.

Controller options

Controllers can also carry higher-level options.

Representative pattern:

typescript
@Controller('student', {
  exclude: false,
  tags: ['Student'],
})
class ControllerStudent {}

Representative controller-option areas include:

  • exclude
  • tags
  • actions
  • enable
  • meta

This is especially important because the controller surface can also be tuned from app config through onion/config override patterns.

Relationship to the backend contract loop

Read this guide together with:

A practical split is:

  • controllers define the route and contract surface
  • validation and DTOs define the request/response schema language
  • OpenAPI emits the machine-readable contract
  • CRUD generation instantiates the thread quickly
  • tests verify the contract through action execution

Practical implications for controller implementation

When creating or editing a controller, preserve the Vona controller model instead of rewriting it into a generic framework style.

The safest workflow is:

  1. use the Vona CLI to create the controller skeleton
  2. inspect the generated module-specific patterns
  3. add @Web, @Arg, validation, and OpenAPI metadata in the same style
  4. choose deliberately whether response wrapper defaults should stay in place
  5. verify the resulting routes and response conventions

Released under the MIT License.