Backend API Standards (NestJS)
Project Structure
This monorepo contains:
- api/: NestJS backend
- web/: Frontend application
- shared/: Shared code (interfaces, models, constants, helpers)
project/
├── api/src/
│ ├── modules/products/
│ │ ├── dtos/
│ │ ├── entities/
│ │ ├── products.controller.ts
│ │ ├── products.module.ts
│ │ └── products.service.ts
│ ├── interceptors/response.interceptor.ts
│ └── filters/validation.filter.ts
├── web/src/
│ └── rest-client/verbs.ts
└── shared/modules/products/
├── interfaces/
├── models/
├── products.endpoints.ts
└── products.constants.ts
Standard Response Structure
All API responses use ResponseDto<T>:
// shared/helpers/response.helper.ts
export interface ResponseDto<T> {
success: boolean; // Operation success status
code: string; // Operation result code (e.g., "PRODUCT_CREATED")
message: string; // Human-readable message (English only)
httpCode: number; // HTTP status code
data: T | null; // Response payload or null on error
}
Response Interceptor
Automatically sets HTTP status codes from ResponseDto.httpCode:
// api/src/interceptors/response.interceptor.ts
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const response = context.switchToHttp().getResponse<Response>();
return next.handle().pipe(
map((data: ResponseDto<any>) => {
if (data?.httpCode) response.status(data.httpCode);
return data;
})
);
}
}
Validation
Global ValidationPipe
// api/src/main.ts
app.useGlobalPipes(new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
transformOptions: { enableImplicitConversion: true }
}));
app.useGlobalFilters(new ValidationFilter());
ValidationFilter
Formats validation errors as standard responses:
// api/src/filters/validation.filter.ts
@Catch(BadRequestException)
export class ValidationFilter implements ExceptionFilter {
catch(exception: BadRequestException, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<Response>();
const exceptionResponse = exception.getResponse() as any;
if (Array.isArray(exceptionResponse.message)) {
const standardResponse: ResponseDto<string[]> = {
success: false,
code: ValidationCodes.VALIDATION_ERROR,
message: ValidationMessages[ValidationCodes.VALIDATION_ERROR].en,
httpCode: HttpStatus.BAD_REQUEST,
data: exceptionResponse.message
};
response.status(HttpStatus.BAD_REQUEST).json(standardResponse);
return;
}
response.status(HttpStatus.BAD_REQUEST).json({
success: false,
code: ValidationCodes.BAD_REQUEST,
message: ValidationMessages[ValidationCodes.BAD_REQUEST].en,
httpCode: HttpStatus.BAD_REQUEST,
data: null
});
}
}
Constants for Codes and Messages
Define codes and messages centrally in shared:
// shared/modules/products/products.constants.ts
export enum ProductCodes {
PRODUCT_CREATED = 'PRODUCT_CREATED',
PRODUCT_NOT_FOUND = 'PRODUCT_NOT_FOUND',
PRODUCT_ALREADY_EXISTS = 'PRODUCT_ALREADY_EXISTS',
ERROR_CREATING_PRODUCT = 'ERROR_CREATING_PRODUCT'
}
export const ProductMessages: Record<ProductCodes, Record<string, string>> = {
[ProductCodes.PRODUCT_CREATED]: {
en: 'Product created successfully',
es: 'Producto creado exitosamente'
},
[ProductCodes.PRODUCT_NOT_FOUND]: {
en: 'Product not found',
es: 'Producto no encontrado'
},
// ... more messages
};
Key Rule: Always use English (.en) in API responses, even if multiple languages are defined.
Models and Entities
Shared Models
Models are interfaces shared between frontend and backend:
// shared/modules/products/models/product.model.ts
export interface ProductModel {
id: string;
name: string;
description: string;
price: number;
createdAt: Date;
updatedAt: Date;
}
API Entities
Entities implement models with ORM decorators:
// api/src/modules/products/entities/product.entity.ts
import { ProductModel } from '@shared/modules/products/models/product.model';
@Entity('products')
export class Product implements ProductModel {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('varchar', { unique: true })
name: string;
@Column('text')
description: string;
@Column('decimal', { precision: 10, scale: 2 })
price: number;
@CreateDateColumn({ type: 'timestamp without time zone' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp without time zone' })
updatedAt: Date;
}
Interfaces and DTOs
Interfaces (Shared)
Define data structures for requests and responses:
// shared/modules/products/interfaces/create-product-request.interface.ts
export interface ICreateProductRequest {
name: string;
description: string;
price: number;
}
// shared/modules/products/interfaces/create-product-response.interface.ts
export interface ICreateProductResponse {
id: string;
name: string;
description: string;
price: number;
createdAt: Date;
updatedAt: Date;
}
Request DTOs (API Only)
DTOs implement interfaces and add validations. Never create response DTOs - use ResponseDto<Interface>:
// api/src/modules/products/dtos/create-product-request.dto.ts
import { ICreateProductRequest } from '@shared/modules/products/interfaces/create-product-request.interface';
export class CreateProductRequestDto implements ICreateProductRequest {
@ApiProperty({ example: 'Smartphone XYZ' })
@IsString({ message: 'Name must be a string' })
@IsNotEmpty({ message: 'Name is required' })
name: string;
@ApiProperty({ example: 'Latest generation smartphone' })
@IsString({ message: 'Description must be a string' })
@IsNotEmpty({ message: 'Description is required' })
description: string;
@ApiProperty({ example: 1299.99 })
@IsNumber({}, { message: 'Price must be a number' })
@Min(0, { message: 'Price cannot be negative' })
@IsNotEmpty({ message: 'Price is required' })
price: number;
}
Naming Conventions
Interfaces (Shared)
- Pattern:
I<Verb><EntityName><Request|Response> - Files:
<verb>-<entity-name>-<request|response>.interface.ts - Examples:
ICreateProductRequest→create-product-request.interface.tsIGetProductResponse→get-product-response.interface.ts
DTOs (API)
- Pattern:
<Verb><EntityName>RequestDto(requests only) - Files:
<verb>-<entity-name>-request.dto.ts - Examples:
CreateProductRequestDto→create-product-request.dto.tsUpdateProductRequestDto→update-product-request.dto.ts
Recommended Verbs
- Create: Creating resources
- Get: Retrieving resources
- Update: Updating resources
- Delete/Remove: Deleting resources
- Custom verbs allowed (e.g., SignIn, SignUp) - maintain consistency within modules
Services
Services contain business logic and must:
- Map entities to response interfaces (never return entities directly)
- Use try/catch for error handling
- Always return
ResponseDto<T> - Use English messages (
.en)
// api/src/modules/products/products.service.ts
@Injectable()
export class ProductsService {
private readonly logger = new Logger(ProductsService.name);
constructor(
@InjectRepository(Product)
private readonly productsRepository: Repository<Product>
) {}
async create(dto: CreateProductRequestDto): Promise<ResponseDto<ICreateProductResponse>> {
try {
const existing = await this.productsRepository.findOne({
where: { name: dto.name }
});
if (existing) {
return {
success: false,
code: ProductCodes.PRODUCT_ALREADY_EXISTS,
message: ProductMessages[ProductCodes.PRODUCT_ALREADY_EXISTS].en,
httpCode: HttpStatus.CONFLICT,
data: null
};
}
const product = this.productsRepository.create(dto);
const saved = await this.productsRepository.save(product);
// Map entity to response interface
const response: ICreateProductResponse = {
id: saved.id,
name: saved.name,
description: saved.description,
price: saved.price,
createdAt: saved.createdAt,
updatedAt: saved.updatedAt
};
return {
success: true,
code: ProductCodes.PRODUCT_CREATED,
message: ProductMessages[ProductCodes.PRODUCT_CREATED].en,
httpCode: HttpStatus.CREATED,
data: response
};
} catch (error) {
this.logger.error(`Error creating product: ${error.message}`, error.stack);
return {
success: false,
code: ProductCodes.ERROR_CREATING_PRODUCT,
message: ProductMessages[ProductCodes.ERROR_CREATING_PRODUCT].en,
httpCode: HttpStatus.INTERNAL_SERVER_ERROR,
data: null
};
}
}
}
Key Rules:
- Never use NestJS exceptions for business logic errors
- Always return
ResponseDtowith proper information - Always use
.enfor messages - Return type:
Promise<ResponseDto<T>>
Controllers
Controllers delegate to services and use centralized endpoint constants:
// api/src/modules/products/products.controller.ts
import { PRODUCTS_BASE_PATH, PRODUCTS_PATHS } from '@shared/modules/products/products.endpoints';
@ApiTags('Products')
@Controller(PRODUCTS_BASE_PATH)
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Post(PRODUCTS_PATHS.CREATE)
@ApiBearerAuth()
@ApiHeader({ name: 'x-refresh-token' })
@ApiHeader({ name: 'x-id-token' })
@ApiOperation({ summary: 'Create a new product' })
create(@Body() dto: CreateProductRequestDto): Promise<ResponseDto<ICreateProductResponse>> {
return this.productsService.create(dto);
}
@Get(PRODUCTS_PATHS.GET_ALL)
@ApiBearerAuth()
@ApiHeader({ name: 'x-refresh-token' })
@ApiHeader({ name: 'x-id-token' })
@ApiOperation({ summary: 'Get all products' })
findAll(@Query() query: GetProductRequestDto): Promise<ResponseDto<IGetProductResponse[]>> {
return this.productsService.findAll(query);
}
@Get(PRODUCTS_PATHS.GET_BY_ID)
@ApiBearerAuth()
@ApiHeader({ name: 'x-refresh-token' })
@ApiHeader({ name: 'x-id-token' })
@ApiOperation({ summary: 'Get product by ID' })
findOne(@Param('id') id: string): Promise<ResponseDto<IGetProductResponse>> {
return this.productsService.findOne(id);
}
@Put(PRODUCTS_PATHS.UPDATE)
@ApiBearerAuth()
@ApiHeader({ name: 'x-refresh-token' })
@ApiHeader({ name: 'x-id-token' })
@ApiOperation({ summary: 'Update a product' })
update(@Body() dto: UpdateProductRequestDto): Promise<ResponseDto<IUpdateProductResponse>> {
return this.productsService.update(dto);
}
@Delete(PRODUCTS_PATHS.DELETE)
@ApiBearerAuth()
@ApiHeader({ name: 'x-refresh-token' })
@ApiHeader({ name: 'x-id-token' })
@ApiOperation({ summary: 'Delete a product' })
remove(@Param('id') id: string): Promise<ResponseDto<null>> {
return this.productsService.remove(id);
}
}
Centralized Routes
Define all routes in shared endpoints file:
// shared/modules/products/products.endpoints.ts
export const PRODUCTS_BASE_PATH = 'products';
export const PRODUCTS_PATHS = {
GET_ALL: '',
GET_BY_ID: ':id',
CREATE: '',
UPDATE: '',
DELETE: ':id'
};
export const PRODUCTS_ENDPOINTS = {
GET_ALL: `${PRODUCTS_BASE_PATH}/${PRODUCTS_PATHS.GET_ALL}`,
GET_BY_ID: `${PRODUCTS_BASE_PATH}/${PRODUCTS_PATHS.GET_BY_ID}`,
CREATE: `${PRODUCTS_BASE_PATH}/${PRODUCTS_PATHS.CREATE}`,
UPDATE: `${PRODUCTS_BASE_PATH}/${PRODUCTS_PATHS.UPDATE}`,
DELETE: `${PRODUCTS_BASE_PATH}/${PRODUCTS_PATHS.DELETE}`
};
Structure:
[ENTITY]_BASE_PATH: Base route (e.g., 'products')[ENTITY]_PATHS: Route suffixes for each operation[ENTITY]_ENDPOINTS: Complete routes for frontend use
Usage:
- Controllers use
[ENTITY]_BASE_PATHand[ENTITY]_PATHS - Frontend uses
[ENTITY]_ENDPOINTS
Frontend HTTP Client
Use typed HTTP verbs with ResponseDto:
// web/src/rest-client/verbs.ts
import { ResponseDto } from '@shared/helpers/response.helper';
export const get = async <T>(
endpoint: string,
params?: Record<string, unknown>,
headers: Record<string, string> = {}
): Promise<ResponseDto<T>> => {
const config = await createConfigurations(endpoint, params ?? {}, undefined, headers, Methods.Get);
try {
const response = await axios(config);
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return error.response.data;
}
throw error;
}
};
// Similar for post, put, patch, remove...
Frontend Service Example
// web/src/modules/auth.services.ts
import { ResponseDto } from '@shared/helpers/response.helper';
import { ISignInResponse } from '@shared/modules/auth/interfaces/sign-in-response.interface';
import { SignInPostRequestDto } from '@shared/modules/auth/dtos/sign-in-post.dtos';
import { post } from '@/rest-client/verbs';
import { AUTH_ENDPOINTS } from '@shared/modules/auth/auth.endpoints';
export const loginPost = async (
credentials: SignInPostRequestDto
): Promise<ResponseDto<ISignInResponse>> => {
const response = await post<ISignInResponse>(
AUTH_ENDPOINTS.SIGN_IN,
{},
{ ...credentials }
);
if (response.data) {
setCookie({ name: 'refresh_token', value: response.data.refreshToken, maxAge: 1000 * 60 * 60 * 24 * 30 });
setCookie({ name: 'id_token', value: response.data.idToken, maxAge: 1000 * 60 * 60 * 24 });
setCookie({ name: 'access_token', value: response.data.accessToken, maxAge: 1000 * 60 * 60 * 24 });
}
return response;
};
Module Creation Checklist
When creating a new module, follow this order:
- Create model in
shared/modules/[entity]/models/[entity].model.ts - Define interfaces in
shared/modules/[entity]/interfaces/ - Create constants file in
shared/modules/[entity]/[entity].constants.ts - Define endpoints in
shared/modules/[entity]/[entity].endpoints.ts - Implement entity in
api/src/modules/[entity]/entities/ - Create request DTOs in
api/src/modules/[entity]/dtos/ - Develop service in
api/src/modules/[entity]/[entity].service.ts - Implement controller in
api/src/modules/[entity]/[entity].controller.ts - Verify naming conventions
Key Principles
- Consistency: All responses follow standard format
- Security: Never expose raw entities; map to interfaces
- Type Safety: Shared interfaces ensure frontend/backend alignment
- Validation: Centralized validation with clear error messages
- Maintainability: Constants eliminate magic strings
- Separation of Concerns: Interfaces define structure, DTOs add validation
- English Only: API messages always use English
- No Response DTOs: Use
ResponseDto<Interface>instead