8 patrones de diseño que todo desarrollador debe conocer

Seguro que has escuchado sobre patrones de diseño en general, y has quedado un tanto confundido.

En este video, aprenderemos ocho patrones de diseño, de manera sencilla, para que los recuerdes con facilidad.

  • En 1994, The Gang of Four publicó el sagrado libro 'Patrones de Diseño',
  • introduciendo 23 patrones de diseño orientados a objetos,
  • clasificados en tres categorías: patrones creacionales, patrones estructurales y patrones de comportamiento.

¿Puedes creer que este libro publicado hace más de 30 años todavía sigue generando discusiones interesantes?

Así mismo, las entrevistas suelen incluir preguntas sobre estos patrones de diseño.

Ok, suficiente sobre la historia.

Comencemos con nuestro primer patrón creacional: Factory.

Factory

Imagina que quieres una hamburguesa, pero no quieres preocuparte por conseguir todos los ingredientes y prepararla.

Entonces, en lugar de eso, simplemente decides ordenar una hamburguesa.

Bueno, podemos hacer lo mismo con el código.

Si se necesita una lista de ingredientes para crear una hamburguesa, en su lugar podemos usar una fábrica (o Factory).

Esta fábrica se encargará de instanciar hamburguesas por nosotros.

Ya sea una hamburguesa simple, una hamburguesa Royal o incluso una hamburguesa vegana.

Todo lo que tenemos que hacer es decirle a la fábrica qué tipo de hamburguesa queremos, igual a lo que harías en un restaurante.

// Modelo
class Burger {
    constructor(bun, cheese, sauce) {
        this.bun = bun;
        this.cheese = cheese;
        this.sauce = sauce;
    }

    toString() {
        return `Bun: ${this.bun}, Cheese: ${this.cheese}, Sauce: ${this.sauce}`;
    }
}

// Fábrica
class BurgerFactory {
    createSimpleBurger() {
        return new Burger('Regular Bun', 'Cheddar', 'Ketchup');
    }

    createRoyalBurger() {
        return new Burger('Sesame Bun', 'Gouda', 'Special Sauce');
    }

    createVeganBurger() {
        return new Burger('Gluten-Free Bun', 'No Cheese', 'Vegan Mayo');
    }
}

// Cómo se usa
const burgerFactory = new BurgerFactory();
const simpleBurger = burgerFactory.createSimpleBurger();
const royalBurger = burgerFactory.createRoyalBurger();
const veganBurger = burgerFactory.createVeganBurger();

console.log(simpleBurger.toString());
console.log(royalBurger.toString());
console.log(veganBurger.toString());
  • Cada método de la clase BurgerFactory crea un tipo de hamburguesa en particular, según indica su nombre.
  • El método toString en la clase Burger provee una representación en cadena de la hamburguesa.

Builder

Ahora, si quieres un poco más de control sobre cómo se prepara la hamburguesa, puedes optar por el patrón Builder.

La idea es que si queremos hacer una hamburguesa, no tenemos que pasar inmediatamente todos los argumentos.

En su lugar, podemos usar un Constructor de Hamburguesas (en inglés BurgerBuilder).

Tendremos un método para añadir cada ingrediente, ya sea el pan, el queso o las cremas.

Cada uno devolverá una referencia al Builder, y finalmente, tendremos un método build que devolverá el producto final.

Este patrón se usa mucho cuando tienes que construir objetos complejos, con muchos parámetros.

// Modelo
class Burger {
    constructor() {
        this.bun = null;
        this.cheese = null;
        this.sauce = null;
        this.veggies = null;
        this.patty = null;
    }

    toString() {
        return `Burger with: ${this.bun ? this.bun + ' bun, ' : ''}${this.cheese ? this.cheese + ' cheese, ' : ''}${this.sauce ? this.sauce + ' sauce, ' : ''}${this.veggies ? this.veggies + ', ' : ''}${this.patty ? this.patty + ' patty' : ''}`;
    }
}

// Clase Builder
class BurgerBuilder {
    constructor() {
        this.burger = new Burger();
    }

    withBun(bun) {
        this.burger.bun = bun;
        return this;
    }

    withCheese(cheese) {
        this.burger.cheese = cheese;
        return this;
    }

    withSauce(sauce) {
        this.burger.sauce = sauce;
        return this;
    }

