¿Alguna vez has estado en un equipo en el que necesitas comenzar un proyecto desde cero? Ese suele ser el caso en muchas empresas emergentes y otras pequeñas empresas.
Hay tantos lenguajes de programación, arquitecturas y otras preocupaciones diferentes que puede ser difícil saber por dónde empezar. Ahí es donde entran los patrones de diseño.
Un patrón de diseño es como una plantilla para su proyecto. Utiliza ciertas convenciones y puede esperar un tipo específico de comportamiento de él. Estos patrones se componían de las experiencias de muchos desarrolladores, por lo que en realidad son como diferentes conjuntos de mejores prácticas.
Y usted y su equipo pueden decidir qué conjunto de mejores prácticas es más útil para su proyecto. Según el patrón de diseño que elija, todos comenzarán a tener expectativas sobre lo que debería hacer el código y el vocabulario que usarán.
Los patrones de diseño de programación se pueden usar en todos los lenguajes de programación y se pueden usar para adaptarse a cualquier proyecto porque solo le brindan un esquema general de una solución.
Hay 23 patrones oficiales del libro Design Patterns - Elements of Reusable Object-Oriented Software , que se considera uno de los libros más influyentes sobre teoría orientada a objetos y desarrollo de software.
En este artículo, voy a cubrir cuatro de esos patrones de diseño solo para darle una idea de cuáles son algunos de los patrones y cuándo los usaría.
El patrón de diseño Singleton
El patrón singleton solo permite que una clase u objeto tenga una sola instancia y usa una variable global para almacenar esa instancia. Puede usar la carga diferida para asegurarse de que solo haya una instancia de la clase porque solo creará la clase cuando la necesite.
Eso evita que varias instancias estén activas al mismo tiempo, lo que podría causar errores extraños. La mayoría de las veces esto se implementa en el constructor. El objetivo del patrón singleton suele ser regular el estado global de una aplicación.
Un ejemplo de singleton que probablemente usa todo el tiempo es su registrador.
Si trabaja con algunos de los frameworks de front-end como React o Angular, sabe todo lo complicado que puede ser manejar registros provenientes de múltiples componentes. Este es un gran ejemplo de singletons en acción porque nunca desea más de una instancia de un objeto registrador, especialmente si está usando algún tipo de herramienta de seguimiento de errores.
class FoodLogger { constructor() { this.foodLog = [] } log(order) { this.foodLog.push(order.foodItem) // do fancy code to send this log somewhere } } // this is the singleton class FoodLoggerSingleton { constructor() { if (!FoodLoggerSingleton.instance) { FoodLoggerSingleton.instance = new FoodLogger() } } getFoodLoggerInstance() { return FoodLoggerSingleton.instance } } module.exports = FoodLoggerSingleton
Ahora no tiene que preocuparse por perder registros de varias instancias porque solo tiene uno en su proyecto. Entonces, cuando desee registrar la comida que se ordenó, puede usar la misma instancia de FoodLogger en varios archivos o componentes.
const FoodLogger = require('./FoodLogger') const foodLogger = new FoodLogger().getFoodLoggerInstance() class Customer { constructor(order) { this.price = order.price this.food = order.foodItem foodLogger.log(order) } // other cool stuff happening for the customer } module.exports = Customer
const FoodLogger = require('./FoodLogger') const foodLogger = new FoodLogger().getFoodLoggerInstance() class Restaurant { constructor(inventory) { this.quantity = inventory.count this.food = inventory.foodItem foodLogger.log(inventory) } // other cool stuff happening at the restaurant } module.exports = Restaurant
Con este patrón singleton en su lugar, no tiene que preocuparse por obtener los registros del archivo principal de la aplicación. Puede obtenerlos desde cualquier lugar de su base de código y todos irán a la misma instancia exacta del registrador, lo que significa que ninguno de sus registros debe perderse debido a nuevas instancias.
El patrón de diseño de estrategia
La estrategia es patrón es como una versión avanzada de una declaración if else. Básicamente es donde crea una interfaz para un método que tiene en su clase base. Luego, esta interfaz se usa para encontrar la implementación correcta de ese método que debe usarse en una clase derivada. La implementación, en este caso, se decidirá en tiempo de ejecución según el cliente.
Este patrón es increíblemente útil en situaciones en las que tiene métodos obligatorios y opcionales para una clase. Algunas instancias de esa clase no necesitarán los métodos opcionales y eso causa un problema para las soluciones de herencia. Puede usar interfaces para los métodos opcionales, pero luego tendrá que escribir la implementación cada vez que use esa clase, ya que no habría una implementación predeterminada.
Ahí es donde nos salva el patrón de estrategia. En lugar de que el cliente busque una implementación, delega en una interfaz de estrategia y la estrategia encuentra la implementación correcta. Un uso común de esto es con los sistemas de procesamiento de pagos.
Usted podría tener un carrito de la compra que sólo permite a los clientes comprobar con sus tarjetas de crédito, pero perderá clientes que desean utilizar otros métodos de pago.
El patrón de diseño de la estrategia nos permite desacoplar los métodos de pago del proceso de pago, lo que significa que podemos agregar o actualizar estrategias sin cambiar ningún código en el carrito de compras o en el proceso de pago.
A continuación se muestra un ejemplo de implementación de un patrón de estrategia utilizando el ejemplo de método de pago.
class PaymentMethodStrategy { const customerInfoType = { country: string emailAddress: string name: string accountNumber?: number address?: string cardNumber?: number city?: string routingNumber?: number state?: string } static BankAccount(customerInfo: customerInfoType) { const { name, accountNumber, routingNumber } = customerInfo // do stuff to get payment } static BitCoin(customerInfo: customerInfoType) { const { emailAddress, accountNumber } = customerInfo // do stuff to get payment } static CreditCard(customerInfo: customerInfoType) { const { name, cardNumber, emailAddress } = customerInfo // do stuff to get payment } static MailIn(customerInfo: customerInfoType) { const { name, address, city, state, country } = customerInfo // do stuff to get payment } static PayPal(customerInfo: customerInfoType) { const { emailAddress } = customerInfo // do stuff to get payment } }
Para implementar nuestra estrategia de método de pago, creamos una sola clase con múltiples métodos estáticos. Cada método toma el mismo parámetro, customerInfo , y ese parámetro tiene un tipo definido de customerInfoType . (¡Hola a todos los desarrolladores de TypeScript! ??) Tenga en cuenta que cada método tiene su propia implementación y utiliza valores diferentes de customerInfo .
Con el patrón de estrategia, también puede cambiar dinámicamente la estrategia que se utiliza en tiempo de ejecución. Eso significa que podrá cambiar la estrategia, o la implementación del método, que se utiliza según la entrada del usuario o el entorno en el que se ejecuta la aplicación.
También puede establecer una implementación predeterminada en un archivo config.json simple como este:
{ "paymentMethod": { "strategy": "PayPal" } }
Cada vez que un cliente comienza a realizar el proceso de pago en su sitio web, el método de pago predeterminado que encuentran será la implementación de PayPal que proviene de config.json . Esto podría actualizarse fácilmente si el cliente selecciona un método de pago diferente.
Ahora crearemos un archivo para nuestro proceso de pago.
const PaymentMethodStrategy = require('./PaymentMethodStrategy') const config = require('./config') class Checkout { constructor(strategy='CreditCard') { this.strategy = PaymentMethodStrategy[strategy] } // do some fancy code here and get user input and payment method changeStrategy(newStrategy) { this.strategy = PaymentMethodStrategy[newStrategy] } const userInput = { name: 'Malcolm', cardNumber: 3910000034581941, emailAddress: '[email protected]', country: 'US' } const selectedStrategy = 'Bitcoin' changeStrategy(selectedStrategy) postPayment(userInput) { this.strategy(userInput) } } module.exports = new Checkout(config.paymentMethod.strategy)
Esta clase de Checkout es donde el patrón de estrategia llega a lucirse. Importamos un par de archivos para que tengamos las estrategias de método de pago disponibles y la estrategia predeterminada de la configuración .
Luego creamos la clase con el constructor y un valor de respaldo para la estrategia predeterminada en caso de que no haya uno establecido en la configuración . A continuación, asignamos el valor de la estrategia a una variable de estado local.
Un método importante que debemos implementar en nuestra clase de Checkout es la capacidad de cambiar la estrategia de pago. Un cliente puede cambiar el método de pago que desea usar y usted deberá poder manejar eso. Para eso es el método changeStrategy .
Una vez que haya realizado una codificación elegante y haya obtenido todas las entradas de un cliente, puede actualizar la estrategia de pago de inmediato en función de su entrada y establecerá dinámicamente la estrategia antes de que el pago se envíe para su procesamiento.
En algún momento, es posible que deba agregar más métodos de pago a su carrito de compras y todo lo que tendrá que hacer es agregarlo a la clase PaymentMethodStrategy . Estará disponible instantáneamente en cualquier lugar donde se use esa clase.
El patrón de diseño de estrategia es poderoso cuando se trata de métodos que tienen múltiples implementaciones. Puede parecer que está usando una interfaz, pero no tiene que escribir una implementación para el método cada vez que lo llama en una clase diferente. Le brinda más flexibilidad que las interfaces.
The Observer Design Pattern
If you've ever used the MVC pattern, you've already used the observer design pattern. The Model part is like a subject and the View part is like an observer of that subject. Your subject holds all of the data and the state of that data. Then you have observers, like different components, that will get that data from the subject when the data has been updated.
The goal of the observer design pattern is to create this one-to-many relationship between the subject and all of the observers waiting for data so they can be updated. So anytime the state of the subject changes, all of the observers will be notified and updated instantly.
Some examples of when you would use this pattern include: sending user notifications, updating, filters, and handling subscribers.
Say you have a single page application that has three feature dropdown lists that are dependent on the selection of a category from a higher level dropdown. This is common on many shopping sites, like Home Depot. You have a bunch of filters on the page that are dependent on the value of a top-level filter.
The code for the top-level dropdown might look something like this:
class CategoryDropdown { constructor() { this.categories = ['appliances', 'doors', 'tools'] this.subscriber = [] } // pretend there's some fancy code here subscribe(observer) { this.subscriber.push(observer) } onChange(selectedCategory) { this.subscriber.forEach(observer => observer.update(selectedCategory)) } }
This CategoryDropdown file is a simple class with a constructor that initializes the category options we have available for in the dropdown. This is the file you would handle retrieving a list from the back-end or any kind of sorting you want to do before the user sees the options.
The subscribe method is how each filter created with this class will receive updates about the state of the observer.
The onChange method is how we send out notification to all of the subscribers that a state change has happened in the observer they're watching. We just loop through all of the subscribers and call their update method with the selectedCategory.
The code for the other filters might look something like this:
class FilterDropdown { constructor(filterType) { this.filterType = filterType this.items = [] } // more fancy code here; maybe make that API call to get items list based on filterType update(category) { fetch('//example.com') .then(res => this.items(res)) } }
This FilterDropdown file is another simple class that represents all of the potential dropdowns we might use on a page. When a new instance of this class is created, it needs to be passed a filterType. This could be used to make specific API calls to get the list of items.
The update method is an implementation of what you can do with the new category once it has been sent from the observer.
Now we'll take a look at what it means to use these files with the observer pattern:
const CategoryDropdown = require('./CategoryDropdown') const FilterDropdown = require('./FilterDropdown') const categoryDropdown = new CategoryDropdown() const colorsDropdown = new FilterDropdown('colors') const priceDropdown = new FilterDropdown('price') const brandDropdown = new FilterDropdown('brand') categoryDropdown.subscribe(colorsDropdown) categoryDropdown.subscribe(priceDropdown) categoryDropdown.subscribe(brandDropdown)
What this file shows us is that we have 3 drop-downs that are subscribers to the category drop-down observable. Then we subscribe each of those drop-downs to the observer. Whenever the category of the observer is updated, it will send out the value to every subscriber which will update the individual drop-down lists instantly.
The Decorator Design Pattern
Using the decorator design pattern is fairly simple. You can have a base class with methods and properties that are present when you make a new object with the class. Now say you have some instances of the class that need methods or properties that didn't come from the base class.
You can add those extra methods and properties to the base class, but that could mess up your other instances. You could even make sub-classes to hold specific methods and properties you need that you can't put in your base class.
Either of those approaches will solve your problem, but they are clunky and inefficient. That's where the decorator pattern steps in. Instead of making your code base ugly just to add a few things to an object instance, you can tack on those specific things directly to the instance.
So if you need to add a new property that holds the price for an object, you can use the decorator pattern to add it directly to that particular object instance and it won't affect any other instances of that class object.
Have you ever ordered food online? Then you've probably encountered the decorator pattern. If you're getting a sandwich and you want to add special toppings, the website isn't adding those toppings to every instance of sandwich current users are trying to order.
Here's an example of a customer class:
class Customer { constructor(balance=20) { this.balance = balance this.foodItems = [] } buy(food) { if (food.price) < this.balance { console.log('you should get it') this.balance -= food.price this.foodItems.push(food) } else { console.log('maybe you should get something else') } } } module.exports = Customer
And here's an example of a sandwich class:
class Sandwich { constructor(type, price) { this.type = type this.price = price } order() { console.log(`You ordered a ${this.type} sandwich for $ ${this.price}.`) } } class DeluxeSandwich { constructor(baseSandwich) { this.type = `Deluxe ${baseSandwich.type}` this.price = baseSandwich.price + 1.75 } } class ExquisiteSandwich { constructor(baseSandwich) { this.type = `Exquisite ${baseSandwich.type}` this.price = baseSandwich.price + 10.75 } order() { console.log(`You ordered an ${this.type} sandwich. It's got everything you need to be happy for days.`) } } module.exports = { Sandwich, DeluxeSandwich, ExquisiteSandwich }
This sandwich class is where the decorator pattern is used. We have a Sandwich base class that sets the rules for what happens when a regular sandwich is ordered. Customers might want to upgrade sandwiches and that just means an ingredient and price change.
You just wanted to add the functionality to increase the price and update the type of sandwich for the DeluxeSandwich without changing how it's ordered. Although you might need a different order method for an ExquisiteSandwich because there is a drastic change in the quality of ingredients.
The decorator pattern lets you dynamically change the base class without affecting it or any other classes. You don't have to worry about implementing functions you don't know, like with interfaces, and you don't have to include properties you won't use in every class.
Now if we'll go over an example where this class is instantiated as if a customer was placing a sandwich order.
const { Sandwich, DeluxeSandwich, ExquisiteSandwich } = require('./Sandwich') const Customer = require('./Customer') const cust1 = new Customer(57) const turkeySandwich = new Sandwich('Turkey', 6.49) const bltSandwich = new Sandwich('BLT', 7.55) const deluxeBltSandwich = new DeluxeSandwich(bltSandwich) const exquisiteTurkeySandwich = new ExquisiteSandwich(turkeySandwich) cust1.buy(turkeySandwich) cust1.buy(bltSandwich)
Final Thoughts
I used to think that design patterns were these crazy, far-out software development guidelines. Then I found out I use them all the time!
A few of the patterns I covered are used in so many applications that it would blow your mind. They are just theory at the end of the day. It's up to us as developers to use that theory in ways that make our applications easy to implement and maintain.
Have you used any of the other design patterns for your projects? Most places usually pick a design pattern for their projects and stick with it so I'd like to hear from you all about what you use.
Thanks for reading. You should follow me on Twitter because I usually post useful/entertaining stuff: @FlippedCoding