Skip to content

API Response & Error Handling

Key objectives: Clear responses, meaningful errors, protect sensitive data, consistent logging.

Core Principles:

  1. Clear envelope structure (status, data/errors, meta, links)
  2. Either data (success) or errors (failure), never both
  3. Specific exceptions over generic Error
  4. Full context in exceptions (what, where, why)
  5. Never expose sensitive data (passwords, tokens, internals)
  6. Log everything internally
{
"status": { "code": 200000, "message": "Request Succeeded" },
"data": { "type": "resources", "id": "...", "attributes": {...} },
"meta": { "timestamp": "2025-10-15T10:30:00.000Z" },
"links": { "self": "..." }
}

The @ResourceType('resource-name') decorator is required on all controllers. It tells the TransformInterceptor what type to use in JSON:API responses.

@Controller('resources')
@ResourceType('resources') // ✅ Sets type: "resources" in response
export class ResourcesController {
@Get() findAll() { return this.resourcesService.findAll(); }
}

What happens:

  • With decorator: Response includes "type": "resources" in JSON:API format
  • Without decorator: Raw data returned (no transformation)

Response Types:

  1. Single Resource: { data: { type: "resources", id: "...", attributes: {...} } }
  2. Collection: { data: [{ type: "resources", ... }] }
  3. Paginated: { data: [...], meta: { pagination } }

The TransformInterceptor automatically wraps all controller responses in the JSON:API envelope.

HttpException (NestJS)
├── BadRequestException (400)
│ ├── ValidationException (400001) - Body validation
│ └── InvalidParameterException (400002) - Query params
├── UnauthorizedException (401)
├── ForbiddenException (403)
├── NotFoundException (404)
├── ConflictException (409)
├── UnprocessableEntityException (422)
├── InternalServerErrorException (500)
└── ServiceUnavailableException (503)

Use: DTO validation fails (automatic via @Body())

export class CreateResourceDTO {
@IsEmail() email: string;
@IsNotEmpty() first_name: string;
}
// Auto-throws ValidationException on invalid data

Use: Query parameter validation (use @ValidatedQuery())

Method 1: Using @ValidatedQuery (Recommended)

@Get()
findAll(@ValidatedQuery(QueryParamsDTO) query: QueryParamsDTO) {
// ✅ Type-safe + throws 400002 on error
return this.service.findAll(query);
}

Method 2: Manual Throw (For custom validation)

import { InvalidParameterException } from '@lib/common/utils/http-exception/invalid-parameter.exception';
@Get('search')
async search(@Query('keyword') keyword: string) {
const allowedFields = ['name', 'email', 'reference_number'];
const searchField = keyword?.split(':')[0];
if (!allowedFields.includes(searchField)) {
throw new InvalidParameterException([{
field: 'keyword',
message: `Invalid search field '${searchField}'. Allowed: ${allowedFields.join(', ')}`
}]);
}
return this.service.search(keyword);
}

Comparison:

AspectValidationException (400001)InvalidParameterException (400002)
UseRequest bodyQuery parameters
Decorator@Body()@ValidatedQuery(DTO) or manual
Source/data/attributes/fieldparameter: field
ExamplePOST /resources with invalid bodyGET /resources?page=invalid

Use: Business logic validation, invalid state transitions

if (order.status === OrderStatus.COMPLETED) {
throw new BadRequestException('Cannot cancel a completed order');
}

Use: Missing/invalid token, expired session

if (!token) throw new UnauthorizedException('Token required');
if (!this.jwtService.verify(token)) throw new UnauthorizedException('Invalid token');

Use: Authenticated but lacks permissions

if (!userPermissions.includes(requiredPermission)) {
throw new ForbiddenException(`Required: ${requiredPermission}`);
}

Use: Resource doesn’t exist

const resource = await this.resourceRepository.findOne({ where: { id } });
if (!resource) throw new NotFoundException(`Resource ${id} not found`);
return resource;

Use: Duplicate records, constraint violations

if (existingResource) {
throw new ConflictException(`Resource with reference number '${ref}' already exists`);
}
// Also auto-handled by BaseServiceOperations for DB constraints
  • UnprocessableEntityException (422): Semantically invalid (e.g., scheduling in the past)
  • InternalServerErrorException (500): Unexpected server errors
  • ServiceUnavailableException (503): Temporary downtime, maintenance
async createResource(dto: CreateResourceDTO): Promise<Resource> {
return this.executeDbOperation(async () => {
return await this.resourceRepository.save(dto);
});
}

Database Errors Handled Automatically:

Database ErrorPostgreSQL CodeException ThrownHTTP StatusUser Message
Unique constraint violation23505ConflictException409”A record with the provided details already exists”
Foreign key violation23503BadRequestException400”Invalid reference to another record”
Not-null violation23502BadRequestException400”A required field was left empty”
Invalid UUID/text format22P02BadRequestException400”Invalid format for a field”
Optimistic lock mismatch-ConflictException409”Record was modified by another user”
Entity property not found-BadRequestException400Error message from TypeORM
Generic query error-InternalServerErrorException500”A database error occurred”
async transferResource(resourceId: string, newCategoryId: string) {
return this.executeDbOperation(async () => {
const resource = await this.findById(resourceId); // May throw NotFoundException
if (resource.status === 'ARCHIVED') {
throw new BadRequestException('Cannot transfer an archived resource');
}
resource.category_id = newCategoryId;
return await this.resourceRepository.save(resource);
});
}
try {
await this.externalApi.call();
} catch (error) {
this.logger.error('API failed', { error: error.message, stack: error.stack });
throw new InternalServerErrorException('Unexpected error. Try again later.');
}

