StackPractices
intermediate Por Mathias Paulenko

Abstract Factory para Familias de Componentes UI

Crea familias de objetos relacionados sin especificar clases concretas, permitiendo implementaciones específicas de plataforma que comparten una interfaz común.

Resumen

Despacha un botón a tres plataformas y el problema aparece rápido: Web quiere un <button>, iOS quiere un UIButton, Android quiere un widget Material. Todos son “un botón” pero no son intercambiables — mezcla un checkbox web en un formulario nativo y la pantalla se rompe. El patrón Abstract Factory crea familias enteras de objetos relacionados a través de una interfaz, así que cada producto que recibes pertenece a la misma plataforma o tema.

Esta página cubre la aplicación de Abstract Factory a familias de componentes UI cross-platform. Para los fundamentos del patrón y otras aplicaciones, mira la página genérica de Abstract Factory.

Cuándo Usar Esto

  • Un sistema debe soportar múltiples plataformas o temas con familias de componentes cohesionadas
  • Necesitas garantizar que productos de la misma factory sean compatibles — un diálogo nativo no puede convivir con un checkbox web
  • El proceso de creación debería estar oculto del código cliente que no debería saber en qué plataforma está

Evítalo cuando:

  • La familia es chica y estable — dos o tres productos no justifican las interfaces extra; un Factory Method alcanza
  • Las diferencias entre plataformas son cosméticas (colores, espaciado) — variables CSS o un theme object son más simples que implementaciones separadas
  • Solo existe una plataforma hoy; agrega la abstracción cuando la segunda familia realmente llegue

Problema

Un toolkit de UI necesita renderizar botones nativos, checkboxes y dialogs en Web, iOS y Android. Usar Factory Method para cada widget por separado corre el riesgo de mezclar componentes nativos y web en la misma vista.

Solución

// factory/UIFactory.ts
interface Button {
  render(): string;
  onClick(handler: () => void): void;
}

interface Checkbox {
  render(): string;
  toggle(): void;
  isChecked(): boolean;
}

interface Dialog {
  show(): void;
  hide(): void;
  setTitle(title: string): void;
}

interface UIFactory {
  createButton(label: string): Button;
  createCheckbox(label: string): Checkbox;
  createDialog(): Dialog;
}

// Familia Web
class WebButton implements Button {
  constructor(private label: string) {}
  render(): string { return `<button>${this.label}</button>`; }
  onClick(handler: () => void): void { /* attach DOM listener */ }
}

class WebCheckbox implements Checkbox {
  private checked = false;
  constructor(private label: string) {}
  render(): string { return `<label><input type="checkbox"> ${this.label}</label>`; }
  toggle(): void { this.checked = !this.checked; }
  isChecked(): boolean { return this.checked; }
}

class WebDialog implements Dialog {
  private title = '';
  show(): void { console.log('Show web dialog:', this.title); }
  hide(): void { console.log('Hide web dialog'); }
  setTitle(title: string): void { this.title = title; }
}

class WebFactory implements UIFactory {
  createButton(label: string): Button { return new WebButton(label); }
  createCheckbox(label: string): Checkbox { return new WebCheckbox(label); }
  createDialog(): Dialog { return new WebDialog(); }
}

// Familia Mobile
class MobileButton implements Button {
  constructor(private label: string) {}
  render(): string { return `[Mobile Button: ${this.label}]`; }
  onClick(): void { /* native tap handler */ }
}

class MobileCheckbox implements Checkbox {
  private checked = false;
  constructor(private label: string) {}
  render(): string { return `[Mobile Switch: ${this.label}]`; }
  toggle(): void { this.checked = !this.checked; }
  isChecked(): boolean { return this.checked; }
}

class MobileDialog implements Dialog {
  private title = '';
  show(): void { console.log('Show mobile modal:', this.title); }
  hide(): void { console.log('Dismiss mobile modal'); }
  setTitle(title: string): void { this.title = title; }
}

class MobileFactory implements UIFactory {
  createButton(label: string): Button { return new MobileButton(label); }
  createCheckbox(label: string): Checkbox { return new MobileCheckbox(label); }
  createDialog(): Dialog { return new MobileDialog(); }
}

