¿Qué son los principios sólidos?

Los principios SOLID representan un conjunto de cinco principios fundamentales de diseño orientado a objetos (OOD) introducidos por Robert C. Martin.

Los principios SOLID proporcionan directrices para construir software que sea más fácil de mantener y escalar a medida que el proyecto crece. Estos principios ayudan a eliminar “code smells”, simplificar la refactorización del código y apoyar prácticas de desarrollo Agile o Adaptativo.

Esta guía presenta cada principio de forma individual, explicando cómo pueden mejorar tus habilidades de desarrollo y ayudarte a crear un código Java mejor y más robusto.

¿Qué son los principios SOLID?

El acrónimo SOLID representa:

  • S: Single Responsibility Principle
  • O: Open-Closed Principle
  • L: Liskov Substitution Principle
  • I: Interface Segregation Principle
  • D: Dependency Inversion Principle

Single Responsibility Principle

El Principio de Responsabilidad Única establece que cada clase debe centrarse en una sola tarea o propósito específico, asegurando que tenga una única razón para cambiar.

En términos técnicos, el diseño de una clase debe verse influenciado por un solo tipo de cambio, como la lógica de base de datos o la lógica de registro. Por ejemplo, si una clase representa una entidad de datos como Book o Student, solo debería actualizarse cuando haya cambios en la estructura o atributos de esa entidad.

Cumplir este principio tiene beneficios importantes. Reduce el riesgo de que varios equipos trabajen sobre la misma clase con objetivos distintos, lo que podría generar cambios conflictivos o incompatibles.

Además, simplifica el control de versiones. Por ejemplo, si se actualiza una clase dedicada a operaciones de base de datos, podemos identificar fácilmente los cambios relacionados con la funcionalidad de la base de datos.

Este principio también minimiza los conflictos de merge en el desarrollo colaborativo. Al asegurar que cada clase tenga un único propósito, se superponen menos cambios, lo que hace que los conflictos sean menos frecuentes y más fáciles de resolver.

Ejemplo

Aquí hay un ejemplo que demuestra el Principio de Responsabilidad Única. Consideremos una clase Student:

Antes de aplicar SRP, la clase Student gestiona tanto responsabilidades propias del estudiante como notificaciones, lo cual viola el principio.

public class Student {
public String getDetails(int studentID) {
// Logic to fetch student details
}
public void updateGrade(int studentID, int grade) {
// Logic to update student grade
}
public void sendNotification(String message) {
// Logic to send notifications to the student
}
}

Después de aplicar SRP, separamos la responsabilidad de notificación en una nueva clase NotificationService:

public class Student {
public String getDetails(int studentID) {
// Logic to fetch student details
}
public void updateGrade(int studentID, int grade) {
// Logic to update student grade
}
}
public class NotificationService {
public void sendNotification(String message) {
// Logic to send notifications to students
}
}

Ahora la clase Student se centra solo en comportamientos relacionados con el estudiante.

La lógica de notificación queda encapsulada en NotificationService, haciendo el código más fácil de gestionar y modificar.

Cada clase tiene una responsabilidad clara y única, reduciendo el acoplamiento y mejorando la mantenibilidad.

Open-Closed Principle

El Principio Abierto/Cerrado establece que “las entidades de software (clases, módulos o funciones) deben estar abiertas a extensión pero cerradas a modificación”. Esto significa que puedes añadir nueva funcionalidad sin alterar el código existente.

Ejemplo

Imagina una clase Shape utilizada para calcular áreas en una aplicación de geometría. Inicialmente solo soporta rectángulos. Más tarde deseas añadir soporte para círculos.

En lugar de modificar la clase Shape, puedes crear una nueva clase Circle que extienda el comportamiento, manteniendo intacta la clase original.

// Base class
public abstract class Shape {
public abstract double calculateArea();
}
// Rectangle class
public class Rectangle extends Shape {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
public double calculateArea() {
return length * width;
}
}
// Circle class
public class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}

Según este principio:

La clase Shape no se modifica, garantizando estabilidad.
Agregar nuevas formas es sencillo extendiendo Shape.
Este enfoque promueve reutilización, flexibilidad y mantenibilidad.

Liskov Substitution Principle

