Volver al inicio
Backend

Backend - Patrones de Diseño

Patrones de diseño fundamentales para desarrollo backend: Singleton, Factory, Repository, Dependency Injection y Circuit Breaker

ConceptosBest Practices

Introducción

Los patrones de diseño son soluciones reutilizables a problemas comunes en el desarrollo de software. Conocerlos te ayudará a escribir código más mantenible y escalable.

Singleton

1 preguntas

1

¿Qué es el patrón Singleton y cuándo usarlo?

Definición: Asegura que una clase tenga solo una instancia y proporciona un punto de acceso global a ella.
Características:
✓Solo una instancia en toda la aplicación
✓Acceso global controlado
✓Inicialización perezosa (lazy initialization)
Implementación en JavaScript/TypeScript:
Clásica:
javascript
class DatabaseConnection {
  constructor() {
    if (DatabaseConnection.instance) {
      return DatabaseConnection.instance;
    }
    
    this.connection = this.connect();
    DatabaseConnection.instance = this;
    return this;
  }
  
  connect() {
    // Lógica de conexión
    return { connected: true };
  }
}

// Uso
const db1 = new DatabaseConnection();
const db2 = new DatabaseConnection();
console.log(db1 === db2); // true - misma instancia
Con módulo ES6:
javascript
// database.js
export default new DatabaseConnection();

// Siempre la misma instancia al importar
En Node.js (módulos son singletons):
javascript
// logger.js
class Logger {
  log(message) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
}

module.exports = new Logger(); // Singleton por defecto
Cuándo usar:
✓Recursos compartidos:
• Conexión a base de datos
• Logger
• Cache
• Configuración
✓Control de acceso:
• Pool de conexiones
• Gestor de archivos
Cuándo NO usar:
✗ Testing: Dificulta testing (mocks)
✗ Estado global: Puede causar problemas
✗ Concurrencia: Puede tener problemas en multi-thread
Problemas comunes:
✗ Testing difícil:
javascript
// ❌ Difícil de mockear
const db = DatabaseConnection.getInstance();

// ✅ Mejor: Dependency Injection
class UserService {
  constructor(db) {
    this.db = db; // Inyectado, fácil de mockear
  }
}
✗ Estado global:
• Puede causar efectos secundarios
• Dificulta debugging
Alternativas modernas:
✓Dependency Injection: En lugar de singleton global
✓Módulos ES6: Ya son singletons por naturaleza
✓Contenedores DI: NestJS, InversifyJS
Mejores prácticas:
•Usar solo cuando realmente necesitas una instancia única
•Preferir Dependency Injection cuando sea posible
•Documentar por qué es singleton
•Considerar thread-safety si aplica

Factory

1 preguntas

1

¿Qué es el patrón Factory y cuándo usarlo?

Definición: Patrón que proporciona una interfaz para crear objetos sin especificar su clase exacta.
Tipos:
1. Simple Factory:
javascript
class UserFactory {
  static create(type) {
    switch (type) {
      case 'admin':
        return new AdminUser();
      case 'regular':
        return new RegularUser();
      default:
        throw new Error('Unknown user type');
    }
  }
}

// Uso
const admin = UserFactory.create('admin');
const user = UserFactory.create('regular');
2. Factory Method:
javascript
class DatabaseFactory {
  createConnection(type) {
    if (type === 'mysql') {
      return new MySQLConnection();
    } else if (type === 'postgres') {
      return new PostgresConnection();
    }
  }
}

class MySQLFactory extends DatabaseFactory {
  createConnection() {
    return new MySQLConnection();
  }
}
3. Abstract Factory:
javascript
// Factory para crear familias de objetos relacionados
class UIFactory {
  createButton() {}
  createDialog() {}
}

class WindowsFactory extends UIFactory {
  createButton() { return new WindowsButton(); }
  createDialog() { return new WindowsDialog(); }
}

