Nada es más fácil que complicar algo que es sencillo. Eso es lo que pienso en muchos casos cuando se mencionan los patrones de diseño y en este post vamos a ver el por qué.
Índex
1 - ¿Qué son los patrones de diseño?
Quiero empezar por mencionar que son los patrones de diseño, porque a diferencia de lo que muchos piensan, un patrón de diseño no es una pieza de código, sino un nombre a una solución para un problema en el código.
Clasificamos estos patrones en 3 grupos diferentes, creacionales, estructurales y colaboraciones y esta distinción viene del libro Design Patterns: Elements of Reusable Object-Oriented Software donde los autores detectan estos grupos ya que distinguen una intención y no implementación del desarrollo de software en la programación orientada a objetos.
Si llevas mucho tiempo en esto, te habrás dado cuenta que el uso de los patrones de diseño varía en función de la experiencia del desarrollador. Esto tiene un motivo simple, las personas menos experimentadas no los conocen por lo que no aplica ninguno. Llega un día en el que conoces cierto patrón y lo quieres aplicar en todas partes y finalmente, llega la tercera etapa donde te das cuenta que aplicar los patrones de diseño en ciertos casos es matar moscas a cañonazos, o lo que es peor intentar solucionar un problema que no tenemos, pero que el querer implementar un patrón nos hace tener.
2 - Entonces el uso de los patrones de diseño, si o no?
Y aquí es donde quería yo llegar, porque los patrones de diseño son muy buenos y ayudan mucho tanto al código como a los desarrolladores cuando están implementados donde deben y cómo deben ya que proporciona un lenguaje común para los desarrolladores y mantienen el código mucho más sencillo de leer y mantener para todo el mundo.
Si no lo utilizas bien puedes acabar con un builder pattern (que menciono más adelante) que no te proporciona valor, por ejemplo si tienes el objeto pizza tienes varias opciones puedes tener un objeto con su constructor:
new pizza(toppings, size)
, o puedes tener lo siguiente:
new Pizza.WithToping(a).WithTopping(b).WithTopping(c).Size(medium).build()
; El cual puedes pensar que da valor, pero en verdad es un wrapper sobre el constructor. Una implementación más correcta es new Pizza.Margarita(size)
, o new Pizza.Hawaiana().WithExtraPineaple().Build()
;
Aunque la primera alternativa que he mencionado es mejor a la alternativa de tener 20 constructores diferentes, con valores por defecto. Así que el uso correcto de los patrones es tan importante como implementarlos bien.
Hay situaciones donde lo podemos evitar, o utilizar alternativas que pueden ser más acordes.
Por ejemplo, un patrón de diseño es el singleton, donde solo tienes una instancia de un objeto, pero hoy en día puedes utilizar inyección de dependencias, donde el resultado final es práticamente el mismo, pero la implementación en mi opinión es mejor con inyeccion de dependencias.
Hay situaciones donde puede causar más trabajo que reducirlo, en ese caso, mejor no hacerlo. O si utlizas builder no tienes que hacerlo en todos los objetos, puedes implementar builder únicamente donde lo necesitas.
Un patrón puede ncluso no solo causar más trabajo, si no que tienes que modificar o hacer prácticas en el código que no tienen sentido para que el patrón encaje.
Con esto en mente, hay una serie de patrones que a mí me parecen muy útiles a la hora de programar en el entorno laboral que nos pueden ayudar mucho en las diferentes etapas de un desarrollo de software.
3 - Patrones de diseño
No quiero hacer un post, ni un curso que hable de todos los patrones de diseño, pero pondré los más claves, en mi opinión, junto a su implementación en el mundo real.
3.1- Patrones de diseño creacionales
Los patrones de diseño creacionales son los que se utilizan a la hora de crear objetos.
Entre ellos tenemos Singleton, Factory Method, Abstract factory.
3.1.1 - Builder Pattern
Aunque lo he mencionado un poco por encima, con el patrón builder lo que hacemos es construir los objetos paso a paso a través de métodos en vez de utilizar el constructor.
Esto garantiza que el objeto resultante siempre va a ser válido y a la larga, si tenemos muchas variables (como en el caso de la pizza), facilitan el mantenimiento del código.
En C# se puede hacer de la siguiente forma:
namespace DesignPatterns.Creationals;
public enum Size { Small, Medium, Large }
public class Pizza
{
public Size Size { get; }
public IReadOnlyList<string> Toppings { get; }
private Pizza(Size size, IEnumerable<string> toppings)
{
Size = size;
Toppings = toppings.ToList().AsReadOnly();
}
public sealed class PizzaBuilder
{
private Size _size = Size.Medium;
private readonly List<string> _toppings = new();
public PizzaBuilder WithSize(Size size)
{
_size = size;
return this;
}
public PizzaBuilder AddTopping(string topping)
{
_toppings.Add(topping);
return this;
}
public PizzaBuilder AddExtraTopping(string topping)
{
_toppings.Add(topping);
_toppings.Add(topping); // +1 unidad extra
return this;
}
public PizzaBuilder RemoveTopping(string topping)
{
_toppings.RemoveAll(t => t.Equals(topping, StringComparison.OrdinalIgnoreCase));
return this;
}
public PizzaBuilder AddCheese() => AddTopping("Cheese");
public PizzaBuilder AddExtraCheese() => AddExtraTopping("Cheese");
public PizzaBuilder RemoveCheese() => RemoveTopping("Cheese");
public PizzaBuilder AddHam() => AddTopping("Ham");
public PizzaBuilder AddExtraHam() => AddExtraTopping("Ham");
public PizzaBuilder RemoveHam() => RemoveTopping("Ham");
public PizzaBuilder AddPineapple() => AddTopping("Pineapple");
public PizzaBuilder AddExtraPineapple() => AddExtraTopping("Pineapple");
public PizzaBuilder RemovePineapple() => RemoveTopping("Pineapple");
public PizzaBuilder AddTomatoes() => AddTopping("Tomatoes");
public PizzaBuilder AddExtraTomatoes() => AddExtraTopping("Tomatoes");
public PizzaBuilder RemoveTomatoes() => RemoveTopping("Tomatoes");
public PizzaBuilder AddBasil() => AddTopping("Basil");
public PizzaBuilder AddExtraBasil() => AddExtraTopping("Basil");
public PizzaBuilder RemoveBasil() => RemoveTopping("Basil");
public Pizza Build()
{
return new Pizza(_size, _toppings);
}
}
public static PizzaBuilder Margarita(Size size = Size.Medium) =>
new PizzaBuilder().WithSize(size)
.AddCheese()
.AddTomatoes()
.AddBasil();
public static PizzaBuilder Hawaiian(Size size = Size.Medium) =>
new PizzaBuilder()
.WithSize(size)
.AddHam()
.AddPineapple();
}
¿Dónde utilizar builder pattern?
En objetos donde podemos tener muchas variaciones del mismo, como en el ejemplo de la pizza y después queremos que sea inmutable.
3.1.2 - Factory method Pattern
En el patrón factory method definimos una clase donde delegamos la responsabilidad de la creación de los objetos y la funcionalidad a sus subclases, por lo que la clase principal actúa como base de otra y nunca como objeto independiente.
En C# hacemos esto con clases abstractas, este sería la implementación en C# si continuamos con el ejemplo de las pizzas.
public abstract class PizzaStore
{
protected abstract Pizza CreateStarPizza();
}
public class MadridStore : PizzaStore
{
protected override Pizza CreateStarPizza()
{
return Pizza.Margarita().Build();
}
}
public class ParisStore : PizzaStore
{
protected override Pizza CreateStarPizza()
{
return Pizza.Margarita().AddTopping("baguette").Build();
}
}
Cada una de las clases hijas implementa a la clase abstracta padre.
¿Dónde utilizar Factory method Pattern?
Cuando la clase principal que utiliza un servicio no debe conocer la implementación de la misma. En .NET se utiliza Ilogger como interfaz para loguear, pero detrás de esa interfaz puedes poner serilog, log4net, etc, al consumidor, le da igual cual.
3.2 - Patrones de diseño estructurales
Los patrones de diseño estructurales se utilizan principalmente para la relación entre los objetos y cómo separarlos o hacerlos independientes para que un cambio en un objeto no afecte al sistema entero.
Dentro de este grupo tenemos bridge, decorator, composite, facade, adapter, flyweight o proxy.
3.2.1 - Adapter
Uno de los patrones más utilizados en el entorno profesional es adapter, ya que permite que es el patrón que permite que dos interfaces trabajen entre sí cuando estas no son directamente compatibles.
El ejemplo más claro donde este código es utilizado es trabajando con sistemas que no tenemos acceso. Estos sistemas pueden ser APIs externas, o incluso una modernización de sistemas actuales (de monolito a microservicios) donde queremos dejar atrás las prácticas realizadas originariamente y montamos el microservicio acorde a los últimos estándares.
De esta forma, con el adapter puedes mantener una compatibilidad temporal hasta que todos los usos de la “forma” antigua cambian (suponiendo que alguna vez suceda dicho cambio).
Otros casos comunes es cuando trabajas con servicios externos y dicho servicio requiere algún dato que tu proceso normal no realiza, en vez de modificar el proceso normal simplemente en nuestra capa donde tenemos el adapter.
3.3 - Patrones de diseño de comportamiento
Los patrones de comportamiento se utilizan para identificar y definir la comunicación entre los objetos.
Hay unos más famosos o más utilizados, y es del grupo en el que mas hay, chain of responsability, command, memento, state, iterator, mediator, observer, strategy, template method y visitor.
Aquí vamos a ver los dos más populares o comunes en las aplicaciones empresariales.
3.3.1 - Mediator pattern
El patrón favorito de todo el mundo, sobre todo en C# es el patrón mediador, donde ya vimos un post al respecto pero para resumir podemos decir que es un partrón el cual hace que las clases u objetos se comunican de forma indirecta, y es la implementación del patrón, en el caso de C#, mediatr quien se encarga de la comunicación entre sí.
[ApiController]
[Route("[controller]")]
public class MediatRExampleController : ControllerBase
{
private readonly ISender _sender;
public MediatRExampleController(ISender sender)
{
_sender = sender;
}
[HttpPut("item")]
public async Task<bool> UpdateItem(ItemDto itemDto)
=> await _sender.Send(new UpdateItemCommand()
{
Id = itemDto.Id,
Price = itemDto.Price,
Title = itemDto.Title
});
}
// Ejecución de a lógica
public class ItemUpdatedEventHandler : INotificationHandler<ItemUpdated>
{
//We dont need this working for the example.
public async Task Handle(ItemUpdated notification, CancellationToken cancellationToken)
{
decimal percentageDifference = ((notification.NewPrice - notification.OldPrice) / notification.NewPrice) * 100;
if (percentageDifference <= -30)
{
Console.WriteLine("Logic to get who is wishlisting that ID and send the emails");
}
}
Desde nuestro código, invocamos al mediador, y es ese mediador quien redirige la llamada.
¿Cuándo utilizar el patrón mediador?
Cuándo quieres desacoplar las diferentes capas de un sistema. Suele ayudar mucho en los microservicios a evitar tener código espagueti al cortar la dependencia directa.
3.3.2 - Observer pattern
El patrón observador es un patrón que permite a un objeto del sistema mantener una lista de suscriptores, los cuales serán notificados automáticamente cuando el estado cambia o se produce un nuevo evento.
De esta forma, ni los subscriptores ni los objetos suscritos tienen están acoplados entre sí.
// Cliente se suscribe y reacciona
Stock st = new Stock();
st.PropertyChanged += (_, e) =>
Console.WriteLine($"{e.PropertyName} cambia a {st.Price}");
st.Price = 10;
st.Price = 35;
class Stock : INotifyPropertyChanged
{
private int _price;
public int Price
{
get => _price;
set
{
if (_price == value) return;
_price = value;
OnPropertyChanged(nameof(Price));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged(string p) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p));
}
En C# tenemos una implementación del patrón observer muy utilizada si usamos WPF o MVVM en general, ya que la propiedad INotifyPropertyChanged
es básicamente el patrón observer.
¿Cuándo utilizar el patrón observer?
Cuando necesitas elementos que reaccionen a otros sin tener que tocar el objeto original.
En el ejemplo que acabamos de ver, estamos imprimiendo por pantalla, pero podría ser una interfaz con un gráfico y que los datos se actualizarán con la base de datos, así actualzarías en tiempo real el gráfico.
Conclusión
Los patrones de diseño son una gran herramienta, pero como cualquier otra debemos utilizarlos con criterio cuando es necesario. La única forma de conseguir tener el conocimiento necesario sobre cuándo implementarlos es con experiencia. No hay otra.