DEV Community

Tochukwu Nwosa
Tochukwu Nwosa

Posted on

NestJS Week 2: Exception Filters, Query Params, and Why You Should Stop Using Try-Catch Everywhere

Welcome back! If you missed Part 1 of my NestJS journey, I covered the basics: modules, controllers, services, DTOs, and validation.

Now we're getting into the good stuff—the patterns that separate messy code from clean, scalable backends.

What I learned this week:

  • Global exception filters
  • Environment configuration with ConfigService
  • The difference between @ Param, @ Query, and @ Body (Have to add space to the names because I noticed some users have them as their username)
  • Building flexible filtering logic for APIs

Let's dive in.


Day 7: Global Exception Filters - Stop Repeating Yourself

The Problem with Try-Catch Everywhere

When I first started, my controllers looked like this:

@Get(':id')
async findOne(@Param('id') id: string) {
  try {
    return await this.productsService.findOne(id);
  } catch (error) {
    if (error instanceof NotFoundException) {
      return { statusCode: 404, message: 'Product not found' };
    }
    return { statusCode: 500, message: 'Something went wrong' };
  }
}
Enter fullscreen mode Exit fullscreen mode

This is scattered, repetitive, and hard to maintain. Every endpoint needs the same error handling logic copy-pasted.

The Solution: Global Exception Filters

NestJS has a better way. Exception filters catch all thrown errors in one place and format responses consistently.

Here's how I set it up:

Step 1: Create the Exception Filter

// global-filters/http-exception-filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { ConfigService } from '@nestjs/config';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(HttpExceptionFilter.name);

  constructor(private configService: ConfigService) {}

  private buildErrorResponse(
    status: number,
    errorResponse: any,
    includeStack: boolean,
    stack?: string,
  ) {
    const base = {
      success: false,
      statusCode: status,
      error: errorResponse,
      timestamp: new Date().toISOString(),
    };
    return includeStack ? { ...base, stackTrace: stack } : base;
  }

  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const status = exception.getStatus();
    const errorResponse = exception.getResponse();

    const isProduction =
      this.configService.get<string>('NODE_ENV') === 'production';

    this.logger.error(
      `Status ${status} Error Response: ${JSON.stringify(errorResponse)}`,
    );

    response.status(status).json(
      isProduction
        ? this.buildErrorResponse(status, errorResponse, false)
        : this.buildErrorResponse(status, errorResponse, true, exception.stack),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

What's happening here?

  1. @Catch(HttpException) - This filter only catches HTTP exceptions
  2. Logger - Logs errors to console (can extend to log files/Sentry later)
  3. ConfigService - Reads environment variables
  4. Environment-based responses:
    • Development: Shows full stack traces for debugging
    • Production: Hides sensitive details

Step 2: Set Up Environment Variables

Install the config package:

npm i --save @nestjs/config
Enter fullscreen mode Exit fullscreen mode

Create a .env file:

NODE_ENV=development
APP_PORT=3000
Enter fullscreen mode Exit fullscreen mode

Update app.module.ts:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }), // Makes .env available everywhere
    // ... other modules
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Step 3: Apply the Filter Globally

In main.ts:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './global-filters/http-exception-filter';
import { ConfigService } from '@nestjs/config';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const configService = app.get<ConfigService>(ConfigService);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  app.useGlobalFilters(new HttpExceptionFilter(configService));

  const port = configService.get<number>('APP_PORT') || 3000;
  await app.listen(port);
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

Now Your Controllers Are Clean AF

@Get(':id')
findOne(@Param('id') id: string) {
  return this.productsService.findOne(+id);
}
Enter fullscreen mode Exit fullscreen mode

That's it. No try-catch. If the service throws a NotFoundException, the filter catches it and returns:

Development response:

{
  "success": false,
  "statusCode": 404,
  "error": "Product not found",
  "timestamp": "2025-11-11T10:30:00.000Z",
  "stackTrace": "Error: Product not found\n    at ProductsService.findOne..."
}
Enter fullscreen mode Exit fullscreen mode

Production response:

{
  "success": false,
  "statusCode": 404,
  "error": "Product not found",
  "timestamp": "2025-11-11T10:30:00.000Z"
}
Enter fullscreen mode Exit fullscreen mode

Why This Approach Wins

  1. Centralized error handling - One place to manage all errors
  2. Cleaner code - Controllers focus on business logic
  3. Consistent API responses - Frontend devs will love you
  4. Better debugging - Errors are logged and formatted properly
  5. Scalability - Add error tracking (Sentry, Datadog) in one place

Note: This doesn't mean you'll never use try-catch in NestJS. But for standard HTTP exceptions, filters handle it better.


Days 8-12: WordPress Side Quest

Real talk—I worked on my 9-5 WordPress project these days. Learning NestJS doesn't pay the bills yet. 😅

But I kept the concepts fresh by thinking about how I'd structure the WordPress project if it were NestJS (spoiler: it would be way cleaner).