    withVeggies(veggies) {
        this.burger.veggies = veggies;
        return this;
    }

    withPatty(patty) {
        this.burger.patty = patty;
        return this;
    }

    build() {
        return this.burger;
    }
}

// Cómo usar
const myBurger = new BurgerBuilder()
                    .withBun('Sesame')
                    .withCheese('Cheddar')
                    .withSauce('Ketchup')
                    .withPatty('Beef')
                    .build();

console.log(myBurger.toString());

Singleton

Un Singleton es una clase de la que sólo puede existir una única instancia a la vez.

Tiene muchos casos de uso, por ejemplo, mantener una única copia del estado de nuestra aplicación.

Digamos que en nuestra aplicación queremos saber si un usuario ha iniciado sesión o no, para acceder al estado no usaremos al método Constructor para instanciar.

Usaremos un método estático llamado 'getAppState', que primero verificará si ya existe una instancia.

  • Si no la hay, instanciaremos una.
  • Si ya existe, simplemente devolveremos la instancia existente.

Así nos aseguramos de tener solo una.

class ApplicationState {
    static #instance = null;

    isAuthenticated = false;

    constructor() {
        if (ApplicationState.#instance) {
            throw new Error("Ya existe una instancia. Usa el método getInstance().");
        }

        ApplicationState.#instance = this;
    }

    // Método estático para acceder a la única instancia
    static getInstance() {
        if (ApplicationState.#instance === null) {
            new ApplicationState();
        }
        return ApplicationState.#instance;
    }
}

// Cómo se usa
const appState = ApplicationState.getInstance();
console.log(appState.isAuthenticated); // false

appState.isAuthenticated = true;
console.log(appState.isAuthenticated); // true

const anotherAppState = ApplicationState.getInstance();
console.log(anotherAppState.isAuthenticated); // true, ya que es la misma instancia

// Si intentas crear una instancia más, esto producirá un error
new ApplicationState(); // Error: Ya existe una instancia. Usa el método getInstance().

Este patrón es útil para que múltiples componentes en tu aplicación compartan una misma instancia, pero ¿cómo pueden todos los componentes escuchar actualizaciones en tiempo real?

Observer

Ahí es donde entra Observer, nuestro primer patrón de comportamiento.

También es conocido como pub-sub, porque un componente publica actualizaciones y otro se suscribe.

Se utiliza ampliamente, más allá de la programación orientada a objetos, por ejemplo, en sistemas distribuidos.

Un ejemplo es YouTube:

  • cada vez que subo un video,
  • todos mis suscriptores reciben una notificación,
  • incluyéndote,
  • porque estás suscrito, ¿verdad?

En este caso, el canal de YouTube es el publisher o publicador, y emitirá eventos, como la subida de un nuevo video.

Queremos que múltiples observadores, también conocidos como suscriptores, sean notificados de estos eventos en tiempo real.

// YouTubeUser class
class YouTubeUser {
    constructor(name) {
        this.name = name;
    }

    notify(channelName, videoTitle) {
        console.log(`${this.name}, se ha subido un nuevo video titulado "${videoTitle}" en el canal ${channelName}`);
    }
}

// YouTubeChannel class
class YouTubeChannel {
    constructor(name) {
        this.name = name;
        this.subscribers = [];
    }

    subscribe(subscriber) {
        this.subscribers.push(subscriber);
    }

    uploadVideo(videoTitle) {
        this.notifySubscribers(videoTitle);
    }

    notifySubscribers(videoTitle) {
        this.subscribers.forEach(subscriber => subscriber.notify(this.name, videoTitle));
    }
}

// Uso
const channel = new YouTubeChannel('Programación y más');

const user1 = new YouTubeUser('Juan');
const user2 = new YouTubeUser('Maria');
const user3 = new YouTubeUser('Carlos');

channel.subscribe(user1);
channel.subscribe(user2);
channel.subscribe(user3);

channel.uploadVideo('Patrones de Diseño en JavaScript');
channel.uploadVideo('Tutorial del Patrón Observador');
  • La clase YouTubeChannel incluye una lista de sus suscriptores.
  • Cuando un usuario nuevo se suscribe, lo añadimos a la lista de suscriptores.
  • Cuando ocurre un evento, recorremos la lista y enviamos los datos del evento a cada uno de ellos.

Para representar diferentes tipos de suscriptores muchas veces es conveniente definir una interfaz.

