Partes anteriores: Código limpio: funciones y métodos en TypeScript [Parte 1] Código limpio: denominación y composición de código en TypeScript [Parte 2] Código limpio: clases y objetos en TypeScript [Parte 3] 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. Tabla de contenido: Principio de responsabilidad única Principio abierto/cerrado Principio de sustitución de Liskov 1. Principio de Responsabilidad Única (PRS) 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()) { // ... } } } ¿Por qué es importante? 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. 2. Principio Abierto/Cerrado (OCP) 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 , cuyo método 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 y no es seguro; podríamos obtener resultados inesperados. HttpRequestCost getDeliveryCost HttpRequestCost Para evitarlo, deberíamos crear una de método abstracto en la clase sin realizaciones. La realización particular habrá heredado las clases: Ananas y Banana. Ellos mismos realizarán la solicitud. request Product tomará el parámetro siguiendo la interfaz de clase , y cuando pasemos dependencias particulares en , ya realizará el método por sí mismo. HttpRequestCost product Product HttpRequestCost request // 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(...); } } ¿Por qué es importante? Siguiendo este principio, reducirá el acoplamiento de código y salvará al sistema de un comportamiento impredecible. 3. Principio de sustitución de Liskov (LSP) 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 . , y son todos trabajadores y heredaron de la clase principal . 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 y roto el principio de sustitución de Liskov. Contractor Designer Programmer Seller Worker access Este principio nos dice que si reemplazamos la superclase con su subclase, por ejemplo la clase , la funcionalidad no debería interrumpirse. Pero si lo hacemos, la funcionalidad de la clase se romperá: el método tendrá realizaciones inesperadas de la clase . Worker Designer Programmer access 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 y y movimos el método a la clase y definimos una realización específica. Si reemplazamos la clase con la subclase , la funcionalidad no se interrumpirá. Employee Contractor access Employee Worker Contractor Worker ¿Por qué es importante? 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.