Controllers
Controllers handle HTTP requests and return responses - they're your API endpoints.
Deep Dive: See Controllers Reference for advanced patterns.
Creating a Controller
Extend BaseController and use decorators to define routes:
import { BaseController, controller, get, jsonResponse, z, HTTP } from '@venizia/ignis';
import { Context } from 'hono';
@controller({ path: '/users' })
export class UserController extends BaseController {
constructor() {
// It's good practice to pass a scope for logging
super({ scope: UserController.name, path: '/users' });
}
@get({
configs: {
path: '/',
responses: jsonResponse({
description: 'A list of users',
schema: z.array(z.object({ id: z.string(), name: z.string() })),
}),
},
})
getAllUsers(c: Context) {
return c.json([{ id: '1', name: 'John Doe' }], HTTP.ResultCodes.RS_2.Ok);
}
}Notice that the binding() method is no longer needed when using decorators.
Controller Lifecycle
Controllers have a simple and predictable lifecycle managed by the application.
| Stage | Method | Description |
|---|---|---|
| 1. Instantiation | constructor(opts) | The controller is created by the DI container. Dependencies are injected, and you call super() to initialize the internal Hono router. |
| 2. Configuration | registerControllers phase | The application automatically discovers and registers all routes defined with decorators (@get, @post, etc.) on your controller methods. If you have routes defined manually inside binding(), that method is also called during this phase. Note: If you exclusively use decorators for routing, the binding() method can be omitted from your controller. |
Defining Routes with Decorators (Recommended)
The recommended way to define routes is by using decorators directly on the controller methods that handle them. This approach is more declarative, keeps your code organized, and provides full type safety for your request parameters, query, body, and even the response.
Type Safety without Boilerplate
For decorator-based routes, you do not need to explicitly annotate the return type with TRouteResponse. TypeScript will automatically infer and validate the return type against the OpenAPI response schema you define in your configs. This gives you full type safety with less code.
HTTP Method Decorators
Ignis provides a decorator for each common HTTP method:
@get(opts)@post(opts)@put(opts)@patch(opts)@del(opts)@api(opts)(a generic decorator where you specify the method in theconfigs)
The opts object contains a configs property that defines the route's path, request validation, and OpenAPI response schemas.
Example using decorators with ROUTE_CONFIGS and full type inference:
For optimal organization and type safety, define your route configurations in a constant with as const. This allows TypeScript to precisely infer the types for your request data and expected responses within your handler methods.
import { BaseController, controller, get, post, HTTP, jsonContent, jsonResponse, TRouteContext } from '@venizia/ignis';
import { z } from '@hono/zod-openapi';
// Define route configs as const for type inference
const TEST_ROUTES = {
getData: {
method: HTTP.Methods.GET,
path: '/',
responses: jsonResponse({
description: 'A simple message',
schema: z.object({ message: z.string(), method: z.string() }),
}),
},
createItem: {
method: HTTP.Methods.POST,
path: '/',
request: {
body: jsonContent({
description: 'Request body for creating an item',
schema: z.object({ name: z.string(), value: z.number().int().positive() }),
}),
},
responses: jsonResponse({
description: 'Created item',
schema: z.object({ id: z.string(), name: z.string(), value: z.number() }),
}),
},
} as const; // Crucial for strict type inference!
@controller({ path: '/my-items' })
export class MyItemsController extends BaseController {
constructor() {
super({ scope: MyItemsController.name, path: '/my-items' });
}
@get({ configs: TEST_ROUTES.getData })
getData(c: TRouteContext<typeof TEST_ROUTES.getData>) { // Return type is automatically inferred
// 'c' is fully typed here, including c.req.valid and c.json return type
return c.json({ message: 'Hello from decorator', method: 'GET' }, HTTP.ResultCodes.RS_2.Ok);
}
@post({ configs: TEST_ROUTES.createItem })
createItem(c: TRouteContext<typeof TEST_ROUTES.createItem>) { // Return type is automatically inferred
// c.req.valid('json') is automatically typed based on createItem.request.body.content['application/json'].schema
const body = c.req.valid('json');
// Return type is automatically validated against createItem.responses[200].content['application/json'].schema
return c.json(
{
id: 'some-uuid',
name: body.name,
value: body.value,
},
HTTP.ResultCodes.RS_2.Ok,
);
}
}Manual Route Definition: An Alternative Approach
While decorators are the recommended approach for most use cases, Ignis also provides a manual way to define routes within the controller's binding() method.
Decorator vs Manual: Quick Comparison
| Aspect | Decorators (@get, @post) | Manual (defineRoute, bindRoute) |
|---|---|---|
| Syntax | Clean, declarative | More verbose |
| Organization | Route config near the handler | Can be in separate files |
| Use Case | Most standard CRUD endpoints | Dynamic route generation |
| Type Safety | Full inference with TRouteContext | Full inference with TRouteContext |
| Readability | High - easy to scan | Medium - requires scrolling |
| Best For | 90% of use cases | Advanced scenarios |
When to Use Manual Route Definition
Manual route definition is useful for:
- Dynamically generating routes based on configuration (e.g., loading routes from a database)
- Organizing all routes for a controller within a single method if you prefer centralized route declarations
- Conditional route registration (e.g., enabling/disabling routes based on feature flags)
- Developers who prefer non-decorator syntax (coming from Express/Fastify)
When using this method, you will override the binding() method in your controller and use this.defineRoute or this.bindRoute to register your endpoints.
defineRoute
Use this method for defining a single API endpoint with all its configurations and handler. It also benefits from type inference when used with TRouteContext.
import { Authentication, HTTP, jsonResponse, z, TRouteContext } from '@venizia/ignis';
// ... inside the binding() method
const GetUsersRoute = {
path: '/',
method: 'get',
authStrategies: [Authentication.STRATEGY_JWT],
responses: jsonResponse({
description: 'List of all users',
schema: z.array(z.object({ id: z.number(), name: z.string() })),
}),
} as const;
this.defineRoute({
configs: GetUsersRoute,
handler: (c: TRouteContext<typeof GetUsersRoute>) => { // Return type is automatically inferred
return c.json([{ id: 1, name: 'John Doe' }], HTTP.ResultCodes.RS_2.Ok);
},
});bindRoute
This method offers a fluent API for defining routes, similar to defineRoute, but structured for chaining. It also benefits from TRouteContext for type safety.
import { jsonResponse, z, TRouteContext, HTTP } from '@venizia/ignis';
// ... inside the binding() method
const GetUserByIdRoute = {
path: '/:id',
method: 'get',
responses: jsonResponse({
description: 'A single user',
schema: z.object({ id: z.string(), name: z.string() }),
}),
} as const;
this.bindRoute({
configs: GetUserByIdRoute,
}).to({
handler: (c: TRouteContext<typeof GetUserByIdRoute>) => { // Return type is automatically inferred
const { id } = c.req.param();
return c.json({ id: id, name: 'John Doe' }, HTTP.ResultCodes.RS_2.Ok);
},
});ControllerFactory for CRUD Operations
For standard CRUD (Create, Read, Update, Delete) operations, Ignis provides a ControllerFactory that can generate a full-featured controller for any given entity. This significantly reduces boilerplate code.
// src/controllers/configuration.controller.ts (Example from @examples/vert)
import { Configuration } from '@/models';
import { ConfigurationRepository } from '@/repositories';
import {
BindingKeys,
BindingNamespaces,
controller,
ControllerFactory,
inject,
} from '@venizia/ignis';
const BASE_PATH = '/configurations';
const _Controller = ControllerFactory.defineCrudController({
repository: { name: ConfigurationRepository.name },
controller: {
name: 'ConfigurationController',
basePath: BASE_PATH,
isStrict: true,
},
entity: () => Configuration, // Provide a resolver for your entity class
});
@controller({ path: BASE_PATH })
export class ConfigurationController extends _Controller {
constructor(
@inject({
key: BindingKeys.build({
namespace: BindingNamespaces.REPOSITORY,
key: ConfigurationRepository.name,
}),
})
repository: ConfigurationRepository,
) {
super(repository); // The generated controller expects the repository in its constructor
}
}The ControllerFactory.defineCrudController method automatically sets up the following routes based on your entity schema:
| Route Name | Method | Path | Description |
|---|---|---|---|
count | GET | /count | Get the number of records matching a filter. |
find | GET | / | Retrieve all records matching a filter. |
findById | GET | /:id | Retrieve a single record by its ID. |
findOne | GET | /find-one | Retrieve a single record matching a filter. |
create | POST | / | Create a new record. |
updateById | PATCH | /:id | Update a single record by its ID. |
updateBy | PATCH | / | Update multiple records matching a where filter. |
deleteById | DELETE | /:id | Delete a single record by its ID. |
deleteBy | DELETE | / | Delete multiple records matching a where filter. |
Customization
The ControllerFactory is highly customizable. You can override the Zod schemas for any of the generated routes to add, remove, or modify fields for request validation and response shapes. You can also configure other behaviors, like making delete operations return the deleted records.
For a full list of customization options, see the Deep Dive: ControllerFactory documentation.
defineJSXRoute (Server-Side Rendered HTML)
Use this method for routes that render HTML pages using JSX components. This is perfect for building server-side rendered web applications.
import { htmlResponse } from '@venizia/ignis';
// ... inside the binding() method
this.defineJSXRoute({
configs: {
path: '/',
method: 'get',
description: 'Home page',
responses: htmlResponse({
description: 'HTML home page',
}),
},
handler: (c) => {
const user = { name: 'John Doe' };
return c.html(<HomePage user={user} />);
},
});Key Points:
- The handler must return
c.html()with a JSX component - JSX routes automatically include HTML content-type in OpenAPI documentation
- Views are typically organized in
src/views/directory - Components can be reused across multiple pages
Example View Component:
// src/views/pages/home.page.tsx
import type { FC } from '@venizia/ignis';
import { MainLayout } from '../layouts/main.layout';
interface HomePageProps {
user: { name: string };
}
export const HomePage: FC<HomePageProps> = ({ user }) => {
return (
<MainLayout title="Home">
<h1>Welcome, {user.name}!</h1>
<p>This page is rendered on the server using JSX.</p>
</MainLayout>
);
};Example Layout Component:
// src/views/layouts/main.layout.tsx
import type { FC, PropsWithChildren } from '@venizia/ignis';
interface MainLayoutProps {
title: string;
}
export const MainLayout: FC<PropsWithChildren<MainLayoutProps>> = ({ title, children }) => {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</header>
<main>{children}</main>
<footer>
<p>© 2025 My App</p>
</footer>
</body>
</html>
);
};Note: JSX support in
Ignisuses Hono's built-in JSX runtime. Make sure yourtsconfig.jsonincludes the JSX configuration (this is already set up in the framework's base configuration).
Accessing Validated Request Data
When you define Zod schemas in your route's request configuration (whether with decorators or manual definition), Hono's validation middleware automatically parses and validates the incoming data. You can access this validated data using c.req.valid().
import { z } from '@hono/zod-openapi';
import { jsonContent, put, HTTP } from '@venizia/ignis';
// ... inside a controller class
const UserSchema = z.object({ name: z.string(), email: z.string().email() });
@put({
configs: {
path: '/:id',
method: 'put',
request: {
params: z.object({ id: z.string() }),
query: z.object({ notify: z.string().optional() }),
body: jsonContent({ schema: UserSchema }),
},
// ... responses
},
})
updateUser(c: Context) {
// Access validated data from the request
const { id } = c.req.valid('param');
const { notify } = c.req.valid('query');
const userUpdateData = c.req.valid('json'); // for body
console.log(`Updating user ${id} with data:`, userUpdateData);
if (notify) {
console.log('Notification is enabled.');
}
return c.json({ success: true, id, ...userUpdateData }, HTTP.ResultCodes.RS_2.Ok);
}Using c.req.valid() is the recommended way to access request data as it ensures that the data conforms to the schema you've defined. For even better type safety, you can use the TRouteContext utility type.
import { z } from '@hono/zod-openapi';
import { jsonContent, put, TRouteContext, HTTP } from '@venizia/ignis';
// ... inside a controller class
const updateUserConfig = {
path: '/:id',
method: 'put',
request: {
params: z.object({ id: z.string() }),
query: z.object({ notify: z.string().optional() }),
body: jsonContent({
schema: z.object({ name: z.string(), email: z.string().email() }),
}),
},
// ... responses
} as const; // Use 'as const' for strict type inference
@put({ configs: updateUserConfig })
updateUser(c: TRouteContext<typeof updateUserConfig>) {
// Access validated data from the request
const { id } = c.req.valid('param');
const { notify } = c.req.valid('query');
const userUpdateData = c.req.valid('json'); // for body
console.log(`Updating user ${id} with data:`, userUpdateData);
if (notify) {
console.log('Notification is enabled.');
}
return c.json({ success: true, id, ...userUpdateData }, HTTP.ResultCodes.RS_2.Ok);
}Using TRouteContext provides full type inference for c.req.valid(), so your editor will know that id is a string, notify is an optional string, and userUpdateData matches the body schema.