Para este caso, simplemente vamos a definir una clase para representar a un usuario de YouTube, e imprimir cada notificación que recibe.

Siguiendo esta idea, un suscriptor puede estar suscrito a múltiples canales.

Iterator

Iterator, o iterador, es un patrón bastante simple, que define cómo se pueden iterar los valores en un objeto.

La sintaxis puede variar, dependiendo del lenguaje.

Aquí tienes un ejemplo de cómo implementar un custom iterator para una colección de libros, que se representa por la clase BookCollection:

class Book {
    constructor(title, author) {
        this.title = title;
        this.author = author;
    }

    toString() {
        return `${this.title} by ${this.author}`;
    }
}

class BookCollection {
    constructor() {
        this.books = [];
    }

    addBook(book) {
        this.books.push(book);
    }

    [Symbol.iterator]() {
        let index = 0;
        const books = this.books;

        return {
            next() {
                if (index < books.length) {
                    return { value: books[index++], done: false };
                } else {
                    return { done: true };
                }
            }
        };
    }
}

// Cómo declarar
const myBooks = new BookCollection();
myBooks.addBook(new Book('1984', 'George Orwell'));
myBooks.addBook(new Book('The Great Gatsby', 'F. Scott Fitzgerald'));

// Cómo usar
for (const book of myBooks) {
    console.log(book.toString());
}
  • La clase BookCollection representa a una colección de libros. E implementa el protocolo iterator a través del método [Symbol.iterator]().
  • El método [Symbol.iterator]() devuelve un objeto iterator con un método next().
  • Cada invocación de next() devuelve el libro siguiente en la colección, hasta que no haya más, y en ese momento devuelve { done: true }.
  • El bucle for...of se usa entonces para iterar la variable myBooks, y esto usa el iterador definido al interior de BookCollection.

Este patrón es útil para escenarios en que necesitas tener mayor control sobre cómo se accede a los elementos de una colección:

  • A veces necesitas iterar siguiendo un orden específico, o de una manera que no está soportada por los métodos de iteración que incluye el lenguaje.

  • Permite encapsular la estructura y lógica de tu colección, sin importar si estás usando un arreglo, una lista enlazada o cualquier otro tipo de estructura de datos.

  • También puedes agregar funcionalidad adicional, además de la iteración. Por ejemplo, filtrar elementos mientras se itera, recorrer los datos con un orden bien particular, o incluso transformar los datos durante la iteración.

Strategy

Ahora, si quieres modificar o extender el comportamiento de una clase, sin modificar la clase directamente, puedes usar el patrón Strategy, o estrategia.

Por ejemplo,

  • puedes filtrar un arreglo eliminando valores positivos,
  • o puedes filtrarlo eliminando todos los valores impares.

Estas son dos estrategias, pero tal vez en el futuro quieras añadir más, respetando el principio Open Closed.

Bueno, podemos definir una estrategia de filtro, crear una implementación que eliminará todos los valores negativos y una implementación que eliminará todos los valores impares.

// Estrategia para filtrar números negativos
class NegativeNumberFilter {
    filter(numbers) {
        return numbers.filter(number => number >= 0);
    }
}

// Estrategia para filtrar números impares
class OddNumberFilter {
    filter(numbers) {
        return numbers.filter(number => number % 2 === 0);
    }
}

// Clase Context que utiliza una estrategia
class NumberFilterContext {
    constructor() {
        this.strategy = null;
    }

    setStrategy(strategy) {
        this.strategy = strategy;
    }

    filter(numbers) {
        if (this.strategy === null) {
            throw new Error("Please set a strategy.");
        }

        return this.strategy.filter(numbers);
    }
}

// Cómo usar
const numbers = [1, -2, 3, 4, -5, 6, -7, 8, 9];
const numberFilter = new NumberFilterContext();

console.log("Lista original:", numbers);

// Una estrategia
numberFilter.setStrategy(new NegativeNumberFilter());
console.log("Luego de filtrar negativos:", numberFilter.filter(numbers));

// Otra estrategia
numberFilter.setStrategy(new OddNumberFilter());
console.log("Luego de filtrar impares:", numberFilter.filter(numbers));

Como ves, solo tenemos que pasar la estrategia de nuestro interés a nuestro objeto que filtra, y obtendremos el resultado deseado.

De esta manera, podemos añadir estrategias adicionales, sin modificar nuestra clase encargada de filtrar.

