NestJS Integration

Use onion-lasagna patterns with NestJS framework.

Installation

bun add @nestjs/core @nestjs/common @nestjs/platform-express

Import the NestJS integration:

import {
  OnionRequest,
  OnionExceptionFilter,
  type ContextualRawHttpRequest,
  type NestContextExtractor,
} from '@cosmneo/onion-lasagna/http/frameworks/nestjs';

Overview

NestJS integration differs from other frameworks because NestJS uses decorators and its own module system. The integration provides:

  • OnionRequest - Decorator to extract HTTP request data
  • OnionExceptionFilter - Exception filter for domain errors
  • Type-safe context extraction for authenticated routes

Quick Start

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

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

  @Get(':userId')
  async getUser(@OnionRequest() request: RawHttpRequest) {
    const route = this.routeHandlers.get('GET:/api/users/{userId}');
    return route.handler(request);
  }
}

@OnionRequest Decorator

Extracts an HttpRequest object from the NestJS request:

import { OnionRequest } from '@cosmneo/onion-lasagna/http/frameworks/nestjs';
import type { RawHttpRequest } from '@cosmneo/onion-lasagna/http/server';

@Get(':id')
async getItem(@OnionRequest() request: RawHttpRequest) {
  // request contains: pathParams, query, body, headers
}

The RawHttpRequest structure (from @cosmneo/onion-lasagna/http/server):

interface RawHttpRequest {
  pathParams: Record<string, string>;
  query: Record<string, string | string[]>;
  body: unknown;
  headers: Record<string, string>;
}

With Context Extractor (Protected Routes)

For routes that need authentication context, pass a context extractor:

import { Controller, Get, UseGuards } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
import {
  OnionRequest,
  type NestContextExtractor,
  type ContextualRawHttpRequest,
} from '@cosmneo/onion-lasagna/http/frameworks/nestjs';

interface AuthContext {
  userId: string;
}

const extractAuthContext: NestContextExtractor<AuthContext> = (ctx: ExecutionContext) => {
  const request = ctx.switchToHttp().getRequest();
  return {
    userId: request.user?.userId ?? '',
  };
};

@Controller('api/projects')
@UseGuards(JwtAuthGuard)
export class ProjectsController {
  @Get()
  async list(
    @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest<AuthContext>,
  ) {
    // request.context.userId is type-safe
  }
}

OnionExceptionFilter

Maps domain errors to HTTP responses:

import { UseFilters } from '@nestjs/common';
import { OnionExceptionFilter } from '@cosmneo/onion-lasagna/http/frameworks/nestjs';

// Apply to controller
@Controller('users')
@UseFilters(OnionExceptionFilter)
export class UsersController {}

// Or apply globally in main.ts
app.useGlobalFilters(new OnionExceptionFilter());

Error Mapping

Error TypeHTTP Status
ObjectValidationError400
InvalidRequestError400
UseCaseError400
AccessDeniedError403
NotFoundError404
ConflictError409
UnprocessableError422
Internal errors500

Using Unified Route Handlers

For consistency with other frameworks, you can use the unified route handlers:

import { Controller, Get, Post, Patch, Delete, Inject, UseGuards, Res, HttpCode } from '@nestjs/common';
import type { Response } from 'express';
import {
  OnionRequest,
  type ContextualRawHttpRequest,
  type NestContextExtractor,
} from '@cosmneo/onion-lasagna/http/frameworks/nestjs';
import type { UnifiedRouteInput } from '@cosmneo/onion-lasagna/http/server';

interface AuthContext {
  userId: string;
}

const extractAuthContext: NestContextExtractor<AuthContext> = (ctx) => {
  const request = ctx.switchToHttp().getRequest();
  return { userId: request.user?.userId ?? '' };
};

@Controller('api/projects')
@UseGuards(JwtAuthGuard)
export class ProjectsController {
  constructor(
    @Inject('ROUTE_HANDLERS')
    private readonly routeHandlers: Map<string, UnifiedRouteInput>,
  ) {}

  private async callHandler(
    method: string,
    path: string,
    request: ContextualRawHttpRequest<AuthContext>,
    res: Response,
  ) {
    const route = this.routeHandlers.get(`${method}:${path}`);
    if (!route) throw new Error(`Route not found: ${method}:${path}`);

    const response = await route.handler(request, request.context);
    res.status(response.status);
    return response.body;
  }

