metis-desk/.agents/skills/rdx-solid/referencias/patrones-de-diseño.md

36 KiB

Patrones de diseño (Design Patterns)

Los patrones de diseño son soluciones reutilizables a problemas recurrentes de diseño de software.

Proporcionan un vocabulario común que facilita comunicar decisiones arquitectónicas, reconocer estructuras y organizar responsabilidades.

Este documento desarrolla los criterios definidos en el SKILL.md principal.

Los patrones deben utilizarse cuando resuelvan un problema real, no como objetivos de implementación ni como demostración de sofisticación técnica.

1. Principios fundamentales

No fuerces la utilización de patrones

"Permite que los patrones surjan de las necesidades del diseño y de la refactorización, en lugar de imponerlos anticipadamente."

Un patrón debe resolver un problema existente o responder a un requisito de evolución suficientemente definido.

No introduzcas abstracciones destinadas exclusivamente a necesidades hipotéticas.

Cuándo utilizar un patrón

Antes de aplicarlo, comprueba:

  1. Problema identificado: ¿Existe un problema concreto que el patrón resuelve?
  2. Adecuación: ¿El patrón representa correctamente las responsabilidades y relaciones?
  3. Simplicidad: ¿Mejora el diseño sin introducir complejidad innecesaria?
  4. Mantenibilidad: ¿Facilita comprender, modificar o probar el código?
  5. Conocimiento del equipo: ¿La estructura resultará comprensible para otros desarrolladores?
  6. Alternativas: ¿Existe una función, composición u objeto más sencillo que resuelva adecuadamente el problema?

Principios de aplicación

  • Prioriza soluciones directas cuando sean suficientes.
  • Utiliza patrones conocidos cuando reduzcan complejidad o acoplamiento.
  • No introduzcas jerarquías de clases para comportamientos triviales.
  • No crees interfaces sin una responsabilidad o contrato identificable.
  • Prefiere composición cuando aporte flexibilidad.
  • Respeta los patrones y convenciones ya establecidos en el proyecto.
  • No refactorices código estable únicamente para incorporar un patrón diferente.
  • Valora el coste de mantenimiento de cada abstracción.

Un patrón correcto debe simplificar el razonamiento sobre el sistema, no limitarse a reorganizar código en más archivos.


2. Patrones creacionales (Creational Patterns)

Los patrones creacionales proporcionan mecanismos para construir objetos y controlar su creación.

2.1. Singleton

Propósito: garantizar que exista una única instancia de una clase dentro de un ámbito determinado.

Posibles aplicaciones:

  • Recursos compartidos que requieren una única instancia por proceso.
  • Coordinadores cuyo ciclo de vida debe estar controlado.
  • Componentes con restricciones explícitas de unicidad.

Advertencia: Singleton suele utilizarse excesivamente.

No debe ser la solución predeterminada para:

  • Configuración.
  • Logging.
  • Acceso a bases de datos.
  • Clientes HTTP.
  • Servicios de aplicación.

En muchos casos, resulta preferible crear una instancia compartida durante la inicialización de la aplicación e inyectarla donde sea necesaria.

Ejemplo clásico

class Logger {
  private static instance: Logger | undefined;

  private constructor() {}

  static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }

    return Logger.instance;
  }

  log(message: string): void {
    console.log(message);
  }
}

Alternativa mediante composición

class Logger {
  log(message: string): void {
    console.log(message);
  }
}

const logger = new Logger();

const orderService = new OrderService(logger);
const paymentService = new PaymentService(logger);

Este enfoque permite compartir una instancia sin ocultar las dependencias de los consumidores.

Criterios de utilización

  • Utiliza Singleton únicamente cuando la unicidad forme parte del requisito.
  • Evita convertir dependencias compartidas en estado global accesible desde cualquier lugar.
  • Considera el impacto sobre las pruebas y el aislamiento.
  • Distingue entre una instancia única por proceso y una instancia única en todo un sistema distribuido.
  • No utilices Singleton para resolver problemas que pertenecen a la gestión del ciclo de vida de dependencias.

2.2. Factory (Factoría)

Propósito: encapsular la creación de objetos cuando esta requiera decisiones, validaciones o configuraciones específicas.

