Presentation

Unified route system for HTTP handling with type-safe routes, handlers, and framework adapters

The presentation layer provides a unified route system that powers:

  • Type-safe route definitions with schema validation
  • Server-side handlers with request/response mapping
  • Framework adapters (Hono, Fastify, Elysia, NestJS)
  • OpenAPI specification generation
  • Type-safe client generation

Structure

presentation/
└── http/
    ├── routes/
    │   ├── users.routes.ts        ← Route definitions with schemas
    │   └── projects.routes.ts
    ├── handlers/
    │   ├── users.handlers.ts      ← Handler implementations
    │   └── projects.handlers.ts
    └── router.ts                  ← Combined router definition

Route Definitions

Define routes using defineRoute() with full type safety:

import { defineRoute } from '@cosmneo/onion-lasagna/http/route';
import { zodSchema, z } from '@cosmneo/onion-lasagna/http/schema/zod';

export const createUserRoute = defineRoute({
  method: 'POST',
  path: '/api/users',
  request: {
    body: {
      schema: zodSchema(
        z.object({
          email: z.string().email(),
          name: z.string().min(1).max(100),
        }),
      ),
    },
    context: {
      schema: zodSchema(
        z.object({
          userId: z.string(),
        }),
      ),
    },
  },
  responses: {
    201: {
      description: 'User created',
      schema: zodSchema(
        z.object({
          userId: z.string().uuid(),
        }),
      ),
    },
    400: { description: 'Validation error' },
  },
  docs: {
    summary: 'Create a new user',
    tags: ['Users'],
    operationId: 'createUser',
  },
});

Route Definition Options

PropertyDescription
methodHTTP method: GET, POST, PUT, PATCH, DELETE
pathURL path with :param syntax for path parameters
request.bodyRequest body schema
request.queryQuery parameter schema
request.paramsPath parameter schema
request.contextContext schema (from middleware)
responsesResponse schemas by status code
docsOpenAPI documentation

Router Definition

Group routes into a router using defineRouter():

import { defineRouter } from '@cosmneo/onion-lasagna/http/route';
import { createUserRoute, listUsersRoute, getUserRoute } from './routes/users.routes';

export const userRouter = defineRouter({
  users: {
    create: createUserRoute,
    list: listUsersRoute,
    get: getUserRoute,
  },
});

Routers can be nested for complex APIs:

export const apiRouter = defineRouter({
  projects: {
    create: createProjectRoute,
    list: listProjectsRoute,
    get: getProjectRoute,
    tasks: {
      add: addTaskRoute,
      list: listTasksRoute,
      update: updateTaskRoute,
    },
  },
});

Server Routes (Handlers)

Create handlers using the serverRoutes() builder:

import { serverRoutes } from '@cosmneo/onion-lasagna/http/server';
import { userRouter } from './router';

export function createUserHandlers(useCases: UseCases) {
  return serverRoutes(userRouter)
    .handle('users.create', {
      requestMapper: (req, ctx) => ({
        email: req.body.email,
        name: req.body.name,
        createdBy: ctx.userId,
      }),
      useCase: useCases.createUserUseCase,
      responseMapper: (output) => ({
        status: 201 as const,
        body: { userId: output.userId },
      }),
    })
    .handle('users.list', {
      requestMapper: (req) => ({
        page: req.query?.page ?? 1,
        pageSize: req.query?.pageSize ?? 20,
      }),
      useCase: useCases.listUsersUseCase,
      responseMapper: (output) => ({
        status: 200 as const,
        body: output,
      }),
    })
    .build();
}

Handler Configuration

Each handler has three functions:

FunctionPurpose
requestMapperTransform validated HTTP request to use case input
useCaseThe use case to execute
responseMapperTransform use case output to HTTP response

The req parameter in requestMapper is fully typed based on your route definition:

  • req.body - Validated request body
  • req.query - Validated query parameters
  • req.pathParams - Validated path parameters

The ctx parameter contains context from middleware (e.g., authenticated user).


Framework Integration

Register routes with your framework of choice:

Hono

import { Hono } from 'hono';
import { registerHonoRoutes, onionErrorHandler } from '@cosmneo/onion-lasagna/http/frameworks/hono';

const app = new Hono();

app.onError((err, c) => onionErrorHandler(err, c));

registerHonoRoutes(app, routes, {
  middlewares: [authMiddleware],
  contextExtractor: (c) => ({
    userId: c.get('jwtPayload')?.sub,
  }),
});

Fastify

