API Response & Error Handling
Overview
Section titled “Overview”Key objectives: Clear responses, meaningful errors, protect sensitive data, consistent logging.
Core Principles:
- Clear envelope structure (
status,data/errors,meta,links) - Either
data(success) orerrors(failure), never both - Specific exceptions over generic
Error - Full context in exceptions (what, where, why)
- Never expose sensitive data (passwords, tokens, internals)
- Log everything internally
Response Structure
Section titled “Response Structure”{ "status": { "code": 200000, "message": "Request Succeeded" }, "data": { "type": "resources", "id": "...", "attributes": {...} }, "meta": { "timestamp": "2025-10-15T10:30:00.000Z" }, "links": { "self": "..." }}Success Responses
Section titled “Success Responses”@ResourceType Decorator
Section titled “@ResourceType Decorator”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 responseexport 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:
- Single Resource:
{ data: { type: "resources", id: "...", attributes: {...} } } - Collection:
{ data: [{ type: "resources", ... }] } - Paginated:
{ data: [...], meta: { pagination } }
The TransformInterceptor automatically wraps all controller responses in the JSON:API envelope.
Exception Hierarchy
Section titled “Exception Hierarchy”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)Exception Usage Guide
Section titled “Exception Usage Guide”1. ValidationException (400001)
Section titled “1. ValidationException (400001)”Use: DTO validation fails (automatic via @Body())
export class CreateResourceDTO { @IsEmail() email: string; @IsNotEmpty() first_name: string;}// Auto-throws ValidationException on invalid data2. InvalidParameterException (400002)
Section titled “2. InvalidParameterException (400002)”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:
| Aspect | ValidationException (400001) | InvalidParameterException (400002) |
|---|---|---|
| Use | Request body | Query parameters |
| Decorator | @Body() | @ValidatedQuery(DTO) or manual |
| Source | /data/attributes/field | parameter: field |
| Example | POST /resources with invalid body | GET /resources?page=invalid |
3. BadRequestException (400)
Section titled “3. BadRequestException (400)”Use: Business logic validation, invalid state transitions
if (order.status === OrderStatus.COMPLETED) { throw new BadRequestException('Cannot cancel a completed order');}4. UnauthorizedException (401)
Section titled “4. UnauthorizedException (401)”Use: Missing/invalid token, expired session
if (!token) throw new UnauthorizedException('Token required');if (!this.jwtService.verify(token)) throw new UnauthorizedException('Invalid token');5. ForbiddenException (403)
Section titled “5. ForbiddenException (403)”Use: Authenticated but lacks permissions
if (!userPermissions.includes(requiredPermission)) { throw new ForbiddenException(`Required: ${requiredPermission}`);}6. NotFoundException (404)
Section titled “6. NotFoundException (404)”Use: Resource doesn’t exist
const resource = await this.resourceRepository.findOne({ where: { id } });if (!resource) throw new NotFoundException(`Resource ${id} not found`);return resource;7. ConflictException (409)
Section titled “7. ConflictException (409)”Use: Duplicate records, constraint violations
if (existingResource) { throw new ConflictException(`Resource with reference number '${ref}' already exists`);}// Also auto-handled by BaseServiceOperations for DB constraints8–10. Other Exceptions
Section titled “8–10. Other Exceptions”- UnprocessableEntityException (422): Semantically invalid (e.g., scheduling in the past)
- InternalServerErrorException (500): Unexpected server errors
- ServiceUnavailableException (503): Temporary downtime, maintenance
Service Layer Patterns
Section titled “Service Layer Patterns”Pattern 1: executeDbOperation Wrapper
Section titled “Pattern 1: executeDbOperation Wrapper”async createResource(dto: CreateResourceDTO): Promise<Resource> { return this.executeDbOperation(async () => { return await this.resourceRepository.save(dto); });}Database Errors Handled Automatically:
| Database Error | PostgreSQL Code | Exception Thrown | HTTP Status | User Message |
|---|---|---|---|---|
| Unique constraint violation | 23505 | ConflictException | 409 | ”A record with the provided details already exists” |
| Foreign key violation | 23503 | BadRequestException | 400 | ”Invalid reference to another record” |
| Not-null violation | 23502 | BadRequestException | 400 | ”A required field was left empty” |
| Invalid UUID/text format | 22P02 | BadRequestException | 400 | ”Invalid format for a field” |
| Optimistic lock mismatch | - | ConflictException | 409 | ”Record was modified by another user” |
| Entity property not found | - | BadRequestException | 400 | Error message from TypeORM |
| Generic query error | - | InternalServerErrorException | 500 | ”A database error occurred” |
Pattern 2: Business Validation
Section titled “Pattern 2: Business Validation”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); });}Pattern 3: External Service Errors
Section titled “Pattern 3: External Service Errors”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.');}Controller Layer
Section titled “Controller Layer”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).
@ValidatedQuery vs @Query
Section titled “@ValidatedQuery vs @Query”The Problem with @Query
Section titled “The Problem with @Query”// ❌ 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);}The Solution: @ValidatedQuery
Section titled “The Solution: @ValidatedQuery”Benefits:
- ✅ Type-safe: Can use
query: QueryParamsDTOinstead ofany - ✅ Correct error code: Throws
InvalidParameterException(400002) - ✅ Bypasses Global Pipe: Doesn’t conflict with body validation
- ✅ 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) |
| Validation | Manual | Auto (wrong pipe) | Auto (correct) |
| Recommended | ❌ | ❌ | ✅ |
Microservice Communication
Section titled “Microservice Communication”No-throw pattern: sendWithContext never throws, returns defaultValue or null.
// Example 1: Returns null on errorconst 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 errorconst 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.
Logging Patterns
Section titled “Logging Patterns”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; }}Common Pitfalls
Section titled “Common Pitfalls”// ❌ Swallowing exceptionstry { ... } catch (error) { return null; }
// ✅ Throw specific exceptionsif (!resource) throw new NotFoundException(`Resource ${id} not found`);
// ❌ Exposing internalsthrow new InternalServerErrorException(error.stack);
// ✅ Generic message + internal logthis.logger.error('Failed', error.stack);throw new InternalServerErrorException('Try again later');
// ❌ Inconsistent exceptionsthrow new Error('Not found'); // Wrongthrow new HttpException('Not found', 404); // Wrong
// ✅ Consistent specific exceptionsthrow new NotFoundException(`Resource ${id} not found`);Summary Checklist
Section titled “Summary Checklist”- Using most specific exception type?
- Error message has enough context?
- Logging full context internally?
- Protecting sensitive data?
- Using
executeDbOperationfor DB operations? - Letting exceptions bubble to global filter?
- Added
@ResourceTypedecorator?
Quick Reference
Section titled “Quick Reference”Exception Imports
Section titled “Exception Imports”// Customimport { ValidationException } from '@lib/common/utils/http-exception/validation.exception';import { InvalidParameterException } from '@lib/common/utils/http-exception/invalid-parameter.exception';
// NestJSimport { BadRequestException, UnauthorizedException, ForbiddenException, NotFoundException, ConflictException, UnprocessableEntityException, InternalServerErrorException, ServiceUnavailableException,} from '@nestjs/common';Status Code Reference
Section titled “Status Code Reference”| Code | Exception | Use | Trigger |
|---|---|---|---|
| 400 | BadRequestException | Generic client error | Manual |
| 400001 | ValidationException | Body validation | @Body() auto |
| 400002 | InvalidParameterException | Query validation | @ValidatedQuery() |
| 401 | UnauthorizedException | Auth failed | Guards |
| 403 | ForbiddenException | No permission | Guards |
| 404 | NotFoundException | Not found | Services |
| 409 | ConflictException | Duplicate/constraint | DB auto/manual |
| 422 | UnprocessableEntityException | Semantic invalid | Manual |
| 500 | InternalServerErrorException | Server error | Auto/manual |
| 503 | ServiceUnavailableException | Temp unavailable | Manual |
Controller Pattern
Section titled “Controller Pattern”@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); }}