Cuándo utilizarlo:

  • La creación depende de parámetros o configuraciones.
  • Existen varias implementaciones de un contrato.
  • La construcción requiere lógica significativa.
  • Se necesita centralizar una política de creación.

Ejemplo

interface Notification {
  send(message: string): Promise<void>;
}

class EmailNotification implements Notification {
  async send(message: string): Promise<void> {
    await sendEmail(message);
  }
}

class SmsNotification implements Notification {
  async send(message: string): Promise<void> {
    await sendSms(message);
  }
}

class PushNotification implements Notification {
  async send(message: string): Promise<void> {
    await sendPush(message);
  }
}
type NotificationType = 'email' | 'sms' | 'push';

function createNotification(type: NotificationType): Notification {
  switch (type) {
    case 'email':
      return new EmailNotification();

    case 'sms':
      return new SmsNotification();

    case 'push':
      return new PushNotification();
  }
}

Consideraciones

En TypeScript, una función factoría suele ser suficiente.

No es necesario crear una clase NotificationFactory si únicamente encapsula una selección sencilla.

El uso de switch dentro de una factoría es legítimo. No contradice los principios SOLID cuando centraliza adecuadamente una decisión de construcción.

Si las implementaciones deben incorporarse dinámicamente, puede ser preferible un registro explícito.

Evita

  • Factorías que únicamente llaman a un constructor sin añadir valor.
  • Jerarquías de factorías innecesarias.
  • Registros dinámicos para un conjunto pequeño y estable de alternativas.
  • Ocultar dependencias importantes detrás de mecanismos de creación difíciles de rastrear.

2.3. Builder (Constructor por etapas)

Propósito: construir objetos complejos mediante una secuencia controlada de operaciones.

Cuándo utilizarlo:

  • Existen numerosos parámetros opcionales.
  • La construcción requiere varias etapas.
  • Deben validarse combinaciones de parámetros.
  • La preparación de datos de prueba es compleja.

Ejemplo

interface UserOptions {
  name: string;
  email: string;
  age?: number;
}

class UserBuilder {
  private name: string | undefined;
  private email: string | undefined;
  private age: number | undefined;

  withName(name: string): this {
    this.name = name;
    return this;
  }

  withEmail(email: string): this {
    this.email = email;
    return this;
  }

  withAge(age: number): this {
    this.age = age;
    return this;
  }

  build(): UserOptions {
    if (!this.name || !this.email) {
      throw new Error('Faltan los datos obligatorios');
    }

    return {
      name: this.name,
      email: this.email,
      ...(this.age === undefined ? {} : { age: this.age }),
    };
  }
}
const userOptions = new UserBuilder()
  .withName('Alice')
  .withEmail('alice@example.com')
  .build();

La validación impide generar un resultado sin los datos obligatorios.

Alternativa más sencilla

Para muchos casos, un objeto de opciones resulta suficiente:

const userOptions: UserOptions = {
  name: 'Alice',
  email: 'alice@example.com',
};

Criterios de utilización

  • Utiliza Builder cuando aporte claridad a un proceso de construcción complejo.
  • Prefiere objetos de parámetros para construcciones sencillas.
  • Evita utilizar aserciones de no nulidad (!) para ocultar datos obligatorios ausentes.
  • Define correctamente las validaciones de construcción.
  • No dupliques innecesariamente las reglas del constructor o del dominio.

2.4. Prototype (Prototipo)

Propósito: crear nuevos objetos a partir de otros existentes mediante clonación.

Cuándo utilizarlo:

  • La construcción original es costosa.
  • Se necesitan copias con pequeñas variaciones.
  • El objeto dispone de una semántica de clonación definida.
  • Se deben reproducir configuraciones complejas.

Ejemplo

interface DocumentMetadata {
  tags: string[];
  author: string;
}

class Document {
  constructor(
    readonly title: string,
    readonly content: string,
    readonly metadata: DocumentMetadata,
  ) {}

  clone(): Document {
    return new Document(
      this.title,
      this.content,
      {
        author: this.metadata.author,
        tags: [...this.metadata.tags],
      },
    );
  }
}

Este ejemplo copia explícitamente los datos mutables de metadata.

Precauciones

Una copia superficial puede conservar referencias compartidas a objetos anidados.

// Copia superficial: los objetos anidados siguen compartidos.
const copy = { ...original };