// Cliente usa cualquier factory uniformemente
class SettingsScreen {
  private button: Button;
  private checkbox: Checkbox;
  private dialog: Dialog;

  constructor(factory: UIFactory) {
    this.button = factory.createButton('Save');
    this.checkbox = factory.createCheckbox('Dark Mode');
    this.dialog = factory.createDialog();
    this.dialog.setTitle('Confirm');
  }

  render(): string {
    return `${this.button.render()} ${this.checkbox.render()}`;
  }
}

// Uso
const webScreen = new SettingsScreen(new WebFactory());
const mobileScreen = new SettingsScreen(new MobileFactory());

Cómo Funciona

  1. Abstract Factory declara métodos de creación para cada tipo de producto
  2. Concrete Factory implementa creación para una familia específica
  3. Abstract Products declaran interfaces para tipos de producto
  4. Concrete Products implementan una variante para una familia
  5. Cliente usa solo interfaces abstractas; la factory concreta determina la familia
Diagrama de clases de Abstract Factory: la interfaz UIFactory declara createButton, createCheckbox y createDialog, implementada por WebFactory, iOSFactory y AndroidFactory.

Consecuencias y Trade-offs

La garantía de consistencia es el punto — y también el coste. Una vez que el código cliente tiene una factory, todo objeto que construye viene de la misma familia, así que mezclar plataformas deja de ser posible por construcción. Eso vale dinero real en un equipo de design system: nadie vuelve a reportar un checkbox web dentro de un diálogo nativo. El reverso es la rigidez. Agregar un nuevo tipo de producto — digamos, un date picker — significa tocar la interfaz de la factory abstracta más cada factory concreta. Con tres plataformas son cuatro ediciones por una feature; con diez, un impuesto de mantenimiento que no se va.

La estrategia de selección importa más que las clases. El wiring en compile-time (contenedor DI, build variants) es simple y falla rápido, pero ata la familia a un deploy. La selección en runtime (detectPlatform() al arrancar, o por request para contextos web+embed) cuesta indirección pero deja que un binario sirva varias familias. Elige runtime cuando el mismo bundle va a plataformas distintas; compile-time cuando cada plataforma ya tiene su propio artefacto.

El borde de la familia es una decisión de diseño, no un detalle de implementación. “Todo lo de iOS” es demasiado amplio — acopla componentes que evolucionan a distintas velocidades. “Exactamente los widgets de formulario” es angosto pero estable. Si un tipo de producto cambia por razones ajenas al split de plataformas, probablemente no pertenece a la familia. En duda, empieza con la familia más chica que muestre el problema de mezcla y crécela solo cuando un segundo producto real necesite la garantía.

Una estimación práctica: cada tipo de producto es una interfaz más una implementación por familia. Un design system de 5 productos y 3 plataformas son 15 clases concretas más 5 interfaces — manejable. Uno de 15 productos y 3 plataformas son 45 clases que mantienes para siempre. Abstract Factory gana su complejidad cuando la familia es chica, las plataformas son reales, y el bug de mezcla es caro. Cuando las tres no se cumplen, un Factory Method o un theme object hacen el mismo trabajo con una fracción de la superficie.

Consideraciones de Producción

  • Usa frameworks de inyección de dependencias para seleccionar la factory en runtime
  • Abstract Factory funciona bien con Bridge cuando las familias también necesitan renderizado específico de plataforma
  • Considera registros de factory para arquitecturas basadas en plugins
  • Código complementario: el repo companion de abstract-factory-cross-platform empaqueta cada snippet de esta página como archivos TypeScript ejecutables.

Errores Comunes

  • Devolver tipos de producto concretos desde los métodos de la factory — si createButton devuelve WebButton en lugar de Button, los clientes se acoplan a la clase concreta y la garantía de familia se evapora.
  • Dejar que el código cliente construya productos directamente — un new WebButton() junto a un diálogo creado por factory reintroduce justo la mezcla que el patrón existe para prevenir.
  • Detectar la plataforma dentro del cliente — if (isMobile) esparcido por las vistas derrota el patrón; detecta una vez en el bootstrap e inyecta la factory.
  • Testear solo los productos, no la factory — una MobileFactory que devuelve WebCheckbox compila bien y despacha el bug; afirma la familia por cada factory.