import Fastify from 'fastify';
import { registerFastifyRoutes, onionErrorHandler } from '@cosmneo/onion-lasagna/http/frameworks/fastify';

const app = Fastify();

app.setErrorHandler(onionErrorHandler);

registerFastifyRoutes(app, routes, {
  middlewares: [authMiddleware],
  contextExtractor: (request) => ({
    userId: request.user?.userId,
  }),
});

Elysia

import { Elysia } from 'elysia';
import { registerElysiaRoutes, onionErrorHandler } from '@cosmneo/onion-lasagna/http/frameworks/elysia';

const app = new Elysia()
  .onError(({ error }) => onionErrorHandler({ error }));

registerElysiaRoutes(app, routes, {
  middlewares: [authMiddleware],
  contextExtractor: (ctx) => ({
    userId: ctx.store['userId'],
  }),
});

NestJS

NestJS uses a decorator-based approach:

import { Controller, Post, UseGuards, UseFilters } from '@nestjs/common';
import { OnionRequest, OnionExceptionFilter } from '@cosmneo/onion-lasagna/http/frameworks/nestjs';
import type { ContextualRawHttpRequest } from '@cosmneo/onion-lasagna/http/frameworks/nestjs';

@Controller('api/users')
@UseGuards(JwtAuthGuard)
@UseFilters(OnionExceptionFilter)
export class UsersController {
  constructor(private readonly routeHandlers: Map<string, UnifiedRouteInput>) {}

  @Post()
  async create(
    @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest<AuthContext>,
  ) {
    const route = this.routeHandlers.get('POST:/api/users');
    return route.handler(request, request.context);
  }
}

Context Extraction

Context extractors provide request context (e.g., authenticated user) to handlers:

// Define context type
interface AuthContext {
  userId: string;
  roles: string[];
}

// Create extractor
const contextExtractor = (c: Context): AuthContext => ({
  userId: c.get('jwtPayload')?.sub ?? '',
  roles: c.get('jwtPayload')?.roles ?? [],
});

// Use in handler
.handle('projects.create', {
  requestMapper: (req, ctx) => ({
    name: req.body.name,
    createdBy: ctx.userId,  // From context
  }),
  // ...
})

Error Handling

The unified route system provides automatic error handling:

Error TypeHTTP StatusMasked
ObjectValidationError400No
InvalidRequestError400No
UseCaseError400No
AccessDeniedError403No
NotFoundError404No
ConflictError409No
UnprocessableError422No
DomainError500Yes
InfraError500Yes
ControllerError500Yes

500-level errors are masked for security - only a generic message is returned to clients.

Use the framework-specific error handler:

// Hono
app.onError(onionErrorHandler);

// Fastify
app.setErrorHandler(onionErrorHandler);

// Elysia
app.onError(onionErrorHandler);

// NestJS
@UseFilters(OnionExceptionFilter)

OpenAPI Generation

Generate OpenAPI specifications from your router:

import { generateOpenAPI } from '@cosmneo/onion-lasagna/http/openapi';

const spec = generateOpenAPI(userRouter, {
  info: {
    title: 'User API',
    version: '1.0.0',
    description: 'API for user management',
  },
  servers: [
    { url: 'https://api.example.com', description: 'Production' },
    { url: 'http://localhost:3000', description: 'Development' },
  ],
  tags: [
    { name: 'Users', description: 'User operations' },
  ],
});

Type-Safe Client

Generate a type-safe client from your router:

import { createClient } from '@cosmneo/onion-lasagna/http/client';

const client = createClient(userRouter, {
  baseUrl: 'https://api.example.com',
  headers: { Authorization: `Bearer ${token}` },
});

// Fully typed API calls
const user = await client.users.get({
  pathParams: { userId: '123' },
});

const newUser = await client.users.create({
  body: { email: 'john@example.com', name: 'John' },
});

Partial Builds

For large routers, use buildPartial() to build handlers incrementally:

// users.handlers.ts
export function createUserHandlers(useCases: UseCases) {
  return serverRoutes(apiRouter)
    .handle('users.create', { ... })
    .handle('users.list', { ... })
    .buildPartial();  // Returns partial routes
}

// projects.handlers.ts
export function createProjectHandlers(useCases: UseCases) {
  return serverRoutes(apiRouter)
    .handle('projects.create', { ... })
    .handle('projects.list', { ... })
    .buildPartial();
}

// index.ts - Combine all handlers
const routes = [
  ...createUserHandlers(useCases),
  ...createProjectHandlers(useCases),
];