Antes de clonar, determina:

  • Qué elementos deben copiarse.
  • Qué referencias pueden compartirse.
  • Si existen recursos externos.
  • Si deben preservarse las invariantes.
  • Qué significa exactamente la identidad de la copia.

structuredClone() puede ser útil para determinados datos compatibles, pero no sustituye universalmente a una operación de clonación de dominio.

No introduzcas Prototype cuando construir directamente un objeto resulte más sencillo.


3. Patrones estructurales (Structural Patterns)

Los patrones estructurales organizan relaciones entre objetos y permiten combinar capacidades.

3.1. Adapter (Adaptador)

Propósito: permitir que componentes con interfaces incompatibles colaboren mediante un contrato común.

Cuándo utilizarlo:

  • Integración con bibliotecas de terceros.
  • Migración de código legado.
  • Adaptación de APIs externas.
  • Sustitución de proveedores.
  • Separación entre dominio e infraestructura.

Ejemplo

Una biblioteca heredada proporciona este contrato:

class OldPaymentApi {
  makePayment(cents: number): boolean {
    return legacyPaymentProvider.charge(cents);
  }
}

La aplicación utiliza otro contrato:

interface PaymentGateway {
  charge(amount: Money): ChargeResult;
}

El adaptador transforma ambas representaciones:

class OldPaymentAdapter implements PaymentGateway {
  constructor(
    private readonly oldApi: OldPaymentApi,
  ) {}

  charge(amount: Money): ChargeResult {
    const cents = amount.toCents();
    const success = this.oldApi.makePayment(cents);

    return success
      ? ChargeResult.success()
      : ChargeResult.failed();
  }
}

Criterios de utilización

  • Mantén el contrato de dominio independiente del proveedor externo.
  • Centraliza las conversiones necesarias.
  • Comprueba diferencias de unidades, tipos y semántica.
  • Traduce correctamente los errores y resultados.
  • Evita filtrar detalles del proveedor hacia las capas consumidoras.

En el ejemplo, debe verificarse que el importe esté expresado en una moneda y una unidad admitidas por el proveedor.

Un adaptador no debe limitarse a cambiar nombres si existen diferencias semánticas relevantes entre los contratos.


3.2. Bridge (Puente)

Propósito: separar dos dimensiones de variación para que puedan evolucionar de manera independiente.

Cuándo utilizarlo:

  • Existen dos jerarquías conceptuales que pueden combinarse.
  • La herencia produciría numerosas subclases.
  • Se necesita sustituir implementaciones independientemente de sus consumidores.

Ejemplo

Un sistema genera informes que pueden utilizar distintos formatos de salida.

interface ReportRenderer {
  render(content: string): Promise<void>;
}
class PdfRenderer implements ReportRenderer {
  async render(content: string): Promise<void> {
    await generatePdf(content);
  }
}

class HtmlRenderer implements ReportRenderer {
  async render(content: string): Promise<void> {
    await generateHtml(content);
  }
}
class Report {
  constructor(
    private readonly renderer: ReportRenderer,
  ) {}

  async generate(content: string): Promise<void> {
    await this.renderer.render(content);
  }
}

El informe no depende directamente de un formato concreto.

En un sistema con varios tipos de informes y varios formatos, esta separación puede evitar múltiples combinaciones mediante herencia.

Criterios de utilización

  • Comprueba que existan realmente dos dimensiones independientes de variación.
  • Evita utilizar Bridge para una única implementación estable.
  • No crees jerarquías adicionales si una composición sencilla ya resuelve el problema.
  • Favorece contratos pequeños y explícitos.

3.3. Decorator (Decorador)

Propósito: añadir comportamiento a un componente mediante composición, preservando su contrato.

Cuándo utilizarlo:

  • Se necesitan capacidades combinables.
  • Existen operaciones transversales.
  • Se desea ampliar un comportamiento sin modificar su implementación.
  • Debe evitarse una jerarquía extensa de subclases.

Ejemplo

interface Notifier {
  send(message: string): Promise<void>;
}

class EmailNotifier implements Notifier {
  async send(message: string): Promise<void> {
    await sendEmail(message);
  }
}

Decorador que añade SMS:

class SmsDecorator implements Notifier {
  constructor(
    private readonly wrapped: Notifier,
  ) {}

  async send(message: string): Promise<void> {
    await this.wrapped.send(message);
    await sendSms(message);
  }
}

Decorador que añade Slack:

class SlackDecorator implements Notifier {
  constructor(
    private readonly wrapped: Notifier,
  ) {}

  async send(message: string): Promise<void> {
    await this.wrapped.send(message);
    await sendSlackMessage(message);
  }
}

Composición:

const notifier = new SlackDecorator(
  new SmsDecorator(
    new EmailNotifier(),
  ),
);

await notifier.send('Alerta del sistema');

Consideraciones importantes

En este ejemplo, las notificaciones se ejecutan secuencialmente.

Si falla una operación, las siguientes pueden no ejecutarse.

En un sistema real debe definirse:

  • Si se permite el fallo parcial.
  • Si deben efectuarse reintentos.
  • Si las notificaciones son independientes.
  • Si debe conservarse el orden.
  • Cómo se registran los errores.

No utilices Decorator automáticamente cuando una simple función coordinadora resulte más clara.


3.4. Proxy

Propósito: controlar el acceso a un objeto manteniendo un contrato compatible.

Cuándo utilizarlo:

  • Carga diferida (lazy loading).
  • Control de acceso.
  • Caché.
  • Registro de operaciones.
  • Acceso remoto.
  • Control de recursos costosos.

Ejemplo: carga diferida

interface Image {
  display(): void;
}
class RealImage implements Image {
  constructor(private readonly filename: string) {
    this.loadFromDisk();
  }

  private loadFromDisk(): void {
    // Cargar la imagen.
  }

  display(): void {
    // Mostrar la imagen.
  }
}
class ImageProxy implements Image {
  private realImage: RealImage | null = null;

  constructor(private readonly filename: string) {}

  display(): void {
    if (!this.realImage) {
      this.realImage = new RealImage(this.filename);
    }

    this.realImage.display();
  }
}

La imagen real únicamente se crea cuando se necesita.

Criterios de utilización

  • Define claramente qué comportamiento controla el proxy.
  • Mantén los contratos relevantes del objeto real.
  • Considera los efectos de la caché y la concurrencia.
  • No ocultes fallos del objeto real.
  • Evita proxies que únicamente deleguen operaciones sin aportar comportamiento.

En aplicaciones modernas, ciertas bibliotecas y frameworks ya proporcionan mecanismos de carga diferida, caché o control de acceso. Reutilízalos cuando resulten apropiados.


3.5. Composite (Compuesto)

Propósito: tratar objetos individuales y agrupaciones de objetos mediante un contrato común.

Cuándo utilizarlo:

  • Estructuras jerárquicas.
  • Árboles de componentes.
  • Directorios y archivos.
  • Grupos de elementos.
  • Expresiones compuestas.

Ejemplo

interface PriceComponent {
  getPrice(): number;
}
class Product implements PriceComponent {
  constructor(private readonly price: number) {}

  getPrice(): number {
    return this.price;
  }
}
class Box implements PriceComponent {
  private readonly children: PriceComponent[] = [];

  add(component: PriceComponent): void {
    this.children.push(component);
  }

  getPrice(): number {
    return this.children.reduce(
      (total, child) => total + child.getPrice(),
      0,
    );
  }
}

Uso:

const smallBox = new Box();

smallBox.add(new Product(10));
smallBox.add(new Product(20));

const bigBox = new Box();

bigBox.add(smallBox);
bigBox.add(new Product(50));

console.log(bigBox.getPrice()); // 80

Consideraciones

  • Evita referencias circulares cuando la estructura sea un árbol.
  • Define quién controla la modificación de los elementos.
  • No expongas colecciones mutables que comprometan invariantes.
  • Considera la profundidad y el coste de recorrer la jerarquía.
  • Utiliza objetos de valor apropiados cuando existan importes monetarios reales.

Composite es útil cuando la estructura jerárquica forma parte del problema. No es necesario para colecciones planas sencillas.


4. Patrones de comportamiento (Behavioral Patterns)

Los patrones de comportamiento organizan las interacciones, decisiones y responsabilidades entre componentes.

4.1. Strategy (Estrategia)

Propósito: definir diferentes implementaciones de un comportamiento mediante un contrato común.

Cuándo utilizarlo:

  • Existen varios algoritmos alternativos.
  • Las reglas pueden seleccionarse dinámicamente.
  • Los algoritmos evolucionan independientemente.
  • Se necesita probar cada política por separado.

Ejemplo

interface PricingStrategy {
  calculate(basePrice: number): number;
}
class RegularPricing implements PricingStrategy {
  calculate(basePrice: number): number {
    return basePrice;
  }
}
class PremiumDiscount implements PricingStrategy {
  calculate(basePrice: number): number {
    return basePrice * 0.8;
  }
}
class BlackFridayPricing implements PricingStrategy {
  calculate(basePrice: number): number {
    return basePrice * 0.5;
  }
}

El carrito utiliza la estrategia seleccionada:

class ShoppingCart {
  constructor(
    private readonly pricing: PricingStrategy,
  ) {}

  calculateTotal(items: Item[]): number {
    const basePrice = items.reduce(
      (total, item) => total + item.price,
      0,
    );

    return this.pricing.calculate(basePrice);
  }
}

Los ejemplos numéricos ilustran el patrón. Para importes monetarios reales deben utilizarse representaciones y reglas de redondeo adecuadas.

Alternativa mediante funciones

En TypeScript, una función puede ser suficiente:

type PricingStrategy = (basePrice: number) => number;

const regularPricing: PricingStrategy = (price) => price;
const premiumPricing: PricingStrategy = (price) => price * 0.8;

Criterios de utilización

  • Prefiere funciones cuando únicamente necesites intercambiar algoritmos sencillos.
  • Utiliza objetos cuando las estrategias requieran dependencias, estado o comportamiento adicional.
  • No crees una jerarquía de estrategias para uno o dos condicionales triviales.
  • Mantén coherentes las precondiciones y resultados de las implementaciones.

4.2. Observer (Observador)

Propósito: notificar a varios consumidores cuando se produce un evento o cambio de estado.

Cuándo utilizarlo:

  • Sistemas de eventos.
  • Actualizaciones reactivas.
  • Publicación y suscripción.
  • Comunicación desacoplada entre componentes.

Ejemplo

interface OrderPlacedEvent {
  type: 'ORDER_PLACED';
  orderId: string;
}

type OrderObserver = (event: OrderPlacedEvent) => void;
class OrderEventEmitter {
  private readonly observers = new Set<OrderObserver>();

  subscribe(observer: OrderObserver): () => void {
    this.observers.add(observer);

    return () => {
      this.observers.delete(observer);
    };
  }

  notify(event: OrderPlacedEvent): void {
    for (const observer of this.observers) {
      observer(event);
    }
  }
}

Uso:

const emitter = new OrderEventEmitter();

const unsubscribe = emitter.subscribe((event) => {
  console.log(`Pedido realizado: ${event.orderId}`);
});

emitter.notify({
  type: 'ORDER_PLACED',
  orderId: 'ORDER-001',
});

unsubscribe();

El ejemplo utiliza notificaciones síncronas. Si un observador lanza una excepción, puede interrumpir la entrega a los siguientes.

Consideraciones de diseño

  • Define cuándo se emiten los eventos.
  • Evita emitir eventos de negocio confirmados antes de completar la operación correspondiente.
  • Establece cómo se gestionan los errores de los observadores.
  • Permite cancelar suscripciones para evitar retención innecesaria de recursos.
  • Diferencia los eventos internos de los eventos distribuidos.
  • Determina si las notificaciones deben ser síncronas o asíncronas.
  • Considera idempotencia, reintentos y entrega duplicada cuando exista mensajería distribuida.

Observer no sustituye automáticamente a una arquitectura de eventos.

Para comunicación entre procesos o servicios pueden ser necesarios mecanismos de persistencia de eventos, colas o patrones como Transactional Outbox.

Evita

  • Utilizar eventos para ocultar dependencias que deberían ser explícitas.
  • Crear flujos difíciles de rastrear.
  • Encadenar numerosos observadores con efectos secundarios implícitos.
  • Introducir un bus de eventos global para operaciones sencillas.

4.3. Template Method (Método plantilla)

Propósito: definir la estructura general de un algoritmo y permitir que determinadas etapas varíen mediante especialización.

Cuándo utilizarlo:

  • Existe un proceso estable con pasos claramente definidos.
  • Varias implementaciones comparten la misma secuencia.
  • Las diferencias pueden representarse mediante operaciones especializadas.

Ejemplo

abstract class DataExporter {
  export(data: ExportRecord[]): void {
    this.validate(data);

    const content = this.format(data);

    this.write(content);
  }

  private validate(data: ExportRecord[]): void {
    if (!Array.isArray(data)) {
      throw new Error('Datos de exportación no válidos');
    }
  }

  protected abstract format(data: ExportRecord[]): string;

  protected abstract write(content: string): void;
}
class CsvExporter extends DataExporter {
  protected format(data: ExportRecord[]): string {
    return data.map((record) => record.toCsv()).join('\n');
  }

  protected write(content: string): void {
    writeFile('export.csv', content);
  }
}
class JsonExporter extends DataExporter {
  protected format(data: ExportRecord[]): string {
    return JSON.stringify(data);
  }

  protected write(content: string): void {
    writeFile('export.json', content);
  }
}

Alternativa mediante composición

Cuando las variaciones pueden expresarse mediante colaboradores independientes:

interface DataFormatter {
  format(data: ExportRecord[]): string;
}

interface DataWriter {
  write(content: string): void;
}
class DataExporter {
  constructor(
    private readonly formatter: DataFormatter,
    private readonly writer: DataWriter,
  ) {}

  export(data: ExportRecord[]): void {
    const content = this.formatter.format(data);

    this.writer.write(content);
  }
}

La composición permite sustituir el formateador y el mecanismo de escritura por separado.

Criterios de utilización

  • Utiliza Template Method cuando la herencia represente correctamente un algoritmo estable.
  • Prefiere composición cuando las etapas deban combinarse independientemente.
  • Evita clases base con numerosos métodos protegidos.
  • No introduzcas herencia únicamente para compartir unas pocas instrucciones.
  • Comprueba que las especializaciones respetan los contratos de la clase base.

4.4. Command (Comando)

Propósito: encapsular una solicitud u operación como una unidad independiente.

Cuándo utilizarlo:

  • Historial de operaciones.
  • Deshacer y rehacer.
  • Colas de trabajo.
  • Ejecución diferida.
  • Registro de acciones.
  • Operaciones que requieren auditoría.

Ejemplo

interface Command {
  execute(): void;
  undo(): void;
}
class AddItemCommand implements Command {
  private executed = false;

  constructor(
    private readonly cart: Cart,
    private readonly item: Item,
  ) {}

  execute(): void {
    this.cart.add(this.item);
    this.executed = true;
  }

  undo(): void {
    if (!this.executed) {
      return;
    }

    this.cart.remove(this.item);
    this.executed = false;
  }
}
class CommandHistory {
  private readonly history: Command[] = [];

  execute(command: Command): void {
    command.execute();
    this.history.push(command);
  }

  undo(): void {
    const command = this.history.at(-1);

    if (!command) {
      return;
    }

    command.undo();
    this.history.pop();
  }
}

Precauciones importantes

El ejemplo presupone que:

  • La operación add se completa correctamente o lanza una excepción.
  • El elemento puede identificarse y eliminarse de forma inequívoca.
  • No se modifican los mismos datos concurrentemente.
  • Cada comando se ejecuta y deshace según el ciclo previsto.

En aplicaciones reales, estas garantías deben analizarse expresamente.

Deshacer una operación no equivale necesariamente a ejecutar la operación contraria.

Por ejemplo:

  • Deshacer un envío de correo electrónico puede ser imposible.
  • Revertir un pago requiere una nueva operación de negocio.
  • Eliminar un elemento puede requerir conservar su posición original.
  • Una operación distribuida puede exigir acciones compensatorias.

Criterios de utilización

  • Encapsula comandos cuando necesites representar operaciones independientes.
  • Utiliza funciones cuando una simple llamada sea suficiente.
  • Define los errores y las garantías de ejecución.
  • Distingue comandos reversibles de acciones compensables.
  • Evita implementar sistemas de historial o colas sin una necesidad identificada.

5. Reconocimiento de patrones

Análisis mediante cuatro dimensiones

