Convention-based Routing
Controllers are where you handle HTTP requests. Each controller is a class decorated with @Controller that defines one or more route handler methods. Stratal offers two routing approaches: convention-based routing (covered on this page), where method names map directly to HTTP verbs and paths, and HTTP method decorators, where you use explicit @Get, @Post, etc. decorators for full control over paths and methods.
Create a controller
Section titled “Create a controller”Decorate a class with @Controller and implement the IController interface:
import { Controller, IController, Route, RouterContext } from 'stratal/router'import { z } from 'stratal/validation'
@Controller('/api/users')export class UsersController implements IController { @Route({ response: z.object({ users: z.array(z.object({ id: z.string(), name: z.string() })), }), }) index(ctx: RouterContext) { return ctx.json({ users: [] }) }}The @Controller decorator takes two arguments:
route(required) — the base path for all routes in this controller.options(optional) — an object with:
| Option | Type | Description |
|---|---|---|
tags | string[] | OpenAPI tags applied to every route in the controller |
security | SecurityScheme[] | Security schemes applied to every route |
hideFromDocs | boolean | Exclude all routes from the OpenAPI specification |
version | string | string[] | typeof VERSION_NEUTRAL | API version(s) for this controller (see Versioning) |
@Controller('/api/admin', { tags: ['Admin'], security: ['bearerAuth'], hideFromDocs: true,})export class AdminController implements IController { // ...}Convention-based routing
Section titled “Convention-based routing”Stratal maps method names on the IController interface to HTTP verbs and paths automatically. You only implement the methods you need:
| Method | HTTP verb | Path | Default status code |
|---|---|---|---|
index() | GET | /route | 200 |
show() | GET | /route/:id | 200 |
create() | POST | /route | 201 |
update() | PUT | /route/:id | 200 |
patch() | PATCH | /route/:id | 200 |
destroy() | DELETE | /route/:id | 200 |
For a controller at /api/users, defining show() automatically registers GET /api/users/:id. Defining create() registers POST /api/users with a 201 status code.
The @Route decorator
Section titled “The @Route decorator”The @Route decorator configures individual route methods. It accepts a RouteConfig object:
import { Controller, IController, Route, RouterContext } from 'stratal/router'import { z } from 'stratal/validation'
@Controller('/api/users')export class UsersController implements IController { @Route({ body: z.object({ name: z.string().min(1), email: z.string().email(), }), response: z.object({ id: z.string(), name: z.string(), email: z.string(), }), summary: 'Create a new user', description: 'Registers a new user account', tags: ['Users'], }) create(ctx: RouterContext) { // body is validated before this method runs // ... }
@Route({ params: z.object({ id: z.string().uuid() }), response: z.object({ id: z.string(), name: z.string(), }), }) show(ctx: RouterContext) { const id = ctx.param('id') // ... }}The full RouteConfig interface:
| Property | Type | Description |
|---|---|---|
body | RouteBody | Validates the request body. Accepts a Zod schema (defaults to application/json) or a { schema, contentType? } object for custom content types. |
params | ZodObject | Validates path parameters |
query | ZodObject | Validates query string parameters |
response | RouteResponse | Schema for the response body. Accepts a Zod schema (defaults to application/json) or a { schema, description?, contentType? } object. |
tags | string[] | OpenAPI tags for this route |
security | SecurityScheme[] | Security schemes for this route |
summary | string | Short OpenAPI summary |
description | string | Detailed OpenAPI description |
hideFromDocs | boolean | Exclude this route from the OpenAPI spec |
statusCode | number | Override the default success status code |
RouterContext
Section titled “RouterContext”Every route handler receives a RouterContext instance as its first argument. It provides methods to read the request and build the response:
Reading the request
Section titled “Reading the request”// Path parameterconst id = ctx.param('id')
// Query string parameterconst page = ctx.query('page')
// All query parameters as an objectconst filters = ctx.query()
// Request headerconst auth = ctx.header('Authorization')
// Parsed request bodyconst data = await ctx.body<CreateUserDto>()Building the response
Section titled “Building the response”// JSON responsereturn ctx.json({ id: '1', name: 'Alice' })
// JSON response with custom statusreturn ctx.json({ id: '1' }, 201)
// Plain textreturn ctx.text('OK')
// HTMLreturn ctx.html('<h1>Hello</h1>')
// Redirectreturn ctx.redirect('/api/users/1')return ctx.redirect('/login', 302)Streaming responses
Section titled “Streaming responses”RouterContext provides three methods for streaming data to the client:
stream(callback, onError?) — Binary / generic streaming
Section titled “stream(callback, onError?) — Binary / generic streaming”@Get('/download', { response: { schema: z.any(), contentType: 'application/octet-stream' } })download(ctx: RouterContext) { return ctx.stream(async (stream) => { await stream.write(new Uint8Array([72, 101, 108, 108, 111])) })}streamText(callback, onError?) — Text streaming
Section titled “streamText(callback, onError?) — Text streaming”@Get('/text', { response: { schema: z.any(), contentType: 'text/plain' } })text(ctx: RouterContext) { return ctx.streamText(async (stream) => { await stream.write('hello ') await stream.write('world') })}streamSSE(callback, onError?) — Server-Sent Events
Section titled “streamSSE(callback, onError?) — Server-Sent Events”@Get('/events', { response: { schema: z.any(), contentType: 'text/event-stream' } })events(ctx: RouterContext) { return ctx.streamSSE(async (stream) => { await stream.writeSSE({ data: 'hello', event: 'message', id: '1' }) })}Error handling
Section titled “Error handling”All three methods accept an optional onError callback that is invoked if the streaming function throws:
return ctx.stream( () => { throw new Error('stream error') }, async (err, stream) => { await stream.writeln(err.message) await stream.close() })Locale and container access
Section titled “Locale and container access”// Get and set the request localeconst locale = ctx.getLocale()ctx.setLocale('fr')
// Access the DI container for the current requestconst container = ctx.getContainer()const service = container.resolve(MyService)The handle() escape hatch
Section titled “The handle() escape hatch”If the six convention-based methods do not fit your needs, you can implement handle() for full control over routing. When handle() is defined, it receives all requests that match the controller’s base path and Stratal skips convention-based routing for that controller.
import { Controller, IController, RouterContext } from 'stratal/router'
@Controller('/api/health')export class HealthController implements IController { handle(ctx: RouterContext) { return ctx.json({ status: 'ok' }) }}Guards on routes
Section titled “Guards on routes”Guards control whether a request is allowed to proceed. Apply them at the controller level or on individual methods using the @UseGuards decorator:
import { Controller, IController, UseGuards, Route, RouterContext } from 'stratal/router'import { AuthGuard } from './auth.guard'import { RolesGuard } from './roles.guard'
@Controller('/api/orders')@UseGuards(AuthGuard) // applies to all methodsexport class OrdersController implements IController { @Route({ response: orderListSchema }) index(ctx: RouterContext) { // AuthGuard runs before this method return ctx.json({ orders: [] }) }
@UseGuards(RolesGuard) // applies only to this method (in addition to controller guards) @Route({ response: orderSchema }) destroy(ctx: RouterContext) { // AuthGuard and RolesGuard both run before this method // ... }}Controller-level guards run first, followed by method-level guards. If any guard returns false, Stratal rejects the request before the handler executes.
For a complete guide on writing guards, see the Guards guide.
Next steps
Section titled “Next steps”- HTTP Method Decorators for explicit
@Get,@Post, etc. when you need custom paths or non-CRUD endpoints. - Versioning to add URI-based API versioning to your controllers.
- Modules to learn how controllers are registered in modules.
- Dependency Injection to inject services into your controllers.
- Guards guide for writing custom guards.
- OpenAPI overview to see how
@Routeschemas generate API documentation.