Controllers#
Controllers organize routes into class-based structures using Stage 3 TC39 decorators. They provide a clean way to group related handlers with shared middleware and dependency injection.
Basic Controller#
Use @Controller to define a route prefix, then @Get, @Post, etc. to register methods:
import { Controller, Get, Post } from "@buntok/core";
import type { Context } from "@buntok/core";
@Controller("/users")
class UserController {
@Get("/")
list(ctx: Context) {
return ctx.json([{ id: 1, name: "John" }]);
}
@Get("/:id")
detail(ctx: Context) {
const { id } = ctx.params;
return ctx.json({ id, name: "John" });
}
@Post("/")
async create(ctx: Context) {
const body = await ctx.body();
return ctx.json({ created: true, ...body }, 201);
}
}@Controller must be the outermost (topmost) decorator on the class. Stage 3 decorators are evaluated top-to-bottom but applied bottom-to-top, so @Controller (evaluated first, applied last) captures all method routes.Register Controllers#
Use app.registerController() to register controller classes. It accepts a single class/instance or an array:
const app = new Buntok();
// Register single controller
app.registerController(UserController);
// Register multiple controllers using array (Overload approach)
app.registerController([UserController, PostController]);
// Register multiple individually
app.registerController(CommentController);
app.registerController(AdminController);
// Works with RouterGroup too
const api = app.group("/api/v1");
api.registerController([UserController, PostController]);
// Start server
app.listen(1212);HTTP Method Decorators#
| Decorator | HTTP Method |
|---|---|
| @Get(path) | GET |
| @Post(path) | POST |
| @Put(path) | PUT |
| @Patch(path) | PATCH |
| @Delete(path) | DELETE |
| @Options(path) | OPTIONS |
| @Head(path) | HEAD |
| @All(path) | All standard methods |
| @Query(path) | QUERY (Bun-specific) |
Middleware#
Route-Level Middleware#
Use @Use() to attach middleware to a single route. Multiple @Use() calls stack in declaration order.
import { Controller, Get, Use } from "@buntok/core";
import { zValidator } from "@buntok/core/middlewares/validator";
import type { Context, ZodCtx } from "@buntok/core";
const auth = async (ctx: Context, next) => {
const token = ctx.getCookie("token");
if (!token) return ctx.json({ error: "Unauthorized" }, 401);
return next();
};
@Controller("/users")
class UserController {
@Get("/public")
publicEndpoint(ctx: Context) {
return ctx.json({ data: "public" });
}
@Get("/private")
@Use(auth)
privateEndpoint(ctx: Context) {
return ctx.json({ data: "private" });
}
@Get("/validated")
@Use(zValidator("query", paginationSchema))
validatedEndpoint(ctx: ZodCtx<{ query: typeof paginationSchema }>) {
const { page, limit } = ctx.valid("query");
return ctx.json({ page, limit });
}
}Guards#
Use @UseGuard() for simple boolean authorization checks. If any guard returns false, the request is rejected with 403 Forbidden.
import { Controller, Get, UseGuard } from "@buntok/core";
import type { Context } from "@buntok/core";
const isAdmin = (ctx: Context) => ctx.user?.role === "admin";
const isActive = (ctx: Context) => ctx.user?.status === "active";
@Controller("/admin")
class AdminController {
@Get("/dashboard")
@UseGuard(isAdmin, isActive)
dashboard(ctx: Context) {
return ctx.json({ secret: "admin data" });
}
}@UseGuard is syntactic sugar for @Use that returns 403 on failure.Controllers with Groups#
Controllers can be registered inside route groups. The group prefix is prepended to the controller prefix, and group middleware runs after route-level @Use() middleware.
const api = app.group("/api/v1");
api.use(cors);
api.use(authMiddleware);
// UserController has @Controller("/users")
// Final routes: GET /api/v1/users, POST /api/v1/users, etc.
api.registerController(UserController);
// Execution order for GET /api/v1/users:
// @Use middlewares → cors → authMiddleware → handlerInheritance#
Controllers support class inheritance. Child routes override parent routes with the same method name.
import type { Context } from "@buntok/core";
@Controller("/base")
class Base {
@Get("/health")
health(ctx: Context) {
return ctx.json({ ok: true });
}
@Get("/info")
info(ctx: Context) {
return ctx.json({ version: "1.0" });
}
}
@Controller("/users")
class UserController extends Base {
@Get("/")
list(ctx: Context) {
return ctx.json([]);
}
// Override parent's /health route
@Get("/health")
customHealth(ctx: Context) {
return ctx.json({ status: "custom" });
}
}
// Routes:
// GET /users → list
// GET /users/health → customHealth (overrides parent)
// GET /users/info → parent's infoBase Controller (CRUD Scaffold)#
Extend BaseController to get a full CRUD scaffold with pre-built routes:
import { BaseController } from "@buntok/core";
interface User {
id: number;
name: string;
email: string;
}
interface CreateUser {
name: string;
email: string;
}
@Controller("/users")
class UserController extends BaseController<User, CreateUser, Partial<User>> {
constructor(service: UserService) {
super(service);
}
}
// Pre-built routes:
// GET / → service.getAll()
// GET /:id → service.getById(id)
// POST / → service.create(body)
// PUT /:id → service.update(id, body)
// DELETE /:id → service.delete(id)Overriding Methods#
import type { Context } from "@buntok/core";
@Controller("/users")
class UserController extends BaseController<User, CreateUser> {
constructor(private service: UserService) {
super(service);
}
// Override the default getAll behavior
@Get("/")
override async list(ctx: Context) {
const users = await this.service.findAllWithRoles();
return ctx.success(users);
}
// Override parseId for UUID-based IDs
protected override parseId(id: string): string {
return id; // Keep as string, don't convert to number
}
}Dependency Injection#
Controllers work with the IoC container. Use @Dependencies to declare constructor dependencies, then let the container resolve them automatically.
Recommended: @Dependencies + container.scan()
This is the pattern generated by buntok create. The @Dependencies decorator declares what the controller needs, and container.scan() resolves the entire dependency tree automatically:
import { Container, Dependencies, Controller, Get } from "@buntok/core";
import type { Context } from "@buntok/core";
class UserService {
async findAll() {
return await db.user.findMany();
}
}
@Dependencies(UserService)
@Controller("/users")
class UserController {
constructor(private readonly userService: UserService) {}
@Get("/")
async list(ctx: Context) {
const users = await this.userService.findAll();
return ctx.json(users);
}
}
// Auto-resolve entire dependency tree
const container = new Container();
container.scan([UserController]);
app.setContainer(container);
app.registerController(UserController);container.scan() recursively resolves all @Dependencies chains. If UserService depends on UserRepository, the container resolves the full chain automatically.Manual: useFactory
For more control, register dependencies manually with useFactory:
const container = new Container();
container.register(UserService, { useClass: UserService });
container.register(UserController, {
useFactory: (c) => new UserController(c.resolve(UserService)),
});
app.setContainer(container);
app.registerController(container.resolve(UserController));app.setContainer(), controllers are instantiated with new Controller(). Use Container for constructor injection.How It Works#
Decorators execute once at class-definition time (bootstrap), not per request:
@Get,@Post, etc. push route entries to a temporary array@Useattaches middleware to the pending route entry@Controllercaptures all entries and stores them in aWeakMapapp.registerController()reads the metadata and callsapp.registerRoute()for each method
At runtime, the router sees no difference between decorator-based and functional route registrations. Both converge to the same registerRoute() call.
Next Steps#
- Decorators - Full decorator reference
- IoC Container - Dependency injection
- Middleware - Middleware patterns
- Routing - Functional routing