Skip to content

Controllers

Controllers are the fundamental building blocks of the API presentation layer. In the Model-View-Controller (MVC) architectural pattern, they are responsible for handling incoming HTTP Requests and returning appropriate HTTP Responses.

A Controller's primary function is to act as a traffic director. It acts as the interface between the external world (HTTP clients) and the internal domain logic (Services). They bind specific URL paths to handler functions, validating input data before delegating business processing to the Service layer.

Concept: The Waiter

In our architectural model, the Controller acts as the Waiter.

  • Takes the Order: Receives the HTTP request.
  • Clarifies Details: Extracts parameters, body, and headers.
  • Validates: Ensures the request is intelligible.
  • Delegates: Passes the valid order to the Chef (Service).
  • Serves: Returns the final response to the client.
  • Crucial Rule: Waiters do not cook. Controllers should contain zero business logic.

Routing Mechanism

Routing is the process of resolving a URL path (e.g., /users/123) to a specific method within a specific class. Karin utilizes a decorator-based routing mechanism derived from metadata reflection.

Basic Configuration

A Controller is defined using the @Controller() decorator. This decorator accepts an optional path argument, which serves as the "prefix" for all routes defined within that class.

typescript
import { Controller, Get } from '@project-karin/core';

@Controller('users')
export class UsersController {
  
  // Maps to GET /users
  @Get()
  findAll() {
    return 'This returns all users';
  }
}

HTTP Methods

Karin provides decorators for all standard HTTP verbs. Each method decorator maps a specific HTTP operation to the handler.

  • @Get(path?)
  • @Post(path?)
  • @Put(path?)
  • @Delete(path?)
  • @Patch(path?)
  • @Options(path?)
  • @Head(path?)
  • @All(path?) - Matches any HTTP verb.

Route Parameters

Dynamic routes allow you to capture specific segments of the URL. Parameters are defined using a colon syntax (:paramName) in the path string.

To access these parameters within your handler, use the @Param() decorator.

typescript
@Get(':id')
findOne(@Param('id') id: string) {
  // GET /users/123 -> id = "123"
  return `Fetching user ${id}`;
}

IMPORTANT

Route parameters are always extracted as strings by the underlying router. If you need a number (e.g., for a database ID), you must parse it manually or use a Pipe to transform it.

Wildcards

For more complex patterns, Karin supports wildcard routing. The asterisk (*) acts as a catch-all for any sequence of characters in that segment.

typescript
// Matches: /files/documents, /files/images/logo.png
@Get('files/*')
getFile() {
  return 'File handler';
}

Route Precedence

When defining routes, order matters. The router often matches paths sequentially or by specificity. A common pitfall occurs when a static path is masked by a generic parameter path defined earlier.

Incorrect Order:

typescript
@Get(':id') // This captures EVERYTHING, including "profile"
findById(@Param('id') id: string) {}

@Get('profile') // This is unreachable
getProfile() {}

Correct Order: Always place specific static paths before dynamic parameter paths.

typescript
@Get('profile')
getProfile() {}

@Get(':id')
findById(@Param('id') id: string) {}

Request Object Handling

A robust API requires precise extraction of data from the incoming request. Karin provides a suite of decorators to access specific portions of the HTTP payload.

Request Body (@Body)

For POST, PUT, and PATCH requests, the payload is typically sent as JSON in the request body. The @Body() decorator extracts this data.

Using DTOs (Data Transfer Objects): It is strictly recommended to define a DTO for your request body. With Karin, you use the createZodDto helper.

typescript
import { z } from 'zod';
import { createZodDto } from '@project-karin/core';

// 1. Define Schema
const CreateUserSchema = z.object({
  username: z.string(),
  email: z.string().email(),
});

// 2. Define Class
export class CreateUserDto extends createZodDto(CreateUserSchema) {}

Controller Usage:

typescript
import { CreateUserDto } from './dtos/create-user.dto'; // Import as Value!

@Post()
create(@Body() createUserDto: CreateUserDto) {
  // createUserDto is fully typed and validated
  return this.usersService.create(createUserDto);
}

WARNING

While TypeScript 5+ allows import type, you MUST import DTOs as values for metadata reflection to work. import { CreateUserDto } ...import type { CreateUserDto } ...

Query Parameters (@Query)

Query parameters appear after the ? in the URL (e.g., /items?limit=10&sort=desc).

