Skip to main content
The @ooneex/controller component is the HTTP/WebSocket handler layer. A controller is a class with a single index method, bound to a route by a @Route decorator. When a request matches the route, the framework constructs a context — request data, response builder, logger, cache, user, locale, and route metadata — and calls index(context). The handler reads what it needs from context and returns a response. Generics carry your params, payload, queries, and response shapes end to end, so the context is fully typed.

Why this component

  • One method per route. Every controller implements IController — a single typed index(context) handler. No base class to extend, no boilerplate.
  • Typed context. IController<T> and ContextType<T> thread your params, payload, queries, and response types through the whole handler.
  • Rich request context. The handler receives the parsed request, a response builder, the logger, cache, rate limiter, locale, authenticated user, headers, files, IP, host, and matched route metadata.
  • Decorator-bound routing. @Route.get, @Route.post, @Route.socket, and friends bind a class to a path, method, version, validation schema, and roles in one place.
  • Sync or async. index may return an IResponse directly or a Promise<IResponse> — both are supported.
  • HTTP and WebSocket. The same controller shape handles both; socket controllers use a context with an added channel API.

How it works

A request flows through routing into the matched controller and back out as a response. The handler never constructs the context — the framework does, then calls index.
StageWhat happens
MatchThe router matches the request to a route declared by a @Route decorator.
ContextThe framework builds ContextType<T> — request, response, services, user, locale, route metadata.
Handleindex(context) runs your logic, reading context.params, context.payload, context.queries, etc.
RespondThe handler returns context.response.json(...) (or another response); the framework sends it.
The index signature is the whole contract:
interface IController<T extends ContextConfigType = ContextConfigType> {
  index: (
    context: ContextType<T>,
  ) => Promise<IResponse<T["response"]>> | IResponse<T["response"]>;
}
ContextConfigType describes the shape you bind to a route — response is always present, while params, payload, and queries come from the request config and are optional. ContextType<T> is the object passed to index. Its most-used members:
MemberPurpose
requestParsed IRequest — call .params(), .payload(), .queries().
responseResponse builder, e.g. response.json(data).
params / payload / queriesTyped request data taken from your config T.
routeMatched route metadata: name, path, method, version, description, roles.
userAuthenticated IUser or null.
loggerRequest-scoped logger.
cache / rateLimiter / permissionOptional services available on the context.
header / files / ip / host / methodRaw request details.
langDetected locale (LocaleInfoType).
envApplication environment (IAppEnv).

Usage

A controller is a class with one index method, decorated with a @Route method matching the HTTP verb. Define a route type once and pass it to both the decorator config and ContextType<T> so the context is fully typed.
import type { ContextType } from "@ooneex/controller";
import { Route } from "@ooneex/routing";
import { Assert } from "@ooneex/validation";

type UserListRouteType = {
  queries: { page?: string; limit?: string; search?: string };
  response: { data: { id: string; name: string }[]; total: number };
};

@Route.get("/users", {
  name: "user.list",
  version: 1,
  description: "List users with optional filtering and pagination",
  queries: Assert({ page: "string?", limit: "string?", search: "string?" }),
  response: Assert({ data: "object[]", total: "number" }),
  roles: ["ROLE_ADMIN"],
})
export class UserListController {
  constructor(private readonly userService: UserService) {}

  public async index(context: ContextType<UserListRouteType>) {
    const { page, limit, search } = context.request.queries();
    const result = await this.userService.execute({ page, limit, search });

    return context.response.json(result);
  }
}
The @Route object exposes one decorator per HTTP method — get, post, put, patch, delete, options, head — plus socket for WebSocket controllers. Each takes the route path and a config:
Config fieldPurpose
nameUnique route name in dot notation, e.g. user.list.
versionRoute version (number).
descriptionHuman-readable description.
paramsPer-segment validators for /path/:id routes, e.g. { id: new AssertId() }.
payload / queries / responseAssert({...}) schemas for body, query string, and response.
rolesRole strings required to access the route, e.g. ["ROLE_USER"].
A controller with URL params reads them through context.request.params() (validated by the decorator’s params):
import type { ContextType } from "@ooneex/controller";
import { Route } from "@ooneex/routing";
import { Assert, AssertId } from "@ooneex/validation";

type UserDetailRouteType = {
  params: { id: string };
  response: { id: string; name: string; email: string };
};

@Route.get("/users/:id", {
  name: "user.detail",
  version: 1,
  description: "Get user by ID",
  params: { id: new AssertId() },
  response: Assert({ id: "string", name: "string", email: "string" }),
  roles: ["ROLE_USER"],
})
export class UserDetailController {
  constructor(private readonly userService: UserService) {}