Day 13: Mastering @ Param, @ Query, and @ Body

Time to understand how to extract data from incoming requests properly.

The Three Decorators

Decorator Used For Example URL
@Body() POST/PUT request body N/A
@Param() Route parameters /products/:id
@Query() Query strings /products?name=shirt&type=clothing

@ Body - Extracting Request Body

Used for creating or updating resources:

@Post()
create(@Body() createProductDto: CreateProductDto) {
  return this.productsService.create(createProductDto);
}
Enter fullscreen mode Exit fullscreen mode

Request body:

{
  "productName": "Laptop",
  "productType": "electronics",
  "price": 50000
}
Enter fullscreen mode Exit fullscreen mode

@ Param - Extracting Route Parameters

Used for identifying specific resources:

@Get(':id')
findOne(@Param('id') id: string) {
  return this.productsService.findOne(+id);
}
Enter fullscreen mode Exit fullscreen mode

URL: http://localhost:3000/products/5

You can extract multiple params:

@Get(':category/:id')
findByCategory(
  @Param('category') category: string,
  @Param('id') id: string,
) {
  return this.productsService.findByCategoryAndId(category, +id);
}
Enter fullscreen mode Exit fullscreen mode

URL: http://localhost:3000/products/electronics/5

@Query - Building Flexible Filters

This is where things get interesting. Query parameters are optional and perfect for filtering.

Step 1: Create a Query DTO

// dto/find-product-query.dto.ts
import { IsOptional, IsString } from 'class-validator';

export class FindProductQueryDto {
  @IsOptional()
  @IsString()
  productName?: string;

  @IsOptional()
  @IsString()
  productType?: string;
}
Enter fullscreen mode Exit fullscreen mode

The @IsOptional() decorator means these fields don't have to be present.

Step 2: Use It in the Controller

@Get()
findAll(@Query() query: FindProductQueryDto) {
  return this.productsService.findAll(query);
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Implement Filtering Logic in the Service

findAll(query?: FindProductQueryDto) {
  // If no query params, return all products
  if (!query || Object.keys(query).length === 0) {
    return {
      message: 'All products fetched successfully',
      data: this.products,
    };
  }

  // Filter based on query params
  const filtered = this.products.filter((prod) => {
    const matchesName = query.productName
      ? prod.productName.toLowerCase().includes(query.productName.toLowerCase())
      : true;

    const matchesType = query.productType
      ? prod.productType.toLowerCase() === query.productType.toLowerCase()
      : true;

    return matchesName && matchesType; // Both conditions must match
  });

  return {
    message: 'Filtered products',
    data: filtered,
  };
}
Enter fullscreen mode Exit fullscreen mode

How It Works

Request: GET /products?productName=laptop&productType=electronics

Logic:

  1. Check if productName matches (case-insensitive, partial match)
  2. Check if productType matches (case-insensitive, exact match)
  3. Return products that match both conditions

Using && vs ||:

  • && (AND) - Narrows the search (must match all filters)
  • || (OR) - Broadens the search (matches any filter)

I used && because I wanted to narrow down results progressively.

When to Use What?

Use @Query for:

  • Optional filtering (/products?name=laptop&sort=asc)
  • Pagination (/products?page=1&limit=10)
  • Search functionality

Use @ Param for:

  • Required identifiers (/products/:id)
  • Hierarchical resources (/users/:userId/orders/:orderId)

Use @ Body for:

  • Creating resources (POST)
  • Updating resources (PUT/PATCH)

Key Takeaways from Week 2

  1. Global exception filters > try-catch everywhere
  2. ConfigService makes environment management clean
  3. @Query is perfect for flexible, optional filtering
  4. @ Param is for required identifiers in the URL
  5. DTOs with @IsOptional() make query validation easy

What's Next?

Now that I've got the fundamentals down, it's time to level up:

  • Database integration (Mongoose + MongoDB)
  • Authentication with JWT
  • Guards and middleware
  • Database relations (User → Products → Orders)

If you're following along, let me know what you'd like me to cover next!


Connect With Me

I'm documenting my transition from frontend to full-stack. Let's connect and learn together!

🌐 Portfolio: tochukwu-nwosa.vercel.app

🚀 Latest Project: Tech Linkup - Discover tech events in Nigerian cities

💼 LinkedIn: nwosa-tochukwu

🐙 GitHub: @tochukwunwosa

🐦 Twitter/X: @tochukwudev

📝 Dev.to: @tochukwu_dev

Drop a comment if you're also learning NestJS or have tips for backend beginners! 👇


This is part of my #LearningInPublic series where I document my journey from frontend to full-stack development. Follow along for more!

Top comments (1)

Collapse
 
kostyatretyak profile image
Костя Третяк • Edited

logger = new Logger(HttpExceptionFilter.name);

It is better to use DI for such cases:

constructor(private logger: Logger) {}
Enter fullscreen mode Exit fullscreen mode

P.S. If you don't want use the white space in @ Body, then just use @Body (with tilde symbol).