class MacFactory extends UIFactory {
  createButton() { return new MacButton(); }
  createDialog() { return new MacDialog(); }
}
Cuándo usar:
✓Creación compleja:
• Objetos con lógica de creación compleja
• Múltiples pasos para crear objeto
✓Múltiples tipos:
• Diferentes variantes del mismo tipo
• Selección basada en configuración
✓Desacoplamiento:
• Cliente no conoce clases concretas
• Fácil agregar nuevos tipos
Ejemplo práctico:
javascript
// Factory para crear diferentes tipos de notificaciones
class NotificationFactory {
  static create(type, config) {
    switch (type) {
      case 'email':
        return new EmailNotification(config.email);
      case 'sms':
        return new SMSNotification(config.phone);
      case 'push':
        return new PushNotification(config.deviceId);
      default:
        throw new Error(`Unknown notification type: ${type}`);
    }
  }
}

// Uso
const notification = NotificationFactory.create('email', { email: 'user@example.com' });
notification.send('Hello!');
Ventajas:
✓Desacopla creación de uso
✓Fácil agregar nuevos tipos
✓Centraliza lógica de creación
✓Facilita testing (mock factory)
Desventajas:
✗ Puede añadir complejidad
✗ Más código
✗ Puede volverse complejo con muchos tipos
Mejores prácticas:
•Usar cuando la creación es compleja
•Mantener factory simple
•Considerar Builder para objetos muy complejos

Repository

1 preguntas

1

¿Qué es el patrón Repository y cuándo usarlo?

Definición: Capa de abstracción entre la lógica de negocio y la capa de acceso a datos.
Propósito:
✓Abstraer acceso a datos
✓Facilitar testing (mock repository)
✓Cambiar implementación de DB sin afectar lógica
✓Centralizar queries
Estructura básica:
typescript
// Interface
interface UserRepository {
  findById(id: string): Promise<User | null>;
  findAll(): Promise<User[]>;
  create(user: User): Promise<User>;
  update(id: string, user: Partial<User>): Promise<User>;
  delete(id: string): Promise<void>;
}

// Implementación con SQL
class SQLUserRepository implements UserRepository {
  constructor(private db: Database) {}
  
  async findById(id: string): Promise<User | null> {
    const row = await this.db.query(
      'SELECT * FROM users WHERE id = ?',
      [id]
    );
    return row ? this.toUser(row) : null;
  }
  
  async create(user: User): Promise<User> {
    const result = await this.db.query(
      'INSERT INTO users (name, email) VALUES (?, ?)',
      [user.name, user.email]
    );
    return { ...user, id: result.insertId };
  }
  
  // ... otros métodos
}

// Implementación con MongoDB
class MongoUserRepository implements UserRepository {
  constructor(private collection: Collection) {}
  
  async findById(id: string): Promise<User | null> {
    const doc = await this.collection.findOne({ _id: id });
    return doc ? this.toUser(doc) : null;
  }
  
  // ... otros métodos
}

// Uso en servicio
class UserService {
  constructor(private userRepo: UserRepository) {}
  
  async getUser(id: string) {
    return await this.userRepo.findById(id);
  }
}
Ventajas:
✓Testing:
javascript
// Mock repository para tests
class MockUserRepository implements UserRepository {
  async findById(id: string) {
    return { id, name: 'Test User' };
  }
}

// Test
const service = new UserService(new MockUserRepository());
✓Cambio de DB:
• Cambiar de SQL a MongoDB sin cambiar lógica de negocio
• Solo cambiar implementación del repository
✓Separación de concerns:
• Lógica de negocio no conoce detalles de DB
• Queries centralizadas
✓Reutilización:
• Múltiples servicios pueden usar mismo repository
Cuándo usar:
✓Aplicaciones complejas:
• Múltiples fuentes de datos
• Lógica de negocio compleja
✓Testing importante:
• Necesitas mockear acceso a datos
✓Cambios de DB frecuentes:
• Puede cambiar de SQL a NoSQL
✓Clean Architecture:
• Parte de arquitectura limpia
Cuándo NO usar:
✗ Proyectos simples:
• CRUD básico
• Overhead innecesario
✗ Queries muy específicas:
• Puede volverse complejo
Mejores prácticas:
✓Una entidad = Un repository:
• UserRepository para User
• OrderRepository para Order
✓Métodos específicos del dominio:
javascript
// ✅ Específico del dominio
async findActiveUsers(): Promise<User[]> {
  return this.db.query('SELECT * FROM users WHERE status = ?', ['active']);
}

