Saltar al contenido principal

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:
    • ICreateProductRequestcreate-product-request.interface.ts
    • IGetProductResponseget-product-response.interface.ts

DTOs (API)

  • Pattern: <Verb><EntityName>RequestDto (requests only)
  • Files: <verb>-<entity-name>-request.dto.ts
  • Examples:
    • CreateProductRequestDtocreate-product-request.dto.ts
    • UpdateProductRequestDtoupdate-product-request.dto.ts
  • 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:

  1. Map entities to response interfaces (never return entities directly)
  2. Use try/catch for error handling
  3. Always return ResponseDto<T>
  4. 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 ResponseDto with proper information
  • Always use .en for 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:

  1. [ENTITY]_BASE_PATH: Base route (e.g., 'products')
  2. [ENTITY]_PATHS: Route suffixes for each operation
  3. [ENTITY]_ENDPOINTS: Complete routes for frontend use

Usage:

  • Controllers use [ENTITY]_BASE_PATH and [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

  1. Consistency: All responses follow standard format
  2. Security: Never expose raw entities; map to interfaces
  3. Type Safety: Shared interfaces ensure frontend/backend alignment
  4. Validation: Centralized validation with clear error messages
  5. Maintainability: Constants eliminate magic strings
  6. Separation of Concerns: Interfaces define structure, DTOs add validation
  7. English Only: API messages always use English
  8. No Response DTOs: Use ResponseDto<Interface> instead