API Response Interceptor
Overview
Global interceptor system ensuring all API responses follow a consistent structure with standardized error handling.
Architecture
┌─────────────────────────────┐
│ Incoming HTTP Request │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ ValidationPipe │
│ • Validates DTOs │
└──────────────┬──────────────┘
│
┌──────┴──────┐
│ │
Success Error
│ │
▼ ▼
Controller HttpExceptionFilter
│ │
▼ │
ResponseInterceptor │
│ │
└──────┬──────┘
▼
Standardized ResponseResponse Structure
All responses follow this structure:
{
data: any; // Response payload
success: boolean; // true for success, false for errors
metadata?: any; // Optional (pagination, etc.)
error?: { // Only present when success: false
code: string;
message: string;
details?: unknown;
};
}Success Examples
Simple Response:
{
"success": true,
"data": { "message": "User created", "id": 123 }
}With Metadata:
{
"success": true,
"data": [{ "id": 1, "name": "User 1" }],
"metadata": { "total": 100, "page": 1 }
}Error Examples
Validation Error:
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [{ "property": "email", "constraints": {...} }]
}
}Not Found:
{
"success": false,
"data": null,
"error": {
"code": "NOT_FOUND",
"message": "User not found"
}
}Error Codes
| Code | Status | Description |
|---|---|---|
VALIDATION_ERROR | 400 | DTO validation failed |
BAD_REQUEST | 400 | Invalid input |
UNAUTHORIZED | 401 | Authentication failed |
FORBIDDEN | 403 | Not authorized |
NOT_FOUND | 404 | Resource not found |
TOO_MANY_REQUESTS | 429 | Rate limited |
INTERNAL_SERVER_ERROR | 500 | Unexpected error |
SERVICE_UNAVAILABLE | 503 | Service down |
Implementation
Components
Type Definitions: packages/types/src/types/api-response.types.ts
ApiResponse<T>interfaceApiErrorinterfaceApiErrorCodeenum
Response Interceptor: src/common/interceptors/response.interceptor.ts
- Wraps successful responses automatically
- Detects pre-wrapped responses to avoid double-wrapping
Exception Filter: src/common/filters/http-exception.filter.ts
- Catches all exceptions
- Formats validation errors with details
- Maps HTTP status codes to error codes
- Logs errors appropriately
Registration: src/main.ts
app.useGlobalPipes(new ValidationPipe({...}));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseInterceptor());Usage
Controllers
No changes required - existing controllers work automatically:
@Get(':id')
async findOne(@Param('id') id: string) {
const user = await this.service.findOne(id);
if (!user) throw new NotFoundException('User not found');
return user; // Auto-wrapped
}Validation
Use DTOs with decorators:
export class CreateUserDto {
@IsEmail()
email: string;
@MinLength(3)
name: string;
}
@Post()
create(@Body() dto: CreateUserDto) {
// Validation errors auto-formatted
return this.service.create(dto);
}With Metadata
Return data and metadata separately:
@Get()
async findAll(@Query() query: PaginationDto) {
const { items, total, page } = await this.service.findAll(query);
return { data: items, metadata: { total, page } };
}Custom Status Codes
Use @HttpCode() decorator:
@Post()
@HttpCode(201)
create(@Body() dto: CreateDto) {
return this.service.create(dto);
}Testing
Manual testing:
# Success response
curl http://localhost:5500/
# 404 Error
curl http://localhost:5500/nonexistent
# Validation error
curl -X POST http://localhost:5500/auth \
-H "Content-Type: application/json" -d '{}'E2E tests expect wrapped responses:
it("should return wrapped response", () => {
return request(app.getHttpServer())
.get("/users")
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty("success", true);
expect(res.body).toHaveProperty("data");
});
});Best Practices
- Return data directly - Let the interceptor handle wrapping
- Use standard exceptions -
throw new NotFoundException(...) - Include metadata for lists - Pagination info, record counts
- Use DTOs for validation - Leverage class-validator
- Keep error messages clear - User-friendly and actionable
Frontend Integration
The frontend ApiClient handles the structure automatically:
const response = await apiClient.get<User[]>("/users");
// response.data contains unwrapped data
// response.success indicates success/failure
// response.error contains error details if failedMigration
Existing endpoints work without changes:
// Before & After - Same code
@Get()
findAll() {
return [{ id: 1 }, { id: 2 }];
}
// Response auto-wrapped: { success: true, data: [...] }For custom responses, structure as data + metadata:
// Before
return { success: true, users: [...], count: 10 };
// After
return { data: [...], metadata: { count: 10 } };