El Principio de Sustitución de Liskov, introducido por Barbara Liskov en 1987, establece que los subtipos deben poder sustituir a sus tipos base sin provocar comportamientos inesperados.

Ejemplo

Consideremos una clase Bird. Un pájaro normalmente puede volar. Pero un Penguin no puede volar, lo que rompe el principio si hereda directamente ese comportamiento.

Violación:

// Parent class
public class Bird {
public void fly() {
System.out.println("Flying...");
}
}
// Child class
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly");
}
}

Para respetar el principio, se separa el comportamiento de volar:

// Base class
public abstract class Bird {
public abstract void eat();
}
// Flying behavior
public interface Flyable {
void fly();
}
// Subclass for birds that can fly
public class Sparrow extends Bird implements Flyable {
@Override
public void eat() {
System.out.println("Sparrow is eating...");
}
@Override
public void fly() {
System.out.println("Sparrow is flying...");
}
}
// Subclass for birds that cannot fly
public class Penguin extends Bird {
@Override
public void eat() {
System.out.println("Penguin is eating...");
}
}

Ahora Bird se centra solo en comportamientos comunes.
El vuelo está aislado en una interfaz.
Las sustituciones funcionan correctamente sin errores en ejecución.

Interface Segregation Principle

Este principio afirma que ningún cliente debería verse obligado a implementar interfaces que no utiliza.

La idea es evitar interfaces grandes y genéricas y dividirlas en interfaces más pequeñas y específicas.

Ejemplo

Imaginemos un sistema de gestión de bibliotecas. Existe una interfaz general, LibraryServices, que incluye métodos para prestar libros, devolver libros, pagar multas por retraso y reservar libros electrónicos.

Ahora bien, consideremos que existen dos tipos de usuarios: visitantes presenciales y usuarios en línea.

Los visitantes presenciales solo necesitan acceso a las funciones de préstamo y devolución de libros.

Los usuarios en línea se centran en reservar libros electrónicos y pueden no utilizar los servicios de préstamo o devolución.

Tener una única interfaz LibraryServices para ambos tipos de usuarios obliga a cada uno a implementar métodos que no necesita, lo que viola el Principio de Segregación de Interfaces.

Violación del Principio de Segregación de Interfaces

// Fat interface with unrelated methods
public interface LibraryServices {
void borrowBook();
void returnBook();
void payLateFee();
void reserveEBook();
}
// In-person visitor implements all methods but uses only a few
public class InPersonVisitor implements LibraryServices {
@Override
public void borrowBook() {
System.out.println("Borrowing a book...");
}
@Override
public void returnBook() {
System.out.println("Returning a book...");
}
@Override
public void payLateFee() {
System.out.println("Paying a late fee...");
}
@Override
public void reserveEBook() {
// Not applicable for in-person visitors
throw new UnsupportedOperationException("Not applicable for in-person visitors");
}
}

Al adherirse al Principio de Segregación de Interfaces, la interfaz grande y única se divide en interfaces más pequeñas y específicas para cada tipo de cliente.

Aplicación correcta del Principio

// Smaller, focused interfaces
public interface BookBorrowing {
void borrowBook();
void returnBook();
}
public interface FeePayment {
void payLateFee();
}
public interface EBookReservation {
void reserveEBook();
}
// In-person visitors only implement relevant interfaces
public class InPersonVisitor implements BookBorrowing, FeePayment {
@Override
public void borrowBook() {
System.out.println("Borrowing a book...");
}
@Override
public void returnBook() {
System.out.println("Returning a book...");
}
@Override
public void payLateFee() {
System.out.println("Paying a late fee...");
}
}
// Online users implement only their relevant interface
public class OnlineUser implements EBookReservation {
@Override
public void reserveEBook() {
System.out.println("Reserving an e-book...");
}
}

El enfoque anterior garantiza que el diseño permanezca limpio y cumpla con el Principio de Segregación de Interfaces.

Beneficios de seguir el principio

  • Cada cliente trabaja únicamente con las interfaces que son relevantes para él.
  • Reduce dependencias innecesarias.
  • Hace que el sistema sea más fácil de mantener y extender.
  • Evita posibles errores en tiempo de ejecución, como métodos no implementados que lanzan excepciones.

