Partes anteriores:
Seguimos considerando enfoques para hacer que nuestro código sea limpio, flexible y mantenible. Y ahora comencemos a investigar soluciones arquitectónicas limpias.
Los principios SOLID fueron introducidos por Robert C. Martin y se consideran ampliamente como las mejores prácticas en programación orientada a objetos y diseño de software.
En este artículo, analizaremos los tres primeros principios SOLID: el principio de responsabilidad única, el principio abierto/cerrado y el principio de sustitución de Liskov en ejemplos prácticos.
Una clase debe tener solo una razón para cambiar
En otras palabras, una clase debería tener una única responsabilidad o trabajo. Es importante porque una clase con una única responsabilidad es más fácil de entender, modificar y mantener. Los cambios en un área del código no se extenderán a todo el sistema, lo que reduce el riesgo de introducir errores.
Veamos un ejemplo práctico y formas de seguir el principio de responsabilidad única:
// Bad class UserSettingsService { constructor(user: IUser) { this.user = user; } changeSettings(settings: IUserSettings): void { if (this.isUserValidated()) { // ... } } getUserInfo(): Promise<IUserSettings> { // ... } async isUserValidated(): Promise<boolean> { const userInfo = await this.getUserInfo(); // ... } }
En este ejemplo, nuestra clase realiza acciones en diferentes direcciones: configura el contexto, lo cambia y lo valida.
Para seguir el SRP, necesitamos dividir estas diferentes responsabilidades.
// Better class UserAuth { constructor(user: IUser) { this.user = user; } getUserInfo(): Promise<IUserSettings> { // ... } async isUserValidated(): boolean { const userInfo = await this.getUserInfo(); // ... } } class UserSettings { constructor(user: IUser) { this.user = user; this.auth = new UserAuth(user); } changeSettings(settings: IUserSettings): void { if (this.auth.isUserValidated()) { // ... } } }
SRP tiene como objetivo mejorar la modularidad del código, minimizando los desafíos derivados de las interdependencias. Al organizar el código en funciones, clases separadas y promover la modularidad, se vuelve más reutilizable, lo que ahorra tiempo que de otro modo se podría dedicar a recodificar la funcionalidad existente.
Las entidades de software (clases, módulos, funciones) deben estar abiertas a la extensión pero cerradas a la modificación.
Debería poder agregar nuevas funciones sin cambiar el código existente. Es importante porque al seguir este principio, puede introducir nuevas funciones o componentes en su sistema sin poner en riesgo la estabilidad del código existente.
Promueve la reutilización del código y reduce la necesidad de realizar grandes cambios al ampliar la funcionalidad.
// Bad class Product { id: number; name: string[]; price: number; protected constructor(id: number, name: string[], price: number) { this.id = id; this.name = name; this.price = price; } } class Ananas extends Product { constructor(id: number, name: string[], price: number) { super(id, name, price); } } class Banana extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } } class HttpRequestCost { constructor(product: Product) { this.product = product; } getDeliveryCost(): number { if (product instanceOf Ananas) { return requestAnanas(url).then(...); } if (product instanceOf Banana) { return requestBanana(url).then(...); } } } function requestAnanas(url: string): Promise<ICost> { // logic for ananas } function requestBanana(url: string): Promise<ICost> { // logic for bananas }
En este ejemplo, el problema está en la clase HttpRequestCost
, cuyo método getDeliveryCost
contiene condiciones para el cálculo de diferentes tipos de productos, y utilizamos métodos separados para cada tipo de producto. Entonces, si necesitamos agregar un nuevo tipo de producto, debemos modificar la clase HttpRequestCost
y no es seguro; podríamos obtener resultados inesperados.
Para evitarlo, deberíamos crear una request
de método abstracto en la clase Product
sin realizaciones. La realización particular habrá heredado las clases: Ananas y Banana. Ellos mismos realizarán la solicitud.
HttpRequestCost
tomará el parámetro product
siguiendo la interfaz de clase Product
, y cuando pasemos dependencias particulares en HttpRequestCost
, ya realizará el método request
por sí mismo.
// Better abstract class Product { id: number; name: string[]; price: string; constructor(id: number, name: string[], price: string) { this.id = id; this.name = name; this.price = price; } abstract request(url: string): void; } class Ananas extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } request(url: string): void { // logic for ananas } } class Banana extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } request(url: string): void { // logic for bananas } } class HttpRequestCost { constructor(product: Product) { this.product = product; } request(): Promise<void> { return this.product.request(url).then(...); } }
Siguiendo este principio, reducirá el acoplamiento de código y salvará al sistema de un comportamiento impredecible.
Los objetos de una superclase deben ser reemplazables por objetos de sus subclases sin dañar la aplicación.
Para entender este principio, echemos un vistazo a este ejemplo:
// Bad class Worker { work(): void {/../} access(): void { console.log('Have an access to closed perimeter'); } } class Programmer extends Worker { createDatabase(): void {/../} } class Seller extends Worker { sale(): void {/../} } class Designer extends Worker { access(): void { throwError('No access'); } }
En este ejemplo, tenemos un problema con la clase Contractor
. Designer
, Programmer
y Seller
son todos trabajadores y heredaron de la clase principal Worker
. Pero al mismo tiempo, los Diseñadores no tienen acceso al perímetro cerrado porque son Contratistas, no Empleados. Y hemos anulado el método access
y roto el principio de sustitución de Liskov.
Este principio nos dice que si reemplazamos la superclase Worker
con su subclase, por ejemplo la clase Designer
, la funcionalidad no debería interrumpirse. Pero si lo hacemos, la funcionalidad de la clase Programmer
se romperá: el método access
tendrá realizaciones inesperadas de la clase Designer
.
Siguiendo el principio de sustitución de Liskov, no debemos reescribir las realizaciones en subclases, sino que debemos crear nuevas capas de abstracción donde definimos realizaciones particulares para cada tipo de abstracción.
Corregimoslo:
// Better class Worker { work(): void {/../} } class Employee extends Worker { access(): void { console.log('Have an access to closed perimeter'); } } class Contractor extends Worker { addNewContract(): void {/../} } class Programmer extends Employee { createDatabase(): void {/../} } class Saler extends Employee { sale(): void {/../} } class Designer extends Contractor { makeDesign(): void {/../} }
Creamos nuevas capas de abstracciones Employee
y Contractor
y movimos el método access
a la clase Employee
y definimos una realización específica. Si reemplazamos la clase Worker
con la subclase Contractor
, la funcionalidad Worker
no se interrumpirá.
Si cumple con LSP, puede sustituir cualquier instancia de subclase donde se espere una instancia de clase base, y el programa aún debería funcionar según lo previsto. Esto promueve la reutilización y la modularidad del código y hace que su código sea más resistente a los cambios.
En el próximo artículo, veremos los principios SOLID de segregación de interfaces e inversión de dependencia.