typescript
@Get()
findAll(
  @Query('limit') limit: string,
  @Query('sort') sort: string
) {
  // Logic to handle pagination
}

Request Headers (@Headers)

To extract standard or custom headers (such as Authorization or X-Custom-ID), use the @Headers() decorator.

typescript
@Post()
secureAction(@Headers('Authorization') token: string) {
  // Validate token
}

Platform-Specific Request Object (@Req)

In edge cases, you may need access to the underlying platform's raw Request object (e.g., to read the IP address or access a property attached by a third-party non-standard middleware).

typescript
@Get()
getIp(@Req() request: Request) {
  return request.headers.get('x-forwarded-for') || 'Unknown IP';
}

WARNING

Accessing the raw @Req() object can couple your controller logic to the underlying HTTP adapter (H3 vs Hono), potentially reducing portability. Use the specific decorators (@Body, @Headers) whenever possible.


Response Management

Karin simplifies response handling by automatically serializing return values.

Standard Response Serialization

  • Objects/Arrays: Automatically serialized to JSON with Content-Type: application/json.
  • Strings: Sent as text/plain (unless HTML is detected).
  • Numbers/Booleans: Cast to string and sent as text.
  • Promises: The framework awaits the Promise and effectively serializes the resolved value.

Status Codes

By default, GET requests return 200 OK, and POST requests return 201 Created. To customize this, use the @HttpCode() decorator.

typescript
@Post()
@HttpCode(204)
create() {
  // Returns 204 No Content
}

Redirection

To redirect the client to a different URL, return a Response object with a redirect status, or use platform-specific utilities if available.

typescript
@Get('old-route')
redirect() {
  return Response.redirect('https://example.com/new-route', 301);
}

Full Response Control

For advanced scenarios—such as serving binary files, setting cookies, or streaming data—you should return a standard Web API Response object. If a handler returns a Response object, Karin bypasses its automatic serialization and sends the response directly.

typescript
@Get('download')
downloadFile() {
  const fileParams = this.service.getPdfBuffer();
  
  return new Response(fileParams.buffer, {
    status: 200,
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': 'attachment; filename="invoice.pdf"'
    }
  });
}

Dependency Injection and Scoping

Controllers are fully integrated into Karin's Dependency Injection (DI) system.

Constructor Injection

Dependencies (Services, Providers) should be injected via the constructor. This promotes loose coupling and simplifies unit testing.

typescript
@Controller('cats')
export class CatsController {
  // The IoC container automatically provides the CatsService instance
  constructor(private readonly catsService: CatsService) {}

  @Get()
  findAll() {
    return this.catsService.findAll();
  }
}

Singleton Scope & Concurrency

In Karin (like NestJS), Controllers are Singletons. This means:

  1. Only one instance of UsersController is created at application startup.
  2. Requests are handled concurrently by the same instance.

Critical Concurrency Implications: You must ensure your controllers are Stateless.

  • NEVER store request-specific data (like a userId) in a class property.
  • If you store this.userId = request.body.id, a concurrent request could overwrite it, causing a race condition where User A sees User B's data.

INCORRECT:

typescript
// DANGEROUS PATTERN
export class UnsafeController {
  private userId: string; // Do not do this

  @Get()
  handler(@Query('id') id: string) {
    this.userId = id; // Race condition risk!
    return this.service.getData(this.userId);
  }
}

CORRECT: Pass data through method arguments.

typescript
export class SafeController {
  @Get()
  handler(@Query('id') id: string) {
    // 'id' is local to this specific execution context (stack)
    return this.service.getData(id);
  }
}

Best Practices

Thin Controllers, Fat Services

A common architectural error is implementing business logic directly within the controller.

  • Controller Role: Validate inputs, extract params, call service.
  • Service Role: Database queries, filtering, third-party calls, complex math.

Bad Code:

typescript
@Post()
create(@Body() body: any) {
  // Validation logic in controller...
  if (!body.email) throw new Error();
  // Database logic in controller...
  const user = db.query('INSERT INTO...'); 
  return user;
}

Good Code:

typescript
@Post()
create(@Body() body: CreateUserDto) {
  // Controller simply delegates
  return this.usersService.create(body);
}

Use Exception Filters

Avoid try/catch blocks used solely to format error responses. Let exceptions bubble up. Karin's global Exception Layer will catch standard errors (like NotFoundException or BadRequestException) and format them into consistent JSON responses automatically.

This keeps your controller code linear and readable.

Released under the MIT License.