  public async index(context: ContextType<UserDetailRouteType>) {
    const { id } = context.request.params();
    const user = await this.userService.execute(id);

    return context.response.json(user);
  }
}
Mutation routes read the body from context.request.payload():
type UserCreateRouteType = {
  payload: { name: string; email: string; password: string };
  response: { id: string; name: string; email: string };
};

@Route.post("/users", {
  name: "user.create",
  version: 1,
  description: "Create a new user account",
  payload: Assert({ name: "string", email: "string.email", password: "8 <= string <= 100" }),
  response: Assert({ id: "string", name: "string", email: "string" }),
  roles: ["ROLE_ADMIN"],
})
export class UserCreateController {
  constructor(private readonly userService: UserService) {}

  public async index(context: ContextType<UserCreateRouteType>) {
    const data = await context.request.payload();
    const user = await this.userService.execute(data);

    return context.response.json(user);
  }
}
The context exposes the authenticated user, logger, and locale directly — read only what the handler needs:
public async index(context: ContextType<UserCreateRouteType>) {
  const { user, logger, lang } = context;

  logger.info("Creating user", { actor: user?.id, lang: lang.code });

  const data = await context.request.payload();
  return context.response.json(await this.userService.execute(data));
}

Socket controllers

WebSocket controllers share the same one-method shape but are bound with @Route.socket and use ContextType from @ooneex/socket, which adds a channel API (channel.send, channel.publish, channel.subscribe, channel.close, channel.ws). The CLI scaffolds either variant — pass --is-socket=true for the socket template.

Best practices

  • Keep controllers thin. Validate and shape the request, delegate the work to an injected service, and return the response. No business logic in index.
  • Define one route type and reuse it. Pass the same type to the @Route config and ContextType<T> so params, payload, queries, and response stay in sync.
  • Include only what the route needs. Add params for path segments, payload for post/put/patch, queries for list/search — always include response.
  • Read through the request methods. Use context.request.params(), .payload(), and .queries() so the decorator’s Assert schemas validate before your code runs.
  • Pick the least-privileged role. Set roles to the lowest role that satisfies the endpoint; access is hierarchical, so a role also grants the roles it inherits.
  • Return responses, don’t construct them. Use the provided context.response builder rather than building raw HTTP responses by hand.
  • Treat optional context as optional. cache, rateLimiter, permission, and user may be absent — guard with ?. and fall back.

CLI command

Scaffold a controller, its route type, and a test file with the generator. It writes the class to modules/<module>/src/controllers/<Name>Controller.ts, registers it in the module, and installs @ooneex/controller if it is missing.
# Interactive: prompts for name, socket, route name/path/method
ooneex controller:create

# HTTP controller with route details
ooneex controller:create --name=UserList --module=user --route-name=user.list --route-path=/users --route-method=get

# Socket controller
ooneex controller:create --name=ChatMessage --module=chat --is-socket=true --route-name=chat.message --route-path=/chat
OptionDescriptionDefault
--nameController class name. Normalized to PascalCase; the Controller suffix is appended automatically.Prompted if omitted
--moduleTarget module the class is generated into.shared
--is-socketGenerate a WebSocket controller instead of HTTP.Prompted if omitted
--route-nameRoute name in dot notation, e.g. user.list.Prompted if omitted
--route-pathRoute path; normalized to kebab-case.Prompted if omitted
--route-methodHTTP method (HTTP controllers only).Prompted if omitted
--overrideOverwrite an existing controller without prompting.false
The generated class is a typed stub — a route type, a @Route decorator with empty Assert schemas, and an index that returns an empty JSON response, ready for you to fill in:
import type { ContextType } from "@ooneex/controller";
import { Route } from "@ooneex/routing";
import { Assert } from "@ooneex/validation";

export type UserListRouteType = {
  params: {

  },
  payload: {

  },
  queries: {

  },
  response: {

  },
};

@Route.get("/users", {
  name: "user.list",
  version: 1,
  description: "",
  params: {
    // id: Assert("string"),
  },
  payload: Assert({

  }),
  queries: Assert({

  }),
  response: Assert({

  }),
  roles: ["ROLE_USER"],
})
export class UserListController {
  public async index(context: ContextType<UserListRouteType>) {
    // const { id } = context.params;

    return context.response.json({

    });
  }
}
See controller:create for the full command reference.

Use with Claude and Codex

The generator ships a matching controller:create skill. It runs the scaffold and then guides your AI agent through completing the controller — filling in the route type, validation schemas, roles, and the index handler, and delegating logic to a service. Initialize the skills once for your agent:
ooneex claude:init
Then ask Claude in natural language — it maps the request to the generator, runs it, and fills in the implementation:
Prompt
Create a controller that returns the current user's profile.
For example, the prompt above maps to a controller:create run with an inferred name, route, and method, then implements the index handler against the user’s profile service.