Los 5 principios SOLID
Los principios SOLID explicados en detalle para el desarrollo de aplicaciones mantenibles y robustas
La programación orientada a objetos supuso la aparición de una nueva forma de diseñar aplicaciones.
Habilitó a los desarrolladores la posibilidad de combinar datos con el mismo propósito en una única clase de modo que la funcionalidad común quedaba encapsulada dentro de la aplicación a diferencia de lo que sucede con la programación funcional u otro tipo de paradigmas. Sin embargo, esto no supuso el fin de aplicaciones con una arquitectura errónea o difíciles de mantener.
Con el fin de combatir esto, Robert C. Martin propuso 5 principios con el fin de facilitar a los desarrolladores la creación de aplicaciones fáciles de leer y, lo que es más importante, mantenibles a largo plazo. Estos 5 principios se reúnen en el acrónimo SOLID:
- S: Single Responsibility Principle
- O: Open-Closed Principle
- L: Liskov Substitution Principle
- I: Interface Segregation Principle
- D: Dependency Inversion Principle
En este artículo descubrirás lo que significa cada uno y sus implicaciones en el diseño de aplicaciones.
¡Vamos a verlos!
Single Responsibility Principle
Una clase debería tener una única función.
Una clase debería ser responsable de una única cosa. En el momento en que adquiere más responsabilidad pasa a estar acoplada, algo que no es deseable si se quiere asegurar el mantenimiento de la aplicación. Esto se debe a que el cambio en una de sus responsabilidades puede afectar a la otra y viceversa.
Por supuesto este principio no aplica sólo a las clases, sino también a otros componentes de software así como a los famosos microservicios.
Por ejemplo, supongamos esta clase:
class Vehicle {
constructor(identifier: string){ }
getVehicleIdentifier() { }
saveVehicle(v: Vehicle) { }
getVehicle(identifier: string): Vehicle { }
}
Esta clase viola el SRP. ¿Por qué? El principio de responsabilidad única establece que cada clase debería tener una única función. Sin embargo, la clase Vehicle
se encarga tanto de gestionar las propiedades ( getVehicleIdentifier
) como del almacenamiento en base de datos ( saveVehicle
). Esto provoca que si por ejemplo se produce un cambio en el sistema de almacenamiento de nuestra aplicación es probable que éste afecte a la forma en que se gestionan las propiedades obligándonos a cambiar la clase Vehicle
así como las clases que la estén usando.
Es decir, el SRP establece un alto grado de rigidez, de modo que no se produzca un efecto dominó cada vez que se produzca un cambio.
Una forma de solucionar el ejemplo anterior sería la siguiente:
class Vehicle {
constructor(identifier: string){ }
getVehicleIdentifier() { }
}class VehicleDB {
saveVehicle(v: Vehicle) { }
getVehicle(identifier: string): Vehicle { }
}
Steve Fenton resumió este principio en la siguiente frase:
When designing our classes, we should aim to put related features together, so whenever they tend to change they change for the same reason. And we should try to separate features if they will change for different reasons. — Steve Fenton
Open-Closed Principle
Las clases deberían estar abiertas a su extensión pero cerradas a su modificación.
Continuemos con nuestra clase Vehicle
:
class Vehicle {
constructor(identifier: string){ }
getVehicleIdentifier() { }
}
Supongamos que queremos iterar sobre una lista de coches e imprimir su número de ruedas:
const vehicles: Array<Vehicle> = [
new Vehicle('car'),
new Vehicle('bike')
];function vehicleWheels(a: Array<Vehicle>) {
for(int i = 0; i <= a.length; i++) {
if(a[i].getVehicleIdentifier() == 'car')
log('4');
if(a[i].getVehicleIdentifier() == 'bike')
log('2');
}
}
vehicleWheels(vehicles);
La función vehicleWheels
no cumple el principio “Open-Closed” porque cada vez que añadamos un nuevo tipo de Vehicle
tendremos que modificarla para añadir su if
correspondiente:
const vehicles: Array<Vehicle> = [
new Vehicle('car'),
new Vehicle('bike'),
new Vehicle('quad')
];function vehicleWheels(a: Array<Vehicle>) {
for(int i = 0; i <= a.length; i++) {
if(a[i].getVehicleIdentifier() == 'car')
log('4');
if(a[i].getVehicleIdentifier() == 'bike')
log('2');
if(a[i].getVehicleIdentifier() == 'quad')
log('4');
}
}
vehicleWheels(vehicles);
Lo cual nos obliga a mantener la función vehicleWheels
cada vez que modifiquemos la clase Vehicle
. ¿Cómo conseguimos que respete el principio “Open-Closed”?
class Vehicle {
constructor(identifier: string){ }
getVehicleIdentifier() { }
abstract getWheels();
}class Car extends Vehicle {
getWheels() { return 4; }
}class Bike extends Vehicle {
getWheels() { return 2; }
}class Quad extends Vehicle {
getWheels() { return 4; }
}
Lo cual nos permite modificar la función vehicleWheels
de la siguiente forma:
const vehicles: Array<Vehicle> = [
new Car(),
new Bike(),
new Quad()
];function vehicleWheels(a: Array<Vehicle>) {
for(int i = 0; i <= a.length; i++) {
log(vehicle.getWheels();
}
}
vehicleWheels(vehicles);
Gracias a la inclusión del método abstracto getWheels
implementado por todos los tipos de Vehicle
, la función vehicleWheels
ya no tiene que ser modificada cada vez que añadamos un nuevo tipo de Vehicle
, sino que la responsabilidad de obtener el número de ruedas pasa a recaer sobre las clases concretas que extienden Vehicle
. De este modo ya no necesitamos modificarla (“closed”) pero su comportamiento se verá extendido cada vez que añadamos un nuevo Vehicle
de forma automática (“open”).
Podemos ver este principio con otro ejemplo. Supongamos que tenemos una clase encargada de guardar nuestros vehículos en base de datos:
class VehicleDBManager {
construct(mysqlConnection: MySQLConnection) {};
saveVehicle(v: Vehicle) {}
}
Esta clase no cumple tampoco el principio “Open-Closed” pues si modificamos el sistema de almacenamiento de nuestra aplicación (por ejemplo, migrando a MongoDB) tendremos que modificarla. Una forma de cumplir este principio sería recurrir a una interfaz que represente cualquier conexión a un sistema de almacenamiento:
class VehicleDBManager {
construct(storageConnection: StorageInterface) {};
saveVehicle(v: Vehicle) {}
}
De modo que la clase VehicleDBManager
sea independiente del sistema de almacenamiento de la aplicación, pues todas las clases que gestionen el almacenamiento cumplirán con la interfaz StorageInterface
y podrán ser intercambiadas.
Liskov Substitution Principle
Una clase debería ser sustituible por su clase padre
El objetivo de este principio es asegurar que una sub-clase pueda asumir el lugar de su superclase sin errores. Es decir, si el código termina por comprobar el tipo de un objeto para realizar una acción, el principio de sustitución de Liskov estará siendo violado.
Por ejemplo, volviendo a nuestro ejemplo de los vehículos, si tenemos el siguiente código:
// ..function maxVehicleSpeed(a: Array<Vehicle>) {
for(int i = 0; i <= a.length; i++) {
if(typeof a[i] === Car)
getCarSpeed(100);
if(typeof a[i] === Bike)
getBikeSpeed(20);
}
}
maxVehicleSpeed(vehicles);
la función maxVehicleSpeed
viola el principio de sustitución de Liskov porque es necesario comprobar el tipo para invocar el método adecuado (además del principio “Open-Closed” que vimos anteriormente).
Para conseguir que la función cumpla el principio tendremos que cumplir los siguientes requisitos postulados por Steve Fenton:
- Si la superclase (
Vehicle
) tiene un método que acepta un parámetro con un supertipo (Vehicle
) , cada subclase debería aceptar como argumento tanto un supertipo (Vehicle
) como un subtipo (Car
). - Si la superclase devuelve un supertipo (
Vehicle
) las subclases deberían devolver una superclase (Vehicle
) o una subclase (Car
).
De acuerdo con esto podemos reimplementar la función maxVehicleSpeed
de la siguiente forma:
function maxVehicleSpeed(a: Array<Vehicle>) {
for(int i = 0; i <= a.length; i++) {
a[i].getMaxSpeed();
}
}
maxVehicleSpeed(vehicles);
De modo que ahora todas las subclases de Vehicle
deberían implementar el método getMaxSpeed()
:
class Vehicle {
constructor(identifier: string){ }
getVehicleIdentifier() { }
abstract getWheels(); abstract getMaxSpeed();
}class Car extends Vehicle {
getWheels() { return 4; }
getMaxSpeed() { return 100; }
}class Bike extends Vehicle {
getWheels() { return 2; }
getMaxSpeed() { return 15; }
}
Por lo que, cada vez que encontremos un objeto Bike
, podremos cambiarlo por un objeto Vehicle
y la aplicación seguirá funcionando normalmente sin fallos.
Interface Segregation Principle
Un cliente no debería estar forzado a depender de métodos que no usa
Es decir, dicho de otro modo, el principio establece la restricción de no añadir funcionalidad adicional a las interfaces de modo que acaben teniendo un gran tamaño. Es mejor crear una nueva interfaz y permitir que las clases implementen las interfaces que necesiten a poblarlas de métodos que sus clientes no necesitarán.
Por ejemplo, la siguiente interfaz:
interface VehicleService {
repairWheels();
repairLights();
repairAirbags();
}
Incumple este principio pues se ha ido añadiendo funcionalidad a la interfaz que probablemente no sea necesaria para todos los clientes que la vayan a emplear. Además, sobrecargando la interfaz VehicleService
estaremos obligándonos a modificar las clases que implementen esta interfaz cada vez que añadamos más funcionalidad:
interface VehicleService {
repairWheels();
repairLights();
repairAirbags();
repairAC();
}
Con el fin de cumplir con este principio, podríamos separar la interfaz en varias:
interface WheelsService {
repairWheels();
}interface LightsService {
repairLights();
}interface AirbagsService {
repairAirbags();
}
De modo que deleguemos en las clases la decisión de qué interfaces deseen implementar.
Dependency Inversion Principle
La dependencia debería recaer sobre abstracciones, no sobre clases concretas
Dicho de otro modo:
- A. Las clases de alto nivel no deberían depender de las clases de bajo nivel. Ambas deberían depender de las abstracciones.
- B. Las abstracciones no deberían depender de los detalles. Los detalles deberían depender de las abstracciones.
En el desarrollo de grandes aplicaciones llega un momento en el que contamos con multitud de módulos. Antes de que se produzca un fuerte acoplamiento entre los distintos componentes y una gran dependencia de las librerías empleadas es recomendable aplicar este principio.
Por ejemplo, supongamos que tenemos un servicio encargado de recuperar elementos de la clase Vehicle
desde una API externa:
class VehicleFetcher {
constructor() {
$this->httpService = new HTTPService();
$this->serializer = new Serializer();
}
// rest of code
}
Esta clase incumple el principio de inversión de dependencias pues VehicleFetcher
depende de clases de bajo nivel como la que se encarga de realizar llamadas HTTP o serializar objetos, las cuales está instanciando en el constructor y utilizando posteriormente.
El primer paso para cumplir con este principio será pasar a depender de abstracciones en vez de elementos concretos:
interface HttpServiceInterface {
function get();
function post();
// etc
}class HttpService implements HttpServiceInterface {
// implementations
}interface SerializerInterface {
function serialize();
function unserialize();
}class Serializer implements SerializerInterface {
// implementations
}
De este modo estamos añadiendo una capa de abstracción a nuestra aplicación que nos permite depender tan solo de las interfaces en vez de su implementación concreta (podemos tener distintas librerías que serialicen pero existe un contrato con la interfaz que todas deberán implementar).
A continuación, deberemos dejar de crear las clases concretas en el constructor de la clase VehicleFetcher
de modo que estas sean pasadas desde el constructor:
class VehicleFetcher {
constructor(HttpServiceInterface $httpService, SerializerInterface $serializer) {
$this->httpClient = $httpService;
$this->serializer = $serializer;
}
// rest of code
}
De modo que si cambiamos la librería encargada de realizar llamadas HTTP no nos veamos obligados a modificar el código de la clase VehicleFetcher
pues seguirá recibiendo una interfaz que cumple con el contrato establecido.
Conclusión
En este artículo he cubierto los 5 principios más importante a los que deberíamos adherirnos siempre que planteemos la arquitectura de una aplicación.
Como siempre, creo que es importante recordar que los principios SOLID no dejan de ser recomendaciones que conviene seguir para reforzar la solidez y mantenibilidad de nuestra aplicación, pero dado que implican un nivel extra de complejidad y abstracción está en nuestra mano decidir cuando seguirlos y en qué lugares podemos prescindir de ellos.
¡Espero que os haya gustado este artículo!
¿Quieres recibir más artículos como este?
Si te ha gustado este artículo te animo a que te suscribas a la newsletter que envío cada domingo con publicaciones similares a esta y más contenido recomendado: 👇👇👇