I'm sure you've heard about design patterns in general, and you've been a bit confused.
Today we will learn eight design patterns, in a simple way, so that you can remember them easily.
- In 1994, The Gang of Four published the sacred book 'Design Patterns'',
- Introducing 23 object-oriented design patterns,
- Classified into three categories: Creational Patterns, Structural Patterns, and Behavioral Patterns.
You may believe that this book published more than 30 years ago is still generating interesting discussions?
Likewise,:
Interviews often include questions about these design patterns.
Ok, enough about the story.
Let's start with our first creational pattern: Factory.
Factory
Imagine you want a burger, but you don't want to worry about getting all the ingredients and preparing it.
So instead, you simply decide to order a burger.
Well, we can do the same thing with the code.
If a list of ingredients is needed to create a burger, we can use a factory (or Factory).
This factory will be in charge of instantiating hamburgers for us.
Whether it's a plain burger, a Royal burger or even a vegan burger.
All we have to do is tell the factory what kind of burger we want, just like you would make in a restaurant.
// 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());
- Each method in the class
BurgerFactoryCreate a particular type of burger, as indicated by the name. - The method
toStringin the Burger class provides a chain representation of the hamburger.
Builder
Now, if you want a little more control over how the burger is prepared, you can opt for the Builder pattern.
The idea is that if we want to make a hamburger, we don't have to immediately pass all the arguments.
Instead, we can use a Burger Builder BurgerBuilder).
We will have a method to add each ingredient, whether it is bread, cheese or creams.
Each one will return a reference to the Builder, and finally, we will have a method build that will return the final product.
This pattern is used a lot when you have to build complex objects, with many parameters.
// 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
A Singleton is a class of which only one instance can exist at a time.
It has many use cases, for example, keeping a single copy of our application state.
Let's say that in our application we want to know if a user is logged in or not, to access the status we will not use the Constructor method to instantiate.
We'll use a static method called 'getAppState', which will first check if an instance already exists.
- If there isn't, we'll instantiate one.
- If it already exists, we will simply return the existing instance.
This way we make sure we have only one.
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().
This pattern is useful for multiple components in your application to share the same instance, but how can all components hear updates in real time?
Observer
That's where Observer, our first behaviour pattern, comes in.
It's also known as a pub-sub, because one component publishes updates and another subscribes.
It is widely used, beyond object-oriented programming, for example in distributed systems.
An example is YouTube:
- Every time I upload a video,
- All my subscribers receive a notification,
- Including you,
- Because you're subscribed, right??
In this case, the YouTube channel is the publisher, and it will broadcast events, such as the upload of a new video.
We want multiple observers, also known as subscribers, to be notified of these events in real-time.
// 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');
- The class
YouTubeChannelIncludes a list of your subscribers. - When a new user subscribes, we add them to the subscriber list.
- When an event occurs, we go through the list and send the event data to each of them.
To represent different types of subscribers, it is often convenient to define an interface.
For this case, we're simply going to define a class to represent a YouTube user, and print out every notification they receive.
Following this idea, a subscriber can be subscribed to multiple channels.
Iterator
Iterator, or iterator, is a fairly simple pattern, which defines how values can be iterated in an object.
Syntax can vary, depending on the language.
Here's an example of how to implement a custom iterator for a book collection, which is represented by the 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());
}
- The class
BookCollectionrepresents a collection of books. And implement the protocoliteratorthrough the[Symbol.iterator](). - The method
[Symbol.iterator]()Returns an Iterator object with a methodnext(). - Each invocation of next() returns the next book in the collection, until there are no more, and at that point it returns
{ done: true }. - The loop
for...ofis then used to iterate the variablemyBooks, and this uses the iterator defined withinBookCollection.
This pattern is useful for scenarios where you need to have more control over how items in a collection are accessed:
-
Sometimes you need to iterate in a specific order, or in a way that isn't supported by the iteration methods that language includes.
-
It allows you to encapsulate the structure and logic of your collection, regardless of whether you're using an array, a linked list, or any other type of data structure.
-
You can also add additional functionality in addition to iteration. For example, filtering items as you iterate, traversing data in a particular order, or even transforming data during iteration.
Strategy
Now, if you want to modify or extend the behaviour of a class, without modifying the class directly, you can use the Strategy pattern, or strategy.
For example,
- You can filter an array by removing positive values,
- Or you can filter it by removing all odd values.
These are two strategies, but maybe in the future you will want to add more, respecting the Open Closed principle.
Well, we can define a filter strategy, create an implementation that will remove all negative values, and an implementation that will remove all odd values.
// 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));
As you can see, we just have to pass the strategy of our interest to our filtering object, and we will get the desired result.
In this way, we can add additional strategies, without modifying our class in charge of filtering.
Adaptor
Next we have Adapter, our first structural pattern.
It is analogous to the real world where we have different types of plugs:
- In some countries it is more common to use them with 2 inputs, and in others with 3.
- Therefore, there are adaptors.
Let's look at an example in code.
Let's say a system traditionally used this class to upload files to the cloud:
// Clase antigua para subir archivos
class LegacyStorage {
uploadFile(fileName, data) {
// Lógica para subir archivos
}
downloadFile(fileName) {
// Lógica para descargar archivos
}
}
It was used in this way:
const legacyStorage = new LegacyStorage();
// Subir un archivo
legacyStorage.uploadFile("un-documento.txt", "Contenido del documento");
// Descargar un archivo
legacyStorage.downloadFile("otro-documento.txt");
Now we want to use Amazon S3, and therefore a new class, very different from what is currently used.
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
}
}
In order not to make too many changes to different sections of our project, we define an adaptor class.
// 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 });
}
}
And it would be used as follows:
// 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
And our last pattern is Facade, which is a word of French origin: façade.
According to the dictionary, a façade is an external appearance, which hides a less pleasant reality.
In the world of programming, the external appearance is the class or interface with which we are going to interact as programmers, and the least pleasant reality is the complexity that we want to hide.
So, a façade is simply a class used to abstract low-level details, in order to facilitate the use of more complex components.
Here are a couple of examples:
- The fetch function of JavaScript abstracts network details that occur at a low level.
- Dynamic arrays, such as vectors in C++, or the ArrayList at Java, they're constantly being resized, so they always work and we don't have to worry about how to reserve and allocate memory.