Técnicas Avanzadas

Composición de factory con inyección de dependencias

Combina Abstract Factory con contenedores DI para selección de factory en runtime:

// factory/FactoryRegistry.ts
interface FactoryRegistry {
  register(key: string, factory: UIFactory): void;
  get(key: string): UIFactory;
}

class UIFactoryRegistry implements FactoryRegistry {
  private factories = new Map<string, UIFactory>();

  register(key: string, factory: UIFactory): void {
    this.factories.set(key, factory);
  }

  get(key: string): UIFactory {
    const factory = this.factories.get(key);
    if (!factory) throw new Error(`Factory not found: ${key}`);
    return factory;
  }
}

// Uso con DI
const registry = new UIFactoryRegistry();
registry.register('web', new WebFactory());
registry.register('mobile', new MobileFactory());

// Selección en runtime basada en ambiente
const platform = detectPlatform(); // 'web' | 'mobile'
const factory = registry.get(platform);
const screen = new SettingsScreen(factory);

Carga dinámica de factories con plugins

Carga factories dinámicamente basadas en configuración o plugins:

// factory/DynamicFactoryLoader.ts
interface FactoryConfig {
  type: string;
  module: string;
  className: string;
}

async function loadFactory(config: FactoryConfig): Promise<UIFactory> {
  const module = await import(config.module);
  const FactoryClass = module[config.className];
  return new FactoryClass();
}

// Selección de factory basada en config
const config: FactoryConfig = {
  type: 'ios',
  module: './factories/iOS',
  className: 'IOSFactory'
};

const factory = await loadFactory(config);

Factory con creación de productos parametrizada

Pasa configuración a factories para instanciación flexible de productos:

interface UIFactoryConfig {
  theme: 'light' | 'dark';
  locale: string;
  accessibility: boolean;
}

interface ConfigurableUIFactory extends UIFactory {
  setConfig(config: UIFactoryConfig): void;
}

class WebFactory implements ConfigurableUIFactory {
  private config: UIFactoryConfig = {
    theme: 'light',
    locale: 'en',
    accessibility: false
  };

  setConfig(config: UIFactoryConfig): void {
    this.config = { ...this.config, ...config };
  }

  createButton(label: string): Button {
    return new WebButton(label, this.config);
  }
}

class WebButton implements Button {
  constructor(
    private label: string,
    private config: UIFactoryConfig
  ) {}

  render(): string {
    const themeClass = this.config.theme === 'dark' ? 'dark-mode' : 'light-mode';
    return `<button class="${themeClass}">${this.label}</button>`;
  }

  onClick(handler: () => void): void { /* attach DOM listener */ }
}

Encadenamiento de métodos de factory para ensamblaje complejo de productos

Encadena métodos de factory para construir productos complejos desde múltiples componentes:

interface ComplexUIFactory extends UIFactory {
  createForm(): Form;
  createFormField(type: 'text' | 'number' | 'email'): FormField;
  createValidator(type: 'required' | 'email' | 'minLength'): Validator;
}

class WebFormFactory implements ComplexUIFactory {
  createButton(label: string): Button { /* ... */ }
  createCheckbox(label: string): Checkbox { /* ... */ }
  createDialog(): Dialog { /* ... */ }

  createForm(): Form {
    return new WebForm();
  }

  createFormField(type: string): FormField {
    return new WebFormField(type);
  }

  createValidator(type: string): Validator {
    return new WebValidator(type);
  }
}

// Cliente construye formulario complejo usando cadena de factory
const factory = new WebFormFactory();
const form = factory.createForm();
form.addField(factory.createFormField('text'));
form.addField(factory.createFormField('email'));
form.addValidator(factory.createValidator('required'));

Inicialización lazy de factories con proxies

Usa proxies para diferir la instanciación de factory hasta el primer uso:

class LazyUIFactory implements UIFactory {
  private factory: UIFactory | null = null;
  private factoryFactory: () => UIFactory;

  constructor(factoryFactory: () => UIFactory) {
    this.factoryFactory = factoryFactory;
  }

