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) |
name | string | Name prefix for routes in this controller (used for URL generation) |
domain | string | Domain pattern to restrict this controller to (e.g., '{tenant}.myapp.com'). See Domain Routing. |
@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 StreamError() }, 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.
CLI: Testing routes
Section titled “CLI: Testing routes”Stratal includes built-in Quarry CLI commands for inspecting and calling your routes.
Listing routes
Section titled “Listing routes”Use route:list to see all registered routes:
npx quarry route:listThis outputs a table with the following columns:
| Column | Description |
|---|---|
| Method | HTTP method (GET, POST, etc.) or WS for WebSocket routes |
| Path | The full route path |
| Action | The handler name (controller method) |
| Type | HTTP or WS |
Filter by method or path:
# Only GET routesnpx quarry route:list --method GET
# Routes containing "users"npx quarry route:list --path users
# Combine filtersnpx quarry route:list --method POST --path /api/adminCalling routes directly
Section titled “Calling routes directly”Use the api command to call any route from the terminal:
# GET request (default)npx quarry api /api/users
# POST with JSON bodynpx quarry api /api/users --method POST --data '{"name":"Alice","email":"alice@example.com"}'
# With custom headers and query parametersnpx quarry api /api/users --header "Authorization:Bearer token123" --query "page=2" --query "limit=10"| Option | Description |
|---|---|
--method | HTTP method (defaults to GET) |
--data | JSON request body |
--header | Header as Key:Value (repeatable) |
--query | Query parameter as key=value (repeatable) |
The response displays the status, headers, and formatted body.
Route groups and the fluent router API
Section titled “Route groups and the fluent router API”Modules can implement the RouteConfigurable interface to configure routing with a fluent builder API. This gives you control over route prefixes, domain patterns, middleware, versioning, and grouping — all scoped to the module’s controllers.
import { Module, type RouteConfigurable, type Router } from 'stratal/module'import { AuthMiddleware } from './auth.middleware'
@Module({ controllers: [UsersController, AdminController],})export class ApiModule implements RouteConfigurable { configureRoutes(router: Router) { router .name('api.') .middleware(AuthMiddleware) .group([UsersController], (r) => { r.prefix('/users').name('users.') }) .group([AdminController], (r) => { r.prefix('/admin').name('admin.').hideFromDocs() }) }}Fluent methods
Section titled “Fluent methods”| Method | Description |
|---|---|
prefix(path, params?) | Set a path prefix for all routes. Optionally pass a Zod schema to validate path parameters. |
domain(pattern) | Restrict routes to a domain pattern (e.g., '{tenant}.myapp.com'). See Domain Routing. |
name(prefix) | Add a name prefix to all routes (e.g., 'api.' makes index become 'api.index'). |
middleware(...classes) | Apply middleware to all routes in scope. |
version(v) | Set the API version (string or string array). |
hideFromDocs(hide?) | Hide routes from the OpenAPI spec. |
use(...classes) | Register global middleware (root router only). |
group(controllers, callback) | Create a sub-group with its own prefix, name, middleware, etc. |
Named routes and URL generation
Section titled “Named routes and URL generation”When routes have names (from router.name() or @Route({ name: '...' })), you can generate URLs using ctx.route():
@Controller('/users')export class UsersController implements IController { @Route({ name: 'users.show' }) show(ctx: RouterContext) { const id = ctx.param('id') return ctx.json({ id }) }
@Route() index(ctx: RouterContext) { // Generate a URL to the show route const url = ctx.route('users.show', { id: '42' }) // → '/users/42' return ctx.json({ users: [], links: { detail: url } }) }}Pass { absolute: true } to generate a full URL (scheme + host) using the current request’s origin:
const url = ctx.route('users.show', { id: '42' }, { absolute: true })// → 'https://example.com/users/42'For type-safe route names, generate types with the CLI:
npx quarry route:typesThis creates a src/stratal.d.ts file that provides autocomplete for route names and validates parameters.
Outside a request context
Section titled “Outside a request context”Use the standalone route() function or inject the Uri service when you need URL generation from a queue consumer, cron job, or service that doesn’t receive a RouterContext:
import { route } from 'stratal/router'
const url = route('users.show', { id: '42' })The Uri service exposes the same primitives plus request-aware helpers like current(), full(), previous(), to(), query(), and sticky parameter defaults() — useful when you want to share a default like locale across many route() calls in a single request. Resolve it from the request container:
import { ROUTER_TOKENS, type Uri } from 'stratal/router'
const uri = ctx.getContainer().resolve<Uri>(ROUTER_TOKENS.Uri)uri.defaults({ locale: 'en' })uri.route('posts.index') // auto-fills :locale paramTrailing slashes
Section titled “Trailing slashes”By default Stratal accepts both /users and /users/ for the same route. To enforce a canonical form, pass a trailingSlash option to the Stratal constructor:
import 'reflect-metadata'import { Stratal } from 'stratal'import { AppModule } from './app.module'
export default new Stratal({ module: AppModule, trailingSlash: 'always', // or 'never' | 'ignore'})| Mode | Behaviour |
|---|---|
'ignore' (default) | Both /users and /users/ match. URL generation leaves paths as-is. |
'always' | Requests without a trailing slash redirect (308) to the trailing-slash form. URL generation appends /. |
'never' | Requests with a trailing slash redirect (308) to the non-trailing form. URL generation strips /. |
The redirect uses HTTP 308 so request bodies survive on POST, PUT, and PATCH. Paths whose final segment contains a dot (e.g. /openapi.json) and the root / are always passed through unchanged.
ctx.route(), the standalone route() function, and the Uri service all apply the configured mode automatically, so generated URLs stay consistent with the redirect behaviour.
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.
- Domain Routing for routing by subdomain patterns.
- Signed URLs for generating tamper-proof links.
- Streaming Responses for streaming data to clients.
- OpenAPI overview to see how
@Routeschemas generate API documentation.