Analysis and Documentation: - Add ANALISIS-ALINEACION-WORKSPACE-2025-12-08.md with comprehensive gap analysis - Document SIMCO v3.2 system with 20+ directives - Identify alignment gaps between orchestration and projects New SaaS Products Structure: - Create apps/products/pos-micro/ - Ultra basic POS (~100 MXN/month) - Target: Mexican informal market (street vendors, small stores) - Features: Offline-first PWA, WhatsApp bot, minimal DB (~10 tables) - Create apps/products/erp-basico/ - Austere ERP (~300-500 MXN/month) - Target: SMBs needing full ERP without complexity - Features: Inherits from erp-core, modular pricing SaaS Layer: - Create apps/saas/ structure (billing, portal, admin, onboarding) - Add README.md and CONTEXTO-SAAS.md documentation Vertical Alignment: - Verify HERENCIA-ERP-CORE.md exists in all verticals - Add HERENCIA-SPECS-CORE.md to verticals - Update orchestration inventories Updates: - Update WORKSPACE-STATUS.md with new products and analysis - Update suite inventories with new structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
15 KiB
15 KiB
PATRÓN: MANEJO DE EXCEPCIONES
Versión: 1.0.0 Fecha: 2025-12-08 Aplica a: Backend (NestJS/Express) Prioridad: OBLIGATORIA
PROPÓSITO
Definir patrones estándar de manejo de errores para garantizar respuestas consistentes y debugging efectivo.
PRINCIPIO FUNDAMENTAL
╔══════════════════════════════════════════════════════════════════════╗
║ EXCEPCIONES CLARAS Y CONSISTENTES ║
║ ║
║ 1. Usar HttpException estándar de NestJS ║
║ 2. Mensajes claros para el usuario ║
║ 3. Detalles técnicos en logs (no en response) ║
║ 4. Códigos HTTP semánticos ║
╚══════════════════════════════════════════════════════════════════════╝
1. EXCEPCIONES HTTP ESTÁNDAR
Matriz de Decisión
| Situación | Exception | HTTP Code | Cuándo Usar |
|---|---|---|---|
| Recurso no existe | NotFoundException |
404 | findOne retorna null |
| Ya existe (duplicado) | ConflictException |
409 | Violación de unique |
| Datos inválidos | BadRequestException |
400 | Validación de negocio falla |
| Sin autenticación | UnauthorizedException |
401 | Token falta o inválido |
| Sin permiso | ForbiddenException |
403 | Autenticado pero sin permiso |
| Método no permitido | MethodNotAllowedException |
405 | HTTP method incorrecto |
| Payload muy grande | PayloadTooLargeException |
413 | Archivo/body excede límite |
| Rate limit | TooManyRequestsException |
429 | Muchas peticiones |
| Error interno | InternalServerErrorException |
500 | Error inesperado |
| Servicio no disponible | ServiceUnavailableException |
503 | DB/API externa caída |
2. PATRONES POR CASO
Not Found (404)
// PATRÓN: Recurso no encontrado
async findOne(id: string): Promise<UserEntity> {
const user = await this.repository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException(`Usuario con ID ${id} no encontrado`);
}
return user;
}
// PATRÓN: Recurso relacionado no encontrado
async assignRole(userId: string, roleId: string): Promise<void> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException(`Usuario ${userId} no encontrado`);
}
const role = await this.roleRepository.findOne({ where: { id: roleId } });
if (!role) {
throw new NotFoundException(`Rol ${roleId} no encontrado`);
}
// Proceder...
}
Conflict (409)
// PATRÓN: Duplicado por campo único
async create(dto: CreateUserDto): Promise<UserEntity> {
const existing = await this.repository.findOne({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException('El email ya está registrado');
}
return this.repository.save(this.repository.create(dto));
}
// PATRÓN: Duplicado con múltiples campos
async createProduct(dto: CreateProductDto): Promise<ProductEntity> {
const existing = await this.repository.findOne({
where: {
sku: dto.sku,
tenantId: dto.tenantId,
},
});
if (existing) {
throw new ConflictException(
`Ya existe un producto con SKU ${dto.sku} en este tenant`
);
}
return this.repository.save(this.repository.create(dto));
}
Bad Request (400)
// PATRÓN: Validación de negocio
async transfer(dto: TransferDto): Promise<void> {
if (dto.fromAccountId === dto.toAccountId) {
throw new BadRequestException(
'La cuenta origen y destino no pueden ser iguales'
);
}
const fromAccount = await this.findAccount(dto.fromAccountId);
if (fromAccount.balance < dto.amount) {
throw new BadRequestException('Saldo insuficiente para la transferencia');
}
// Proceder...
}
// PATRÓN: Estado inválido para operación
async cancelOrder(orderId: string): Promise<void> {
const order = await this.findOne(orderId);
if (order.status === 'delivered') {
throw new BadRequestException(
'No se puede cancelar un pedido ya entregado'
);
}
if (order.status === 'cancelled') {
throw new BadRequestException('El pedido ya está cancelado');
}
// Proceder...
}
Forbidden (403)
// PATRÓN: Sin permiso sobre recurso
async update(userId: string, dto: UpdateUserDto, currentUser: User): Promise<UserEntity> {
const user = await this.findOne(userId);
// Solo el usuario mismo o admin puede editar
if (user.id !== currentUser.id && !currentUser.roles.includes('admin')) {
throw new ForbiddenException('No tienes permiso para editar este usuario');
}
return this.repository.save({ ...user, ...dto });
}
// PATRÓN: Límite de plan/tenant
async createProject(dto: CreateProjectDto, tenant: Tenant): Promise<Project> {
const projectCount = await this.repository.count({
where: { tenantId: tenant.id },
});
if (projectCount >= tenant.plan.maxProjects) {
throw new ForbiddenException(
`Tu plan permite máximo ${tenant.plan.maxProjects} proyectos. ` +
'Actualiza tu plan para crear más.'
);
}
// Proceder...
}
Unauthorized (401)
// PATRÓN: Token inválido (en Guard)
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
if (info?.name === 'TokenExpiredError') {
throw new UnauthorizedException('Tu sesión ha expirado');
}
if (info?.name === 'JsonWebTokenError') {
throw new UnauthorizedException('Token inválido');
}
throw new UnauthorizedException('No autenticado');
}
return user;
}
}
// PATRÓN: Credenciales incorrectas
async login(dto: LoginDto): Promise<TokenResponse> {
const user = await this.userRepository.findOne({
where: { email: dto.email },
});
if (!user || !(await bcrypt.compare(dto.password, user.password))) {
throw new UnauthorizedException('Credenciales incorrectas');
}
// Generar token...
}
Internal Server Error (500)
// PATRÓN: Error inesperado con logging
async processPayment(dto: PaymentDto): Promise<PaymentResult> {
try {
const result = await this.paymentGateway.charge(dto);
return result;
} catch (error) {
// Log detallado para debugging
this.logger.error('Error procesando pago', {
dto,
error: error.message,
stack: error.stack,
gatewayResponse: error.response?.data,
});
// Respuesta genérica al usuario
throw new InternalServerErrorException(
'Error procesando el pago. Por favor intenta de nuevo.'
);
}
}
3. ESTRUCTURA DE RESPUESTA DE ERROR
Formato Estándar
// Respuesta de error estándar
interface ErrorResponse {
statusCode: number;
message: string | string[];
error: string;
timestamp: string;
path: string;
}
// Ejemplo de respuesta
{
"statusCode": 404,
"message": "Usuario con ID abc-123 no encontrado",
"error": "Not Found",
"timestamp": "2024-01-15T10:30:00.000Z",
"path": "/api/v1/users/abc-123"
}
Exception Filter Global
// filters/http-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string | string[] = 'Error interno del servidor';
let error = 'Internal Server Error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object') {
message = (exceptionResponse as any).message || exception.message;
error = (exceptionResponse as any).error || exception.name;
} else {
message = exceptionResponse;
}
} else if (exception instanceof Error) {
// Log error interno completo
this.logger.error('Unhandled exception', {
message: exception.message,
stack: exception.stack,
path: request.url,
method: request.method,
body: request.body,
user: (request as any).user?.id,
});
}
response.status(status).json({
statusCode: status,
message,
error,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
4. EXCEPCIONES PERSONALIZADAS
Cuándo Crear Excepción Custom
// CREAR excepción custom cuando:
// 1. Necesitas información adicional estructurada
// 2. El error es específico del dominio
// 3. Quieres diferenciar en handling
// NO crear custom para errores HTTP estándar
// ❌ class UserNotFoundException extends HttpException {} // Usar NotFoundException
Ejemplo Excepción Custom
// exceptions/business.exception.ts
export class InsufficientBalanceException extends BadRequestException {
constructor(
public readonly currentBalance: number,
public readonly requiredAmount: number,
) {
super({
message: `Saldo insuficiente. Tienes $${currentBalance}, necesitas $${requiredAmount}`,
error: 'Insufficient Balance',
currentBalance,
requiredAmount,
deficit: requiredAmount - currentBalance,
});
}
}
// Uso
if (account.balance < amount) {
throw new InsufficientBalanceException(account.balance, amount);
}
Excepción para Errores de Integración
// exceptions/integration.exception.ts
export class PaymentGatewayException extends ServiceUnavailableException {
constructor(
public readonly gateway: string,
public readonly originalError: string,
) {
super({
message: 'Error de conexión con el servicio de pagos',
error: 'Payment Gateway Error',
gateway,
});
}
}
// Uso
try {
await stripe.charges.create(params);
} catch (error) {
throw new PaymentGatewayException('Stripe', error.message);
}
5. LOGGING DE ERRORES
Niveles de Log
// logger.service.ts
@Injectable()
export class AppLogger {
private readonly logger = new Logger();
// ERROR: Errores que requieren atención
error(message: string, context: object) {
this.logger.error(message, { ...context, timestamp: new Date() });
}
// WARN: Situaciones anómalas pero manejadas
warn(message: string, context: object) {
this.logger.warn(message, { ...context, timestamp: new Date() });
}
// INFO: Eventos importantes del negocio
info(message: string, context: object) {
this.logger.log(message, { ...context, timestamp: new Date() });
}
// DEBUG: Información para desarrollo
debug(message: string, context: object) {
this.logger.debug(message, { ...context, timestamp: new Date() });
}
}
Qué Loggear
// SIEMPRE loggear en ERROR:
{
message: 'Descripción del error',
stack: error.stack, // Stack trace
userId: currentUser?.id, // Quién causó el error
tenantId: currentUser?.tenant, // Contexto de tenant
requestId: request.id, // Para tracing
path: request.url, // Endpoint
method: request.method, // HTTP method
body: sanitize(request.body), // Body (sin passwords)
query: request.query, // Query params
timestamp: new Date(), // Cuándo
}
// NUNCA loggear:
// - Passwords
// - Tokens
// - Tarjetas de crédito
// - Datos sensibles (CURP, RFC, etc.)
6. DOCUMENTACIÓN SWAGGER
// Documentar posibles errores en controller
@Post()
@ApiOperation({ summary: 'Crear usuario' })
@ApiResponse({ status: 201, description: 'Usuario creado', type: UserEntity })
@ApiResponse({ status: 400, description: 'Datos inválidos' })
@ApiResponse({ status: 409, description: 'Email ya registrado' })
@ApiResponse({ status: 401, description: 'No autenticado' })
@ApiResponse({ status: 403, description: 'Sin permisos' })
async create(@Body() dto: CreateUserDto): Promise<UserEntity> {
return this.service.create(dto);
}
7. CHECKLIST DE MANEJO DE ERRORES
Service:
[ ] Cada método que busca por ID usa NotFoundException si no existe
[ ] Operaciones de creación verifican duplicados → ConflictException
[ ] Validaciones de negocio usan BadRequestException
[ ] Verificaciones de permisos usan ForbiddenException
[ ] Errores de integraciones externas tienen try/catch
[ ] Errores inesperados se loggean con contexto completo
[ ] Mensajes de error son claros para el usuario
Controller:
[ ] Swagger documenta posibles errores
[ ] @ApiResponse para cada código de error posible
Global:
[ ] GlobalExceptionFilter configurado
[ ] Logger configurado para errores
[ ] Errores no exponen detalles técnicos en producción
ANTI-PATRONES
// ❌ NUNCA: Mensaje genérico sin contexto
throw new BadRequestException('Error');
// ✅ SIEMPRE: Mensaje descriptivo
throw new BadRequestException('El email ya está registrado');
// ❌ NUNCA: Exponer stack trace en response
throw new Error(error.stack);
// ✅ SIEMPRE: Log interno, mensaje limpio al usuario
this.logger.error('Error detallado', { stack: error.stack });
throw new InternalServerErrorException('Error procesando solicitud');
// ❌ NUNCA: Catch vacío
try { ... } catch (e) { }
// ✅ SIEMPRE: Manejar o re-lanzar
try { ... } catch (e) {
this.logger.error('Context', { error: e });
throw new InternalServerErrorException('Mensaje usuario');
}
// ❌ NUNCA: 500 para errores de validación
throw new InternalServerErrorException('Email inválido');
// ✅ SIEMPRE: Código HTTP semántico
throw new BadRequestException('Email inválido');
Versión: 1.0.0 | Sistema: SIMCO | Tipo: Patrón de Excepciones