  private getFactory(): UIFactory {
    if (!this.factory) {
      this.factory = this.factoryFactory();
    }
    return this.factory;
  }

  createButton(label: string): Button {
    return this.getFactory().createButton(label);
  }

  createCheckbox(label: string): Checkbox {
    return this.getFactory().createCheckbox(label);
  }

  createDialog(): Dialog {
    return this.getFactory().createDialog();
  }
}

// Inicialización lazy
const lazyFactory = new LazyUIFactory(() => {
  console.log('Initializing WebFactory...');
  return new WebFactory();
});

// Factory no inicializada hasta primera llamada de método
const button = lazyFactory.createButton('Click me');

Mejores Prácticas

  1. Mantén la familia chica y estable. Cada tipo de producto que agregas cuesta un cambio de interfaz en todas las factories concretas — un date picker son cuatro ediciones en un sistema de tres plataformas. Familias de 3-7 productos envejecen bien; de 15 se vuelven el problema que venían a resolver.
  2. Inyecta la factory, nunca detectes la plataforma dos veces. El cliente recibe una UIFactory en su constructor. La detección de plataforma pasa una vez, en el bootstrap — esparcir if (platform === 'ios') por la app derrota el patrón.
  3. Testea las factories, no solo los productos. Afirma que WebFactory.createCheckbox() devuelve un WebCheckbox. Una factory que devuelve la familia equivocada compila bien y despacha el bug.
  4. Deja que los productos varíen libremente dentro de la familia. Un MaterialButton y un IOSButton pueden diferir muchísimo en markup y comportamiento — el contrato es la interfaz, no el interior. La factory es lo que mantiene las variantes compatibles.
  5. Versiona la familia, no las piezas. Cuando el design system sube de versión (widgets v1 -> v2), despacha una V2UIFactory en vez de mutar cada producto. Las pantallas viejas se quedan con la familia vieja, las nuevas optan por entrar.

Preguntas frecuentes

¿En qué se diferencia de Factory Method?

Factory Method crea un producto a través de un método sobreescribible — cada subclase elige su variante. Abstract Factory crea una familia completa de productos relacionados a través de un objeto inyectado. Usa Factory Method para un tipo intercambiable; usa Abstract Factory cuando varios tipos deben mantenerse compatibles entre sí.

¿Cuándo debería evitar Abstract Factory?

Cuándo la familia tiene uno o dos productos, cuando las diferencias son cosméticas (un theme object cubre colores), o cuando solo soportas una plataforma. El patrón rinde solo si el bug de mezcla es real y caro — si no, son interfaces que nadie lee.

¿Puedo usar Abstract Factory con frameworks de inyección de dependencias?

Es la forma limpia de hacerlo. Bindea la factory concreta en la config de tu contenedor (container.bind<UIFactory>(WebFactory)), inyecta la interfaz en todas partes, y cambia de familia rebindeando — sin cambios de código en los consumidores.

¿Cómo manejo agregar un nuevo tipo de producto a una familia de factory existente?

Espera tocar cada factory concreta — es el coste conocido del patrón. Agrega el método a la interfaz abstracta, impleméntalo en cada clase concreta, y escribe un compile check o test por familia. Si agregas tipos de producto todos los meses, la familia probablemente es demasiado amplia.

¿Cómo se compara Abstract Factory con el patrón Builder?

Builder ensambla un objeto complejo paso a paso — un producto, muchas partes. Abstract Factory produce familias de productos listos en una llamada cada uno. Componen bien: una factory puede entregarte builders para los productos que necesitan ensamblaje.

¿Debería usar Abstract Factory para cambio de tema simple?

Casi nunca. Colores, espaciado y tipografía son datos — un theme object o variables CSS los manejan sin clases nuevas. Abstract Factory encaja cuando el comportamiento difiere por plataforma, no solo la apariencia: markup distinto, semántica de interacción distinta, requisitos de a11y distintos.

¿Cómo testeo código que usa Abstract Factory?

Dos niveles: tests por factory afirmando que cada una devuelve los productos de su propia familia, y una UIFactory fake en los tests del cliente para no arrastrar widgets reales a los unit tests. Lo segundo es la recompensa — testear contra la interfaz es exactamente lo que compra el patrón.