Principios de Diseño SOLID con Ejemplos Prácticos en C#

En este post me gustaría guardar información sobre los principios de diseño SOLID.
Pero a fin de cuentas, ¿qué es SOLID?
Bueno, básicamente es un acrónimo que representa un conjunto de cinco principios de diseño para la programación orientada a objetos, presentados por Robert C. Martin (“Uncle Bob”).
![]()
By Tim-bezhashvyly - Own work, CC BY-SA 4.0, Link
Estos principios, cuando se aplican juntos, tienen la intención de hacer que los diseños de software sean más comprensibles, flexibles y mantenibles. Vamos a desglosar cada uno con ejemplos prácticos en C#.
S - Single Responsibility Principle (Principio de Responsabilidad Única)
Una clase debe tener una, y solo una, razón para cambiar.
Este principio, aunque simple, es uno de los más violados. Básicamente, establece que una clase debe tener una única responsabilidad o propósito. Mezclar responsabilidades hace que la clase sea frágil y difícil de mantener.
Escenario: Imaginemos que estamos construyendo un sistema de facturación. Tenemos una clase Invoice que calcula el total y también se encarga de guardar la factura en la base de datos.
Violación del principio:
1 | public class Invoice |
El problema aquí es que la clase Invoice tiene dos razones para cambiar:
- Si cambia la lógica de cálculo de la factura (ej. agregar impuestos).
- Si cambia la forma en que se persiste (ej. cambiar de SQL Server a PostgreSQL, o guardar en un archivo).
Solución: Separamos las responsabilidades. La clase Invoice solo se preocupa por los datos y la lógica de negocio de la factura. Creamos una nueva clase, un Repository, cuya única responsabilidad es la persistencia.
1 | // 1. La clase Invoice solo se preocupa por la lógica de la factura. |
Ahora, cada clase tiene una sola razón para cambiar. Si necesitamos guardar la factura en un archivo, creamos un InvoiceFileSaver sin tocar Invoice o InvoiceRepository. Esto hace que el sistema sea mucho más robusto y fácil de extender.
O - Open/Closed Principle (Principio de Abierto/Cerrado)
Las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas para la extensión, pero cerradas para la modificación.
Esto significa que deberías poder agregar nueva funcionalidad sin cambiar el código existente que ya ha sido probado y está en producción. Esto se logra comúnmente a través de interfaces, clases abstractas y patrones de diseño como el Strategy o Specification.
Escenario: Tenemos un sistema de comercio electrónico y necesitamos calcular el costo de envío para un pedido. Inicialmente, solo tenemos un método de envío estándar.
Violación del principio:
1 | public class Order { /* ... */ } |
Cada vez que el negocio introduce un nuevo método de envío, tenemos que modificar la clase ShippingCalculator, lo que aumenta el riesgo de introducir errores en la lógica existente.
Solución: Usamos el patrón Strategy. Definimos una interfaz para la estrategia de cálculo y creamos una clase concreta para cada método de envío.
1 | public class Order { /* ... */ } |
Ahora, la clase ShippingCalculator está cerrada para modificación (no la hemos tocado), pero el sistema está abierto para extensión (podemos agregar infinitas estrategias de envío creando nuevas clases que implementen IShippingStrategy).
L - Liskov Substitution Principle (Principio de Sustitución de Liskov)
Los objetos de una superclase deben poder ser reemplazados por objetos de una subclase sin afectar la corrección del programa.
En términos más simples, si tienes una clase Pajaro, y una subclase Pinguino que hereda de Pajaro, tu programa no debería romperse si le pasas un Pinguino a una función que espera un Pajaro. Esto a menudo se rompe cuando una subclase altera fundamentalmente el comportamiento de un método heredado.
Escenario: Tenemos una clase base Empleado y una subclase Manager. Un Manager es un Empleado, pero tiene una responsabilidad adicional: aprobar gastos.
Violación del principio:
1 | public class Empleado |
El problema es que la clase CEO no puede ser sustituida por Empleado porque cambia el contrato del método CalcularSalario (lanza una excepción en lugar de devolver un decimal).
Solución: Reestructurar la jerarquía para que sea más coherente. Quizás no todos los que trabajan en la empresa son “empleados” en el sentido de tener un salario.
1 | // 1. Una interfaz común para todos los que reciben pago. |
Ahora, cualquier objeto que implemente IPagable puede ser sustituido sin problemas, cumpliendo con el principio de Liskov.
I - Interface Segregation Principle (Principio de Segregación de Interfaces)
Ningún cliente debe ser forzado a depender de métodos que no utiliza.
Este principio aboga por crear interfaces pequeñas y específicas en lugar de interfaces grandes y monolíticas. Si una clase implementa una interfaz con métodos que no necesita, se ve forzada a proporcionar implementaciones vacías o que lanzan excepciones, lo cual es una señal de un mal diseño.
Escenario: Tenemos un sistema de gestión de documentos con una interfaz IMachine para una máquina multifuncional.
Violación del principio:
1 | // Interfaz monolítica |
El problema es que OldFashionedPrinter es forzada a depender de los métodos Scan y Fax, que no utiliza.
Solución: Segregar la interfaz grande en interfaces más pequeñas y específicas por rol.
1 | // 1. Interfaces pequeñas y específicas. |
Ahora, las clases solo dependen de las funcionalidades que realmente proveen. El código es más limpio, más claro y más fácil de mantener.
D - Dependency Inversion Principle (Principio de Inversión de Dependencias)
- Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones (ej. interfaces).
- Las abstracciones no deben depender de los detalles. Los detalles (clases concretas) deben depender de las abstracciones.
Este principio es clave para crear sistemas desacoplados. En lugar de que la lógica de negocio (alto nivel) dependa directamente de la capa de acceso a datos (bajo nivel), ambas dependen de una interfaz definida por la capa de alto nivel.
Escenario: Tenemos un servicio de notificaciones (NotificationService) que envía un correo electrónico cuando ocurre un evento.
Violación del principio:
1 | // Módulo de bajo nivel |
El problema es que NotificationService está fuertemente acoplado a EmailSender. ¿Qué pasa si mañana queremos enviar notificaciones por SMS o Slack? Tendríamos que modificar NotificationService.
Solución: Invertir la dependencia. NotificationService define una interfaz que necesita, y la implementación concreta se le “inyecta” desde el exterior (Inyección de Dependencias).
1 | // 1. La capa de alto nivel define la abstracción que necesita. |
Ahora, NotificationService no sabe nada sobre EmailSender o SmsSender. Solo conoce la interfaz IMessageSender. Podemos agregar nuevos métodos de notificación sin tocar NotificationService, logrando un sistema flexible y desacoplado.