SOLID Design Principles
En este post me gustaría guardar información sobre los principios de diseño SOLID.
Pero a fin de cuentas, ¿Qué es SOLID?
Bueno, básicamente es un set de principios de diseños presentados por Robert C. Martin
By Tim-bezhashvyly - Own work, CC BY-SA 4.0, Link
El, ademas de tener una serie de libros y su blog, hay una selección de 5 principios que usualmente se incluyen. Quiero hacer un breve resumen y una simple implementación en javascript.
Single Responsibility Principle
El primer principio se llama principio de una sola responsabilidad, dicho principio aunque es bastante simple, usualmente tendemos a caer en ese error. Básicamente dice que una clase debería tener una sola responsabilidad y que es una mala idea incluir otras funcionalidades (Y por consiguiente librerías) a esa clase.
Supongamos que tenemos una clase donde guardamos nuestras Tareas
class Tasks {
constructor() {
this.tasks = {};
}
addEvent(text) {
let c = ++Tasks.count;
let task = `${c}: ${text}`;
this.tasks[c] = task;
return c;
}
removeEvent(index) {
delete this.tasks[index]
}
toString() {
return Object.values(this.tasks).join('\n');
}
}
Y para crear nuestras tareas, lo usamos de la siguiente forma
Tasks.count = 0;
let taskManager = new Tasks();
taskManager.addEvent('Study design patterns');
taskManager.addEvent('Clean the house');
console.log(taskManager.toString())
// Resultado:
// $ node index.js
// 1: Study design patterns
// 2: Clean the house
Todo va bien, porque la clase Tasks solamente tiene una responsabilidad. Pero imaginemos que queremos guardar todas las tareas en una base de datos. Esto se puede hacer fácilmente agregando una nueva función de la siguiente forma:
const fs = require('fs');
class Tasks {
constructor() {
this.tasks = {};
}
// Another Methods
save(filename) {
fs.writeFileSync(filename, this.toString())
}
}
Todo luce bien, pero más adelante se tendrá que agregar la función que cargue el archivo, y de pronto en el futuro se quiera traer desde un WebService. Y pues allí ya estamos cayendo en que ya no tenemos una sola responsabilidad, y el tema es ¿porque esto es un problema?
Ya agregamos una segunda responsabilidad a nuestra clase, con ello nuevas librerías, etc pero, imaginemos que esa funcionalidad de guardar quiere ser utilizada en otra clase? Ya vamos viendo por donde va el asunto … no mezclar peras con manzanas
Entonces tiene más sentido sacar todas esas operaciones y hacer una clase separada como la siguiente:
class PersistenceManager {
saveToFile(tasks, filename) {
fs.writeFileSync(filename, tasks.toString())
}
}
Y podemos conectar nuestra clase Tasks con la nueva de la siguiente forma:
let taskManager = new Tasks();
// Adding some tasks
console.log(taskManager.toString())
let persist = new PersistenceManager();
persist.saveToFile(taskManager, 'tasks.txt')
Finalmente, podemos darnos cuenta que es mejor agrupar funcionalidades por clases en vez de tener todas las funcionalidades en una misma clase.
Open-Close Principle
Para explicar estre principio, lo haremos con un ejemplo. Digamos que tenemos una lista de productos y queremos filtrar en base a diferentes criterios.
let Color = Object.freeze({
red: 'red',
green: 'green',
blue: 'blue'
});
let Size = Object.freeze({
small: 'small',
medium: 'medium',
large: 'large'
});
class Product
{
constructor(name, color, size)
{
this.name = name;
this.color = color;
this.size = size;
}
}
class ProductFilter
{
filterByColor(products, color)
{
return products.filter(p => p.color === color);
}
}
En esta implementación crearemos 2 clases que se encargarán de nuestra lógica básica. La primera es el modelo de Product
y la clase que realizará los filtros de nuestros datos ProductFilter
.
Para implementar nuestra aplicación usaremos el siguiente código que nos permitirá instanciar los nuevos productos Product
con sus caracteristicas y usar el metodo filterByColor
de la clase ProductFilter
para realizar un in filtro por el Color.green
.
let apple = new Product('Apple', Color.green, Size.small);
let tree = new Product('Tree', Color.green, Size.large);
let house = new Product('House', Color.blue, Size.large);
let products = [apple, tree, house];
let pf = new ProductFilter();
console.log(`Green products (old):`);
for (let p of pf.filterByColor(products, Color.green))
console.log(` * ${p.name} is green`);
// Green products (old):
// * Apple is green
// * Tree is green
En este momento todo está funcionando correctamente, para los requerimientos actuales, pero digamos que queremos filtrar por Size
class ProductFilter
{
filterByColor(products, color)
{
return products.filter(p => p.color === color);
}
filterBySize(products, size)
{
return products.filter(p => p.size === size);
}
}
Aquí es donde podemos implementar el Open-Close Principle, donde esencialmente los objetos son abiertos para extensión y pero cerrados para modificación.. Pero … ¿Que quiere decir extensión y modificación?; por ejemplo si vamos a la clase ProductFilter
y agregamos un nuevo metodo, eso es modificación; estarianmos modificando una clase que ya ha sido terminada y testeada. Hacer modificaciones sobre esa clase, en esta situación, no es tan bueno como una extensión. Y en cuanto a extensión, lo relacionamos con jerarquía, que quiere decir que una clase se hereda de otra clase y automaticamente tiene propiedades y miembros de la clase padre para tener funcionalidades adicionales.
Osea que la idea es que esta clase de ProductFilter
no necesite ser modificada. Que si vamos a agregar un nuevo filtro Ej: filterBySizeAndColor
no tengamos que modificar la clase, siendo que esto no es algo dinamico y podriamos caer en una explosión de metodos. En este caso podemos implemenetar el patrón de diseño Specification Pattern, que nos permitirá crear una solución más modular y más fácil de trabajar.
La idea es que para trabajar con este patrón, siempre que tengamos un filtro en especifico, se creará una clase nueva para este filtro y esa clase será una nueva espeficicación. Así como lo veremos a continuación:
// specification
class ColorSpecification
{
constructor(color)
{
this.color = color;
}
isSatisfied(item)
{
return item.color === this.color;
}
}
class SizeSpecification
{
constructor(size)
{
this.size = size;
}
isSatisfied(item)
{
return item.size === this.size;
}
}
En este caso hemos creado 2 especificaciones, una para los Color
y otro para los Size
. Aunque puede que luzca bastante elavorado, tenemos el beneficio de que los modelos no están relacionados y simplemente con el metodo isSatisfied(item)
podemos determinar si el item que se tiene cumple con los requisitos del filtro.
Ahora vamos a ver como podemos usar estas especificaciones:
class BetterFilter
{
filter(items, spec)
{
return items.filter(x => spec.isSatisfied(x));
}
}
let bf = new BetterFilter();
console.log(`Green Products (new):`);
for (let p of bf.filter(products,
new ColorSpecification(Color.green)))
{
console.log(` * ${p.name} is green`);
}
// Green Products (new):
// * Apple is green
// * Tree is green
En este momento, esta solución es buena pero necesitamos hacerle una pequeña modificación porque esto solamente funcionaría para una sola especificación. Así que extenderemos un poco esto:
class AndSpecification
{
constructor(...specs)
{
this.specs = specs;
}
isSatisfied(item)
{
return this.specs.every(x => x.isSatisfied(item));
}
}
// Filter by Large and Green products
let spec = new AndSpecification(
new ColorSpecification(Color.green),
new SizeSpecification(Size.large)
);
let bf = new BetterFilter();
for (let p of bf.filter(products, spec))
{
console.log(` * ${p.name} is Large and Green`);
}
// Large and Green Products:
// * Tree is Large and Green
Bueno, retomando el principio que estamos explicando, la ideal del open-close princicle es que las clases sean abiertas para extensión pero cerradas para la modificación. Queriendo decir que no se debe de entrar en las clases existentes y empezar a modificar el código, a menos que sea algo absolutamente necesario como un bug o algo así.
Liskov Substitution Principle
El principio de sustitución de Liskov define que el objeto de una clase hija debería ser capaz de sustituirse por un objeto de una clase padre sin alterar el funcionamiento de la clase padre.
Al sobreescribir un método, exiende el componamiento base en lugar de sustituirlo con algo totalmente distinto.
Dependency Inversion Principle
Las clases de alto nivel no deben depender de clases de bajo nivel. Ambas deben depender de abstracciones. Las abstracciones no deben depender de dealles. Los detalles deben depender de abstracciones.
Pero, que son clases de alto y bajo nivel?
Las clases de bajo nivel son las que se encargan de implementar las funcionalidades basicas de una aplicación como lo son acceso a disco o una base de datos, transferir datos, etc.
Las clases de alto nivel son las que se encargan de implementar la lógica de negocio compleja.
“El principio de inversión de la dependencia suele ir de la mano del principio de abierto/cerrado: puedes extender clases de bajo nivel para utilizarlas con distintas clases de lógica de negocio sin descomponer clases existentes.”
Excerpt From
Sumérgete en los patrones de diseño
Alexander Shvets
This material may be protected by copyright.