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
npm run vona :create:bean controller student -- --module=demo-studentController definition
Representative pattern:
@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:
Route Path = GlobalPrefix + Module Url + Controller Path + Action PathImportant pieces:
GlobalPrefix: from project config, commonly/apiModule 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:
findOne(@Arg.query('id') id: number) {}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:
@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:
@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, andboolean - DTO classes
- Entity classes
When inference is not enough, use explicit schema declaration.
Representative pattern:
@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:
{
code: string;
message: string;
data: string;
}Disable the wrapper
Use @Api.bodyCustom(false) when the endpoint should return the body directly.
Representative pattern:
@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:
@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:
contentTypehttpCodeheaderssetHeadersexcludetags
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:
@Web.get(':id', {
tags: ['Student'],
description: 'Find a student',
})
findOne(@Arg.param('id') id: number): EntityStudent {}Representative action-option areas include:
descriptionsummaryhttpCodecontentTypebodySchemabodySchemaWrapperexcludetagsoperationIdheaderssetHeaders
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:
@Controller('student', {
exclude: false,
tags: ['Student'],
})
class ControllerStudent {}Representative controller-option areas include:
excludetagsactionsenablemeta
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:
- use the Vona CLI to create the controller skeleton
- inspect the generated module-specific patterns
- add
@Web,@Arg, validation, and OpenAPI metadata in the same style - choose deliberately whether response wrapper defaults should stay in place
- verify the resulting routes and response conventions