Principio de Inversión de Dependencias

El Principio de Inversión de Dependencias enfatiza que:

“Las entidades deben depender de abstracciones y no de implementaciones concretas.”

Esto significa que los módulos de alto nivel (responsables de la funcionalidad general) no deben depender directamente de módulos de bajo nivel (responsables de los detalles de implementación). En su lugar, ambos deben depender de abstracciones (como interfaces o clases abstractas).

Al seguir este principio, se logra desacoplamiento, lo que hace que el sistema sea más flexible, mantenible y fácil de modificar sin afectar otros componentes.

Ejemplo

Consideremos un NotificationService que necesita enviar mensajes a través de distintos medios como SMS y Email. Si NotificationService depende directamente de clases concretas para enviar SMS o correos electrónicos, quedará fuertemente acoplado a esas implementaciones. Cualquier cambio en las clases de bajo nivel (por ejemplo, agregar notificaciones por WhatsApp) requeriría modificar NotificationService, violando el principio.

Violación del Principio

// High-level module directly depends on low-level modules
public class NotificationService {
private EmailSender emailSender;
private SMSSender smsSender;
public NotificationService() {
this.emailSender = new EmailSender(); // Tight coupling
this.smsSender = new SMSSender(); // Tight coupling
}
public void sendNotification(String message) {
emailSender.sendEmail(message);
smsSender.sendSMS(message);
}
}
class EmailSender {
public void sendEmail(String message) {
System.out.println("Sending Email: " + message);
}
}
class SMSSender {
public void sendSMS(String message) {
System.out.println("Sending SMS: " + message);
}
}

En este ejemplo, NotificationService está fuertemente acoplado tanto a EmailSender como a SMSSender. Agregar un nuevo método de notificación (por ejemplo, WhatsAppSender) requeriría modificar la clase NotificationService, violando el Principio de Inversión de Dependencias.

Aplicación correcta del Principio

Para cumplir con el Principio de Inversión de Dependencias, se introduce una abstracción (una interfaz) de la cual dependan tanto el módulo de alto nivel (NotificationService) como los módulos de bajo nivel (EmailSender, SMSSender).

// Abstraction for sending notifications
public interface NotificationSender {
void send(String message);
}
// Low-level module: EmailSender
public class EmailSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending Email: " + message);
}
}
// Low-level module: SMSSender
public class SMSSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
// High-level module: NotificationService
public class NotificationService {
private final NotificationSender notificationSender;
// Dependency injection via constructor
public NotificationService(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
public void sendNotification(String message) {
notificationSender.send(message);
}
}

Ejemplo de uso

Ahora NotificationService puede trabajar con cualquier implementación de la interfaz NotificationSender, sin depender directamente de clases específicas.

public class Main {
public static void main(String[] args) {
NotificationSender emailSender = new EmailSender();
NotificationService emailService = new NotificationService(emailSender);
emailService.sendNotification("Your email message!");
NotificationSender smsSender = new SMSSender();
NotificationService smsService = new NotificationService(smsSender);
smsService.sendNotification("Your SMS message!");
}
}

Beneficios del Principio de Inversión de Dependencias

  • Reduce el acoplamiento entre módulos de alto y bajo nivel.
  • Facilita la incorporación de nuevas implementaciones (por ejemplo, WhatsAppSender) sin modificar el código existente.
  • Fomenta la inyección de dependencias, lo que simplifica las pruebas y mejora la flexibilidad.

Al depender de abstracciones en lugar de implementaciones concretas, se puede crear un sistema modular y escalable.

Conclusión

Este artículo exploró los cinco principios SOLID para escribir código limpio y mantenible. Al seguir estos principios, los proyectos se vuelven más fáciles de compartir con los miembros del equipo, ampliar con nuevas funcionalidades, modificar, probar y refactorizar, todo ello minimizando posibles dificultades.

¿Estás buscando los mejores servicios de hosting VPS? En BlueVPS ofrecemos soluciones de alojamiento potentes, escalables y personalizables, con entorno dedicado y tráfico ilimitado. ¡Empieza hoy y lleva tu hosting al siguiente nivel!

Blog