Adapter

A continuación tenemos Adapter, nuestro primer patrón estructural.

Es análogo al mundo real donde tenemos distintos tipos de enchufes:

  • En algunos países es más común usarlos con 2 entradas, y en otros con 3.
  • Por tanto existen adaptadores.

Veamos un ejemplo en código.

Digamos que un sistema tradicionalmente usaba esta clase, para subir archivos a la nube:

// Clase antigua para subir archivos
class LegacyStorage {
    uploadFile(fileName, data) {
        // Lógica para subir archivos
    }

    downloadFile(fileName) {
        // Lógica para descargar archivos
    }
}

Se usaba de esta manera:

const legacyStorage = new LegacyStorage();

// Subir un archivo
legacyStorage.uploadFile("un-documento.txt", "Contenido del documento");

// Descargar un archivo
legacyStorage.downloadFile("otro-documento.txt");

Ahora queremos usar Amazon S3, y por tanto una nueva clase, muy distinta a lo usado actualmente.

class AmazonS3 {
    putObject(params) {
        console.log(`[AmazonS3] Uploading to bucket: ${params.Bucket}, Key: ${params.Key}`);
        // Lógica para subir a S3
    }

    getObject(params) {
        console.log(`[AmazonS3] Downloading from bucket: ${params.Bucket}, Key: ${params.Key}`);
        // Lógica para descargar de S3
    }
}

A fin de no hacer muchos cambios en distintas secciones de nuestro proyecto, definimos una clase adapter.

// Adapter para Amazon S3
class S3Adapter {
    constructor(s3) {
        this.s3 = s3;
    }

    uploadFile(fileName, data) {
        this.s3.putObject({ Bucket: 'your-bucket-name', Key: fileName, Body: data });
    }

    downloadFile(fileName) {
        this.s3.getObject({ Bucket: 'your-bucket-name', Key: fileName });
    }
}

Y se usaría de la siguiente manera:

// Creamos un cliente de S3
const s3 = new AmazonS3();

// Creamos una instancia de nuestro Adapter 
const storage = new S3Adapter(s3);

// Seguimos usando storage como veniamos haciendo
storage.uploadFile("un-documento.txt", "Contenido a subir a S3");
storage.downloadFile("documento-a-descargar-de-s3.txt");

Facade

Y nuestro último patrón es Facade, que es una palabra de origen francés: façade.

Según el diccionario, una fachada es una apariencia externa, que oculta una realidad menos agradable.

En el mundo de la programación, la apariencia externa es la clase o interfaz con la que vamos a interactuar como programadores, y la realidad menos agradable es, la complejidad que se quiere ocultar.

Entonces, una fachada es simplemente una clase usada para abstraer detalles de bajo nivel, a fin de facilitar el uso de componentes más complejos.

Aquí tenemos un par de ejemplos:

  • La función fetch de JavaScript abstrae detalles de red que ocurren a bajo nivel.
  • Los arreglos dinámicos, como los vectores en C++, o los ArrayList en Java, constantemente están siendo redimensionados, de modo que siempre funcionan y no tenemos que preocuparnos por cómo reservar y asignar memoria.

Aprende viendo

¿Sabías que este contenido está también disponible en mi canal de YouTube?

Si estás interesado en aprender más, te invito a revisar mis cursos 😃

# javascript

Logo de Programación y más

Comparte este post si te fue de ayuda 🙂.

Regístrate

Accede a todos los cursos, y resuelve todas tus dudas.

Cursos Recomendados

Imagen para el curso Aprende Javascript

Aprende Javascript

Domina JS con este curso práctico y completo! Fundamentos, ejemplos reales, ES6+, POO, Ajax, Webpack, NPM y más.

Iniciar curso
Imagen para el curso Aprende Vue 3

Aprende Vue 3

Nuevo curso! Aprende Vite, Pinia, Vuetify, Vue Router y TypeScript. Desarrolla un eCommerce desde cero.

Iniciar curso
Imagen para el curso Laravel y Vue

Laravel y Vue

Desarrollemos un Messenger! Aprende sobre Channels, Queues, Vuex, JWT, Sesiones, BootstrapVue y mucho más.

Iniciar curso

Espera un momento ...

¿Te gustaría llevar mi curso de Laravel, gratis?

Sólo debes ingresar tus datos: