Los principios de diseño SÓLIDOS son los principios de diseño más importantes que necesita conocer para escribir código limpio. Tener un dominio sólido de los principios SOLID es una habilidad indispensable para cualquier programador. Son la base sobre la que se desarrollan otros patrones de diseño. En este artículo, abordaremos los principios de diseño SOLID utilizando algunos ejemplos de la vida real y comprenderemos su importancia. Junto con el polimorfismo, la abstracción y la herencia, los principios SOLID son realmente importantes para ser bueno en la programación orientada a objetivos. ¿Por qué son importantes los principios SOLID? Los principios SÓLIDOS son importantes por múltiples razones: Los principios SOLID nos permiten escribir código limpio y fácil de mantener: cuando comenzamos un nuevo proyecto, inicialmente la calidad del código es buena porque tenemos un conjunto limitado de características que implementamos. Sin embargo, a medida que incorporamos más funciones, el código comienza a volverse confuso. Los principios SOLID se basan en los fundamentos de la abstracción, el polimorfismo y la herencia y conducen a patrones de diseño para casos de uso comunes. Comprender estos patrones de diseño ayuda a implementar casos de uso comunes en la programación. Los principios SOLID nos ayudan a escribir código limpio que mejora la capacidad de prueba del código. Esto se debe a que el código es modular y está débilmente acoplado. Cada módulo se puede desarrollar y probar de forma independiente. Exploremos ahora cada uno de los principios SOLID en detalle con ejemplos del mundo real. 1. Principio de responsabilidad única S en principios SOLID significa Principio de Responsabilidad Única. El principio de responsabilidad única establece que una clase debe tener una sola razón para cambiar. Esto limita la cantidad de lugares que necesitamos para realizar cambios al incorporar requisitos adicionales en nuestro proyecto. Cada clase debe tener exactamente una razón para cambiar. Por ejemplo, digamos que estamos diseñando una aplicación bancaria en Java donde tenemos una clase que permite operaciones básicas como débito, crédito y . El método toma una enumeración llamada (como correo electrónico, SMS, etc.) y envía la actualización con el medio apropiado. Escribiremos el código para eso como se muestra a continuación. SavingsAccount sendUpdates sendUpdate NotificationMedium public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void sendNotification(NotificationMedium medium) { if (medium == NotificationMedium.SMS) { // Send SMS here } else if (medium == NotificationMedium.EMAIL) { // Send Email here } } } public enum NotificationMedium { SMS, EMAIL } Ahora, si observa la clase anterior, puede cambiar debido a múltiples razones: SavingsAccount Si hay algún cambio en la lógica central de la clase (como , , etc.). SavingsAccount debit credit Si el banco decide introducir un nuevo medio de Notificación (digamos WhatsApp). Esto es una violación del principio de responsabilidad única de los principios SOLID. Para solucionarlo, crearemos una clase separada que envíe la notificación. Refactoricemos el código anterior de acuerdo con los principios SOLID. public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void printBalance() { System.out.println("Name: " + name+ " Account Balance: " + balance); } public void sendNotification(Medium medium) { Sender.sendNotification(medium, this); } } public enum NotificationMedium { SMS, EMAIL } public class Sender { public static void sendNotification(NotificationMedium medium, SavingsAccount account) { // extract account data from the account object if (medium == NotificationMedium.SMS) { //logic to send SMS here } else if (medium == NotificationMedium.EMAIL) { // logic to send Email here } } } Ahora, dado que hemos refactorizado el código, si hay algún cambio en o en el formato, cambiaremos la clase . Sin embargo, si hay un cambio en la lógica central de , habrá cambios en la clase . NotificationMedium Sender SavingsAccount SavingsAccount Esto soluciona la infracción que observamos en el primer ejemplo. 2. Principio de apertura/cierre El principio Abrir Cerrar establece que debemos diseñar las clases de tal manera que estén abiertas a la extensión (en caso de agregar características adicionales) pero cerradas a la modificación. Estar cerrado por modificaciones nos ayuda de dos maneras: Muchas veces, es posible que la fuente de la clase original ni siquiera esté disponible. Podría ser una dependencia consumida por su proyecto. Mantener la clase original sin cambios reduce las posibilidades de que se produzcan errores. Ya que puede haber otras clases que dependan de la clase que queremos modificar. Para ver un ejemplo del principio Abrir Cerrar, echemos un vistazo al ejemplo de un carrito de compras (como el implementado en sitios web de comercio electrónico). Voy a crear una clase llamada que contendrá una lista de que puede agregarle. Dependiendo del tipo de artículo y de los impuestos que le corresponden, queremos crear un método que calcule el valor total del carrito dentro de la clase . Cart Item Cart Las clases deben estar abiertas para extensión y cerradas para modificación. import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart() { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { if (item.getItemType() == GIFT) { // 8% tax on gift + 2% gift wrap cost value += (item.getValue()*1.08) + item.getValue()*0.02 } else if (item.getItemType() == ItemType.ELECTRONIC_ITEM) { value += (item.getValue()*1.11); } else { value += item.getValue()*1.10; } } return value; } } @Getter @Setter public abstract class Item { protected double price; private ItemType itemType; public double getValue() { return price; } } public enum ItemType { ELECTRONIC, GIFT } En el ejemplo anterior, el método calcula el valor del carrito iterando sobre todos los artículos dentro del carrito e invocando la lógica basada en el tipo de artículo. calculateCartValue Aunque este código parece correcto, viola los principios SOLID. Digamos que necesitamos agregar una nueva regla para un tipo diferente de artículo (por ejemplo, comestibles) mientras calculamos el valor del carrito. En ese caso, tendríamos que modificar la clase original y escribir otra condición dentro de ella que verifique artículos del tipo . Cart else if Grocery Sin embargo, con poca refactorización, podemos hacer que el código se adhiera al principio de apertura/cierre. Veamos cómo. Primero, haremos que la clase sea abstracta y crearemos clases concretas para diferentes tipos de (s) como se muestra a continuación. Item Item public abstract class Item { protected double price; public double getValue() { return price; } } public class ElectronicItem extends Item { public ElectronicItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.11; } } public class GiftItem extends Item { public GiftItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.08 + super.getValue()*0.02; } } public class GroceryItem extends Item { public GroceryItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.03; } } Dentro de cada clase de artículo concreta como , y implementan el método que contiene la lógica empresarial para el cálculo del valor y los impuestos. GroceryItem GiftItem ElectronicItem getValue() Ahora, haremos que la clase dependa de la clase abstracta e invocaremos el método para cada artículo como se muestra a continuación. Cart Item getValue() import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } } Ahora, en este código refactorizado, incluso si se introducen nuevos tipos de , la clase permanece sin cambios. Debido al polimorfismo, cualquiera que sea el tipo real de dentro de los , se invocaría el método de esa clase. Item Cart Item items ArrayList getValue() 3. Principio de sustitución de Liskov El principio de sustitución de Liskov establece que en un código dado, incluso si reemplazamos el objeto de una superclase con un objeto de la clase hija, el código no debería romperse. En otras palabras, cuando una subclase hereda una superclase y anula sus métodos, debe mantener la coherencia con el comportamiento del método de la superclase. Por ejemplo, si hacemos las siguientes clases y dos clases y . Ahora, digamos que creamos un método llamado dentro de la clase Vehículo, se puede anular en la clase , pero no será compatible con la clase ya que no tiene motor (consulte el ejemplo de código a continuación) Vehicle Car Bicycle startEngine() Car Bicycle Bicycle La subclase debe mantener coherencia con el comportamiento de la superclase al anular métodos. class Vehicle { public void startEngine() { // start engine of the vehicle } } class Car extends Vehicle { @Override public void startEngine() { // Start Engine } } class Bicycle extends Vehicle { @Override public void startEngine(){ throw new UnsupportedOperationException("Bicycle doesn't have engine"); } } Ahora, digamos que hay algún código que espera un objeto de tipo vehículo y se basa en el método . Si, al llamar a ese fragmento de código en lugar de pasar un objeto de tipo , pasamos un objeto , se producirían problemas en el código. Dado que el método de la clase (s) generará una excepción cuando se llame al método . Esto sería una violación de los Principios SOLID (principio de sustitución de Liskov) startEngine() Vehicle Bicycle Bicycle startEngine() Para resolver este problema, podemos crear dos clases y y hacer que herede de la clase y que herede de MotorizedVehicle NonMotorizedVehicle Car MotorizedVehicle Bicycle NonMotorizedVehicle class Vehicle { } class MotorizedVehicle extends Vehicle { public void startEngine() { // start engine here } } class Car extends MotorizedVehicle { @Override public void startEngine() { // Start Engine } } class NonMotorizedVehicle extends Vehicle { public void startRiding() { // Start without engine } } class Bicycle extends NonMotorizedVehicle { @Override public void startRiding(){ // Start riding without the engine. } } 4. Principio de segregación de interfaz La "I" en los Principios SOLID significa el Principio de Segregación de Interfaz. El principio de segregación de interfaces establece que en lugar de tener interfaces más grandes que obliguen a las clases de implementación a implementar métodos no utilizados, deberíamos tener interfaces más pequeñas e implementar clases. De esta manera, las clases sólo implementan los métodos relevantes y permanecen limpias. Divida sus interfaces en varias interfaces más pequeñas en lugar de una interfaz grande. Por ejemplo, veamos el marco de Colecciones integrado en Java. Entre otras estructuras de datos, Java también proporciona estructuras de datos y . LinkedList ArrayList La clase implementa las siguientes interfaces: , , , , y . ArrayList Serializable Cloneable Iterable Collection List RandomAccess La clase implementa , , , , , y . LinkedList Serializable Cloneable Iterable Collection Deque List Queue ¡Son bastantes interfaces! En lugar de tener tantas interfaces, los desarrolladores de Java podrían haber combinado , , , , y en una sola interfaz, digamos la interfaz . Ahora, tanto las clases como podrían haber implementado esta nueva interfaz . Serializable Cloneable Iterable Collecton List RandomAccess IList ArrayList LinkedList IList Sin embargo, dado que no admite el acceso aleatorio, podría haber implementado los métodos en la interfaz y podría haber generado cuando alguien intenta llamarlo. LinkedList RandomAccess UnsupportedOperationException Sin embargo, eso sería una violación del principio de segregación de interfaz en los principios SOLID, ya que "forzaría" a la clase LinkedList a implementar los métodos dentro de la interfaz aunque no sea necesario. RandomAccess Por lo tanto, es mejor dividir la interfaz según el comportamiento común y dejar que cada clase implemente muchas interfaces en lugar de una interfaz grande. Principio de inversión de dependencia El principio de inversión de dependencia establece que las clases del nivel superior no deberían depender directamente de las clases del nivel inferior. Esto provoca un estrecho acoplamiento entre los dos niveles. En lugar de eso, las clases inferiores deberían proporcionar una interfaz de la que deberían depender las clases de nivel superior. Depender de interfaces en lugar de clases Por ejemplo, continuemos con el ejemplo que vimos arriba y mejorémoslo para agregar algunas opciones de pago. Supongamos que tenemos dos tipos de opciones de pago con nosotros y . Ahora, en la clase , queremos agregar un método para que calcularía el valor del carrito e iniciaría el pago en función del pago proporcionado. método. Cart DebitCard Paypal Cart placeOrder Para hacer esto, podríamos haber agregado dependencia en el ejemplo anterior agregando las dos opciones de pago como campos dentro de la clase . Sin embargo, eso uniría estrechamente la clase con la clase y . Cart Cart Cart DebitCard Paypal En lugar de eso, crearíamos una interfaz y haríamos que las clases y implementaran las interfaces . Ahora, la clase dependerá de la interfaz y no de los tipos de pago individuales. Esto mantiene las clases poco acopladas. Payment DebitCard Paypal Payment Cart Payment Vea el código a continuación. public interface Payment { void doPayment(double amount); } public class PaypalPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate paypal payment } } public class DebitCardPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate payment via debit card } } import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; private Payment paymentOption; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); this.paymentOption = paymentOption; } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } public void placeOrder() { this.paymentOption.doPayment(calculateCartValue()); } } Si está interesado en aprender más sobre programación orientada a objetos, patrones de diseño GoF y entrevistas de diseño de bajo nivel, consulte mi curso altamente calificado Programación orientada a objetos + Patrones de diseño Java.