Controllers are classes that group HTTP route handlers. They receive a request context, call services, and write a response. Controllers are thin by design — business logic belongs in services.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import { Injectable } from '../core/container.js';
import { Controller, Get, Post, Put, Delete, Validate, ApiOperation } from '../core/decorators.js';
import type { StreetContext } from '../core/context.js';
import { NotFoundException, BadRequestException } from '../http/exceptions.js';
import { ProductService } from '../services/product.service.js';
import type { ValidationSchema } from '../core/types.js';
const createSchema: ValidationSchema = {
body: {
name: { type: 'string', required: true, min: 1, max: 100 },
price: { type: 'number', required: true },
},
};
@Injectable() // Marks class for IoC resolution
@Controller('/api/products') // URL prefix for all routes in this class
export class ProductController {
constructor(
private readonly products: ProductService, // Injected automatically
) {}
@Get('/')
@ApiOperation({ summary: 'List products', tags: ['products'] })
async list(ctx: StreetContext): Promise<void> {
const page = parseInt(ctx.query['page'] ?? '1', 10);
const limit = parseInt(ctx.query['limit'] ?? '20', 10);
const result = await this.products.findAll(page, limit);
ctx.json(result);
}
@Get('/:id')
async getOne(ctx: StreetContext): Promise<void> {
const id = ctx.params['id'];
if (!id) throw new BadRequestException('Missing id');
const product = await this.products.findById(id);
if (!product) throw new NotFoundException(`Product ${id} not found`);
ctx.json(product);
}
@Post('/')
@Validate(createSchema)
async create(ctx: StreetContext): Promise<void> {
const body = ctx.body as { name: string; price: string };
const product = await this.products.create(body);
ctx.json(product, 201);
}
@Delete('/:id')
async remove(ctx: StreetContext): Promise<void> {
const id = ctx.params['id'];
if (!id) throw new BadRequestException('Missing id');
await this.products.remove(id);
ctx.send(204);
}
}
Every handler receives a StreetContext as its only argument. It is the single interface between the HTTP layer and your code.
1
2
3
4
5
6
7
8
9
10
11
12
ctx.method // 'GET' | 'POST' | 'PUT' | 'DELETE' | ...
ctx.path // '/api/products/abc-123'
ctx.params // { id: 'abc-123' }
ctx.query // { page: '2', limit: '10' }
ctx.headers // { 'content-type': 'application/json', 'authorization': 'Bearer ...' }
ctx.body // Parsed JSON object, plain text, or null
ctx.files // ParsedFile[] from multipart uploads
ctx.user // AuthenticatedUser | null (set by auth middleware)
ctx.state // Record<string, unknown> — arbitrary per-request state
ctx.startTime // BigInt nanosecond timestamp — for latency calculation
ctx.req // Raw IncomingMessage (escape hatch)
ctx.res // Raw ServerResponse (escape hatch)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// JSON (sets Content-Type: application/json)
ctx.json({ message: 'ok' });
ctx.json({ error: 'not found' }, 404);
// Text (sets Content-Type: text/plain)
ctx.text('pong');
// HTML (sets Content-Type: text/html)
ctx.html('<h1>Hello</h1>');
// Empty body with status code
ctx.send(204); // No Content
ctx.send(202); // Accepted
// Custom response header
ctx.setHeader('X-Request-Id', '...');
ctx.setHeader('Location', '/api/products/new-id');
// Cookies
ctx.cookie('session') // Read a cookie value → string | undefined
ctx.setCookie('session', blob, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: 86400,
path: '/',
});
// Check if response has been sent (avoid double-write)
if (!ctx.sent) {
ctx.json({ fallback: true });
}
ctx.sent guardctx.sent becomes true after the first call to ctx.json(), ctx.text(), ctx.html(), or ctx.send(). Subsequent calls are silently ignored. This prevents double-response errors in complex middleware pipelines.
Register controllers after all dependencies are in the container:
1
2
3
4
5
6
7
8
// main.ts
container.register(AppConfig, config);
container.register(PgPool, pool);
// Order matters for route conflict resolution
app.registerController(HealthController); // /api/health
app.registerController(UserController); // /api/users
app.registerController(ProductController); // /api/products
registerController throws if the class is not decorated with @Controller.
Always throw, never return an error response directly. The global error handler converts StreetException subclasses to typed JSON responses automatically.
1
2
3
4
5
// ✓ Correct
throw new NotFoundException('Product not found');
// ✗ Avoid — bypasses global error handling, loses type information
ctx.json({ error: 'not found' }, 404);
The only exception is writing non-error responses with specific status codes:
1
2
ctx.json(newProduct, 201); // Created
ctx.send(204); // No Content
Multipart uploads are automatically parsed before the handler runs. Access uploaded files via ctx.files:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Post('/avatar')
async uploadAvatar(ctx: StreetContext): Promise<void> {
if (ctx.files.length === 0) {
throw new BadRequestException('No file provided');
}
const file = ctx.files[0]!;
// File is already on disk — just store the path
await this.userService.setAvatar(ctx.user!.id, {
originalName: file.originalName,
mimeType: file.mimeType,
size: file.size,
path: file.path, // absolute path in uploads directory
});
ctx.json({ uploaded: file.originalName }, 201);
}
See Multipart Uploads for full details.
For Server-Sent Events, write directly to ctx.res using the SSE helper:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { createSse } from '../websocket/sse.js';
@Get('/:id/events')
async events(ctx: StreetContext): Promise<void> {
const sse = createSse(ctx.res, 30_000); // 30s heartbeat
sse.send({ event: 'connected', data: { ts: Date.now() } });
// Keep sending until client disconnects
const interval = setInterval(() => {
if (sse.closed) { clearInterval(interval); return; }
sse.send({ event: 'update', data: { ts: Date.now() } });
}, 5_000);
interval.unref();
ctx.res.once('close', () => clearInterval(interval));
}
See Server-Sent Events for full details.