Cuando analices código nuevo o bibliotecas desconocidas, considera:

  1. Problema que resuelve: creación, estructura o comportamiento.
  2. Ámbito: objeto, clase, módulo o sistema.
  3. Momento de aplicación: configuración, inicialización, compilación o ejecución.
  4. Acoplamiento: qué dependencias introduce y cuáles elimina.

Estas dimensiones ayudan a reconocer patrones incluso cuando su implementación no coincide exactamente con la descripción clásica.

Preguntas adicionales

  • ¿Qué responsabilidades separa?
  • ¿Qué contrato proporciona?
  • ¿Qué variaciones permite?
  • ¿Qué coste introduce?
  • ¿Qué alternativa sería más sencilla?
  • ¿Qué ocurriría si elimináramos el patrón?

Reconocer un patrón no significa que sea necesario introducirlo.


6. Patrones en TypeScript y arquitecturas modernas

Los lenguajes y frameworks modernos proporcionan mecanismos que pueden sustituir implementaciones clásicas basadas en clases.

6.1. Funciones como estrategias

Las funciones de primera clase permiten intercambiar algoritmos sin crear jerarquías.

type DiscountCalculator = (price: number) => number;

const noDiscount: DiscountCalculator = (price) => price;

const premiumDiscount: DiscountCalculator = (price) => price * 0.8;

function calculatePrice(
  price: number,
  discount: DiscountCalculator,
): number {
  return discount(price);
}

Este enfoque puede resultar suficiente cuando las estrategias carecen de estado o dependencias complejas.

6.2. Funciones factoría

Una función puede encapsular la creación de objetos sin necesidad de una clase específica.

function createLogger(environment: 'development' | 'production'): Logger {
  if (environment === 'development') {
    return new ConsoleLogger();
  }

  return new StructuredLogger();
}

6.3. Composición explícita

La composición puede realizarse directamente durante la inicialización de una aplicación.

const logger = new ConsoleLogger();
const repository = new PostgresOrderRepository();

const orderService = new OrderService(
  repository,
  logger,
);

No es obligatorio utilizar un contenedor de dependencias cuando la composición manual sea suficiente.

6.4. Patrones propios de frameworks

Cuando el proyecto utilice React, Express, NestJS u otros frameworks:

  • Respeta los mecanismos idiomáticos del framework.
  • No reproduzcas patrones clásicos que el framework ya resuelve.
  • Evita introducir capas adicionales sin responsabilidades concretas.
  • Mantén la separación entre lógica de negocio y detalles técnicos cuando corresponda.
  • No fuerces el uso de clases en escenarios donde funciones, componentes o hooks sean más apropiados.

Por ejemplo, en React puede resultar más adecuado utilizar composición de componentes y hooks que aplicar literalmente jerarquías de herencia, Decorator u Observer.


7. Antipatrones que deben evitarse

Antipatrón Problema Enfoque recomendado
God Object (Objeto todopoderoso) Una clase concentra responsabilidades independientes. Separar responsabilidades coherentes.
Spaghetti Code (Código espagueti) Flujo y dependencias difíciles de comprender. Simplificar estructuras y reorganizar responsabilidades.
Golden Hammer (Martillo de oro) Utilizar siempre el mismo patrón. Elegir soluciones según el problema concreto.
Premature Optimization (Optimización prematura) Optimizar sin necesidad ni evidencia. Medir y priorizar problemas reales.
Copy-Paste Programming (Programación por copia y pega) Duplicación de conocimiento o reglas. Identificar abstracciones justificadas.
Overengineering (Sobreingeniería) Introducir complejidad para requisitos hipotéticos. Aplicar YAGNI y KISS.
Speculative Generality (Generalización especulativa) Crear extensibilidad sin necesidad real. Diseñar para requisitos existentes o definidos.
Service Locator indiscriminado Ocultar dependencias mediante búsquedas globales. Preferir dependencias explícitas cuando corresponda.
Abstraction Explosion (Explosión de abstracciones) Demasiadas interfaces, clases y capas triviales. Eliminar intermediarios sin responsabilidad real.

La regla de tres

La regla de tres puede ayudar a detectar oportunidades de reutilización, pero no debe interpretarse como un requisito obligatorio.

  • No extraigas automáticamente una abstracción por encontrar tres fragmentos parecidos.
  • No esperes a una tercera duplicación cuando ya exista una regla de negocio común que deba mantenerse coherente.
  • Comprueba que el código duplicado represente el mismo conocimiento.
  • Prefiere una pequeña duplicación comprensible antes que una abstracción incorrecta.