  @Post()
  async create(
    @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest<AuthContext>,
    @Res({ passthrough: true }) res: Response,
  ) {
    return this.callHandler('POST', '/api/projects', request, res);
  }

  @Get()
  async list(
    @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest<AuthContext>,
    @Res({ passthrough: true }) res: Response,
  ) {
    return this.callHandler('GET', '/api/projects', request, res);
  }
}

Module Setup

import { Module } from '@nestjs/common';
import { ProjectsController } from './projects.controller';
import { bootstrapProjectManagement } from '@repo/backend/bounded-contexts/project-management';

const { routes } = bootstrapProjectManagement();

// Create a map of routes for injection
const routeHandlersMap = new Map(
  routes.map((route) => [`${route.method}:${route.path}`, route]),
);

@Module({
  controllers: [ProjectsController],
  providers: [
    {
      provide: 'ROUTE_HANDLERS',
      useValue: routeHandlersMap,
    },
  ],
})
export class ProjectsModule {}

Global Setup

Configure the exception filter globally in main.ts:

import { NestFactory } from '@nestjs/core';
import { OnionExceptionFilter } from '@cosmneo/onion-lasagna/http/frameworks/nestjs';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Global exception filter
  app.useGlobalFilters(new OnionExceptionFilter());

  await app.listen(3000);
}

bootstrap();

Complete Example

// main.ts
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { OnionExceptionFilter } from '@cosmneo/onion-lasagna/http/frameworks/nestjs';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableCors();
  app.useGlobalFilters(new OnionExceptionFilter());

  await app.listen(process.env.PORT ?? 3000);
  console.log(`Server running on http://localhost:${process.env.PORT ?? 3000}`);
}

bootstrap();
// projects.controller.ts
import { Controller, Get, Post, Patch, Delete, UseGuards, Inject, Res, HttpCode } from '@nestjs/common';
import type { Response } from 'express';
import {
  OnionRequest,
  type ContextualRawHttpRequest,
  type NestContextExtractor,
} from '@cosmneo/onion-lasagna/http/frameworks/nestjs';
import type { UnifiedRouteInput } from '@cosmneo/onion-lasagna/http/server';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';

interface AuthContext {
  userId: string;
}

const extractAuthContext: NestContextExtractor<AuthContext> = (ctx) => {
  const request = ctx.switchToHttp().getRequest();
  return { userId: request.user?.userId ?? '' };
};

@Controller('api/projects')
@UseGuards(JwtAuthGuard)
export class ProjectsController {
  constructor(
    @Inject('ROUTE_HANDLERS')
    private readonly routeHandlers: Map<string, UnifiedRouteInput>,
  ) {}

  private async callHandler(
    method: string,
    path: string,
    request: ContextualRawHttpRequest<AuthContext>,
    res: Response,
  ) {
    const route = this.routeHandlers.get(`${method}:${path}`);
    if (!route) throw new Error(`Route not found: ${method}:${path}`);

    const response = await route.handler(request, request.context);
    res.status(response.status);
    if (response.headers) {
      for (const [key, value] of Object.entries(response.headers)) {
        res.setHeader(key, value);
      }
    }
    return response.body;
  }

  @Post()
  async create(
    @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest<AuthContext>,
    @Res({ passthrough: true }) res: Response,
  ) {
    return this.callHandler('POST', '/api/projects', request, res);
  }

  @Get()
  async list(
    @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest<AuthContext>,
    @Res({ passthrough: true }) res: Response,
  ) {
    return this.callHandler('GET', '/api/projects', request, res);
  }

  @Get(':projectId')
  async get(
    @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest<AuthContext>,
    @Res({ passthrough: true }) res: Response,
  ) {
    return this.callHandler('GET', '/api/projects/{projectId}', request, res);
  }

  @Delete(':projectId')
  @HttpCode(204)
  async delete(
    @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest<AuthContext>,
    @Res({ passthrough: true }) res: Response,
  ) {
    return this.callHandler('DELETE', '/api/projects/{projectId}', request, res);
  }
}