Rule: Controllers should NOT catch exceptions. Let them bubble up to AllExceptionsFilter.

// ✅ DO
@Get(':id')
async findOne(@Param('id') id: string) {
return await this.service.findById(id);
}
// ❌ DON'T
@Get(':id')
async findOne(@Param('id') id: string) {
try {
return await this.service.findById(id);
} catch (error) {
return { error: error.message }; // Never do this!
}
}

Exception: Only catch to add controller-specific context (e.g., file upload validation).

// ❌ Problem 1: Loses type safety
@Get()
findAll(@Query() query: any) { // Must use 'any' to avoid Global ValidationPipe
return this.service.findAll(query);
}
// ❌ Problem 2: Triggers Global ValidationPipe (wrong error code)
@Get()
findAll(@Query() query: QueryParamsDTO) { // Throws 400001 instead of 400002
return this.service.findAll(query);
}

Benefits:

  1. Type-safe: Can use query: QueryParamsDTO instead of any
  2. Correct error code: Throws InvalidParameterException (400002)
  3. Bypasses Global Pipe: Doesn’t conflict with body validation
  4. Cleaner syntax: Single decorator instead of pipe configuration
// ✅ RECOMMENDED
@Get()
findAll(@ValidatedQuery(QueryParamsDTO) query: QueryParamsDTO) {
// Type-safe + throws 400002 on validation error
return this.service.findAll(query);
}

Features:

  • Auto type conversion ("123"123, "true"true)
  • Strips unknown properties (whitelist)
  • Nested validation support
  • Array parameter support (filter[]=field||$eq||value)

Comparison Table:

Feature@Query()@Query() with type@ValidatedQuery(DTO)
Type Safety❌ (uses any)⚠️ (triggers Global Pipe)✅ Full
Error Code-400001 (wrong)400002 (correct)
ValidationManualAuto (wrong pipe)Auto (correct)
Recommended

No-throw pattern: sendWithContext never throws, returns defaultValue or null.

// Example 1: Returns null on error
const resource = await this.microserviceClient.sendWithContext<Resource>(
this.logger, this.dataOwnerService,
{ cmd: AppMicroservice.DataOwner.cmd.GetResourceById },
{ id: resourceId }
);
if (!resource) throw new NotFoundException(`Resource ${resourceId} not found`);
// Example 2: Returns empty array on error
const resources = await this.microserviceClient.sendWithContext<Resource[]>(
this.logger, this.dataOwnerService,
{ cmd: AppMicroservice.DataOwner.cmd.GetResources },
filters,
[] // defaultValue
);

Benefits: Clean code, explicit error handling, consistent logging, graceful degradation.

async createResource(dto: CreateResourceDTO, user: IUserSession): Promise<Resource> {
const ctx = { service: 'ResourcesService', userId: user.id, ref: dto.reference_number };
this.logger.log('Creating resource', ctx);
try {
const resource = await this.executeDbOperation(async () => {
return await this.resourceRepository.save(dto);
});
this.logger.log('Resource created', { ...ctx, resourceId: resource.id });
return resource;
} catch (error) {
this.logger.error('Creation failed', { ...ctx, error: error.message, stack: error.stack });
throw error;
}
}
// ❌ Swallowing exceptions
try { ... } catch (error) { return null; }
// ✅ Throw specific exceptions
if (!resource) throw new NotFoundException(`Resource ${id} not found`);
// ❌ Exposing internals
throw new InternalServerErrorException(error.stack);
// ✅ Generic message + internal log
this.logger.error('Failed', error.stack);
throw new InternalServerErrorException('Try again later');
// ❌ Inconsistent exceptions
throw new Error('Not found'); // Wrong
throw new HttpException('Not found', 404); // Wrong
// ✅ Consistent specific exceptions
throw new NotFoundException(`Resource ${id} not found`);
  • Using most specific exception type?
  • Error message has enough context?
  • Logging full context internally?
  • Protecting sensitive data?
  • Using executeDbOperation for DB operations?
  • Letting exceptions bubble to global filter?
  • Added @ResourceType decorator?
// Custom
import { ValidationException } from '@lib/common/utils/http-exception/validation.exception';
import { InvalidParameterException } from '@lib/common/utils/http-exception/invalid-parameter.exception';
// NestJS
import {
BadRequestException, UnauthorizedException, ForbiddenException,
NotFoundException, ConflictException, UnprocessableEntityException,
InternalServerErrorException, ServiceUnavailableException,
} from '@nestjs/common';
CodeExceptionUseTrigger
400BadRequestExceptionGeneric client errorManual
400001ValidationExceptionBody validation@Body() auto
400002InvalidParameterExceptionQuery validation@ValidatedQuery()
401UnauthorizedExceptionAuth failedGuards
403ForbiddenExceptionNo permissionGuards
404NotFoundExceptionNot foundServices
409ConflictExceptionDuplicate/constraintDB auto/manual
422UnprocessableEntityExceptionSemantic invalidManual
500InternalServerErrorExceptionServer errorAuto/manual
503ServiceUnavailableExceptionTemp unavailableManual
@Controller('resources')
@ResourceType('resources')
export class ResourcesController {
// Body → 400001
@Post()
create(@Body() dto: CreateResourceDTO) {
return this.service.create(dto);
}
// Query → 400002
@Get()
findAll(@ValidatedQuery(QueryParamsDTO) query: QueryParamsDTO) {
return this.service.findAll(query);
}
// Service throws NotFoundException if not found
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findById(id);
}
}