// ❌ Genérico (mejor en service)
async findByField(field: string, value: any): Promise<User[]> {
  // Demasiado genérico
}
✓Retornar entidades de dominio:
• No DTOs de DB directamente
• Mapear a objetos de dominio
✓Manejar errores de DB:
• Capturar y convertir a errores de dominio

Dependency Injection

1 preguntas

1

¿Qué es Dependency Injection y por qué es importante?

Definición: Patrón donde las dependencias se "inyectan" desde fuera en lugar de ser creadas dentro de la clase.
Problema sin DI:
javascript
// ❌ SIN Dependency Injection
class UserService {
  constructor() {
    this.db = new Database(); // Dependencia hardcodeada
    this.logger = new Logger(); // Dependencia hardcodeada
  }
  
  async getUser(id) {
    this.logger.log(`Getting user ${id}`);
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

// Problemas:
// - Difícil de testear (no puedes mockear DB)
// - Acoplamiento fuerte
// - No puedes cambiar implementación
Solución con DI:
javascript
// ✅ CON Dependency Injection
class UserService {
  constructor(db, logger) {
    this.db = db; // Inyectado
    this.logger = logger; // Inyectado
  }
  
  async getUser(id) {
    this.logger.log(`Getting user ${id}`);
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

// Uso
const db = new Database();
const logger = new Logger();
const userService = new UserService(db, logger);

// Testing - fácil de mockear
const mockDb = { query: jest.fn() };
const mockLogger = { log: jest.fn() };
const service = new UserService(mockDb, mockLogger);
Tipos de inyección:
1. Constructor Injection (más común):
javascript
class UserService {
  constructor(db, logger) {
    this.db = db;
    this.logger = logger;
  }
}
2. Property Injection:
javascript
class UserService {
  setDatabase(db) {
    this.db = db;
  }
}
3. Method Injection:
javascript
class UserService {
  getUser(id, db) {
    return db.query('...', [id]);
  }
}
Ventajas:
✓Testabilidad:
javascript
// Fácil crear mocks
const mockDb = { query: () => Promise.resolve(mockUser) };
const service = new UserService(mockDb, mockLogger);
✓Desacoplamiento:
• Clase no depende de implementación concreta
• Depende de abstracción (interfaz)
✓Flexibilidad:
• Cambiar implementación fácilmente
• Configuración externa
✓Reutilización:
• Mismas dependencias en múltiples clases
Ejemplo con interfaces:
typescript
// Interface (abstracción)
interface Database {
  query(sql: string, params: any[]): Promise<any>;
}

// Implementación concreta
class MySQLDatabase implements Database {
  async query(sql: string, params: any[]) {
    // Implementación MySQL
  }
}

class PostgresDatabase implements Database {
  async query(sql: string, params: any[]) {
    // Implementación Postgres
  }
}

// Servicio depende de abstracción
class UserService {
  constructor(private db: Database) {} // Interface, no clase concreta
  
  async getUser(id: string) {
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

// Inyección
const db = new MySQLDatabase(); // O PostgresDatabase
const service = new UserService(db);
Contenedores DI:
NestJS (built-in):
typescript
@Injectable()
export class UserService {
  constructor(
    @Inject('DATABASE') private db: Database,
    private logger: Logger
  ) {}
}
InversifyJS:
typescript
@injectable()
class UserService {
  constructor(
    @inject('Database') private db: Database
  ) {}
}
Cuándo usar:
✓Aplicaciones complejas:
• Múltiples dependencias
• Testing importante
✓Clean Architecture:
• Parte fundamental
✓Equipos grandes:
• Facilita colaboración
Mejores prácticas:
✓Inyectar dependencias, no crear:
• No usar new dentro de clases
✓Depender de abstracciones:
• Interfaces, no clases concretas
✓Constructor injection preferido:
• Más claro y explícito
✓Un contenedor DI:
• Centralizar creación de objetos
• Gestión de ciclo de vida

Circuit Breaker

1 preguntas

1

¿Qué es el patrón Circuit Breaker y cuándo usarlo?

Definición: Patrón que previene llamadas repetidas a un servicio que está fallando, permitiendo que se recupere.
Analogía: Como un interruptor eléctrico que se "abre" cuando hay demasiada corriente.
Estados del Circuit Breaker:
1. Closed (Cerrado):
•Estado normal
•Requests pasan normalmente
•Monitorea fallos
2. Open (Abierto):
•Servicio está fallando
•Requests son rechazados inmediatamente
•No intenta llamar al servicio
•Timeout antes de intentar de nuevo
3. Half-Open (Semi-abierto):
•Estado de prueba
•Permite algunos requests
•Si tienen éxito → Closed
•Si fallan → Open
Flujo:
[Closed] → (muchos fallos) → [Open] → (timeout) → [Half-Open]
                                                      ↓
                                            (éxito) → [Closed]
                                            (fallo) → [Open]
Implementación básica:
javascript
class CircuitBreaker {
  constructor(service, options = {}) {
    this.service = service;
    this.failureThreshold = options.failureThreshold || 5;
    this.timeout = options.timeout || 60000; // 1 minuto
    this.resetTimeout = options.resetTimeout || 30000; // 30 segundos
    
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
    this.failureCount = 0;
    this.nextAttempt = Date.now();
  }
  
  async call(...args) {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('Circuit breaker is OPEN');
      }
      // Intentar half-open
      this.state = 'HALF_OPEN';
    }
    
    try {
      const result = await this.service(...args);
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
  
  onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }
  
  onFailure() {
    this.failureCount++;
    
    if (this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
      this.nextAttempt = Date.now() + this.resetTimeout;
    }
  }
}

// Uso
const breaker = new CircuitBreaker(async (url) => {
  const response = await fetch(url);
  if (!response.ok) throw new Error('Service failed');
  return response.json();
});

try {
  const data = await breaker.call('http://api.example.com/data');
} catch (error) {
  // Fallback o error handling
  console.log('Service unavailable, using cache');
}
Cuándo usar:
✓Llamadas a servicios externos:
• APIs de terceros
• Microservicios
• Bases de datos remotas
✓Prevenir cascading failures:
• Si servicio A cae, no afectar servicio B
• Aislar fallos
✓Mejorar resiliencia:
• Sistema sigue funcionando aunque servicios externos fallen
• Fallback a cache o datos alternativos
Ejemplo práctico:
javascript
// Servicio con Circuit Breaker
class PaymentService {
  constructor() {
    this.breaker = new CircuitBreaker(
      this.callPaymentAPI.bind(this),
      {
        failureThreshold: 5,
        resetTimeout: 60000
      }
    );
  }
  
  async processPayment(amount) {
    try {
      return await this.breaker.call(amount);
    } catch (error) {
      // Fallback: guardar en cola para procesar después
      await this.queuePayment(amount);
      return { status: 'queued', message: 'Payment queued for later processing' };
    }
  }
  
  async callPaymentAPI(amount) {
    const response = await fetch('http://payment-api/process', {
      method: 'POST',
      body: JSON.stringify({ amount })
    });
    
    if (!response.ok) {
      throw new Error('Payment API failed');
    }
    
    return response.json();
  }
}
Librerías:
✓opossum (Node.js):
javascript
const CircuitBreaker = require('opossum');

const options = {
  timeout: 3000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000
};

const breaker = new CircuitBreaker(apiCall, options);
breaker.on('open', () => console.log('Circuit opened'));
breaker.on('halfOpen', () => console.log('Circuit half-open'));
Ventajas:
✓Previene sobrecarga de servicios caídos
✓Falla rápido (fail-fast)
✓Permite recuperación
✓Mejora resiliencia del sistema
Desventajas:
✗ Añade complejidad
✗ Puede rechazar requests válidos temporalmente
✗ Necesita configuración cuidadosa
Mejores prácticas:
✓Configurar thresholds apropiados:
• No muy bajo (se abre muy rápido)
• No muy alto (permite muchos fallos)
✓Implementar fallback:
• Cache
• Datos por defecto
• Cola para procesar después
✓Monitorear estados:
• Logs cuando cambia estado
• Métricas de circuit breaker
✓Timeouts apropiados:
• Reset timeout basado en tiempo de recuperación esperado