8. Cómo decidir si utilizar un patrón

Antes de introducir un patrón nuevo:

  1. Identifica el problema concreto.
  2. Comprueba las soluciones existentes en el proyecto.
  3. Define qué responsabilidad debe quedar aislada.
  4. Evalúa una solución directa sin patrón.
  5. Compara complejidad, acoplamiento y mantenibilidad.
  6. Introduce el patrón únicamente si aporta una ventaja clara.
  7. Verifica que no modifica involuntariamente el comportamiento existente.

Señales de que un patrón puede ser útil

  • Existen variaciones de comportamiento que evolucionan independientemente.
  • Se repiten decisiones de creación complejas.
  • Las dependencias impiden probar componentes.
  • Una integración externa contamina los contratos de dominio.
  • Una estructura jerárquica necesita operaciones uniformes.
  • Existen responsabilidades transversales combinables.

Señales de que puede ser innecesario

  • Solo existe una implementación sencilla.
  • La variación es hipotética.
  • El patrón añade numerosos archivos sin simplificar las operaciones.
  • Los consumidores necesitan conocer demasiados detalles del patrón.
  • Una función o un objeto literal resuelve el problema de forma clara.
  • El código original ya es comprensible y fácil de modificar.

9. Lista de comprobación de patrones de diseño

Selección

  • ¿Existe un problema real que justifique el patrón?
  • ¿Conozco una alternativa más sencilla?
  • ¿El patrón representa correctamente el dominio?
  • ¿Reduce el acoplamiento o mejora la cohesión?
  • ¿La solución será comprensible para otros desarrolladores?

Implementación

  • ¿Los contratos están correctamente definidos?
  • ¿Las implementaciones respetan los comportamientos esperados?
  • ¿He evitado clases e interfaces innecesarias?
  • ¿Las dependencias son explícitas cuando corresponde?
  • ¿Se respetan las convenciones del proyecto?

Mantenimiento

  • ¿El patrón facilita incorporar cambios reales?
  • ¿Se puede probar sin infraestructura innecesaria?
  • ¿Introduce complejidad operativa adicional?
  • ¿La estructura sigue siendo adecuada si se incorporan nuevos requisitos?
  • ¿He evitado refactorizaciones ajenas al alcance?

Verificación

  • ¿Se conserva el comportamiento observable?
  • ¿Los contratos existentes siguen siendo compatibles?
  • ¿Se han ejecutado las pruebas pertinentes?
  • ¿Los errores y estados excepcionales están correctamente tratados?
  • ¿La nueva abstracción aporta una mejora verificable?

10. Referencia rápida

Patrón Problema principal Precaución
Singleton Instancia única controlada. Evitar estado global oculto.
Factory Construcción variable o compleja. No crear factorías triviales.
Builder Construcción por etapas. No sustituir innecesariamente objetos de opciones.
Prototype Clonación de objetos. Comprobar referencias mutables.
Adapter Interfaces incompatibles. Preservar la semántica del contrato.
Bridge Variaciones independientes. Evitar jerarquías innecesarias.
Decorator Comportamientos combinables. Controlar orden y efectos secundarios.
Proxy Control de acceso o carga. Mantener transparencia contractual.
Composite Estructuras jerárquicas. Proteger invariantes y evitar ciclos.
Strategy Algoritmos intercambiables. Considerar funciones sencillas.
Observer Notificaciones a consumidores. Gestionar errores y suscripciones.
Template Method Algoritmos con etapas variables. Considerar composición como alternativa.
Command Operaciones como unidades independientes. Definir correctamente reversión y ejecución.

Principio final

"Los patrones de diseño son herramientas para resolver problemas recurrentes, no requisitos que deban imponerse a todas las implementaciones."

Utiliza patrones cuando permitan expresar responsabilidades con mayor claridad, controlar dependencias y facilitar la evolución del software.

No conviertas la identificación de patrones en una obligación de aplicarlos.

La mejor solución es la más sencilla que satisface correctamente los requisitos y mantiene el software comprensible, verificable y preparado para evolucionar.