SOLID: qué principios se aplican en proyectos reales
SOLID se enseña como cinco reglas y se aplica como cinco excusas para crear interfaces. Qué principios aportan valor a diario y cuáles se malinterpretan.
En una entrevista técnica, casi todo desarrollador puede recitar SOLID. En el código, el resultado suele ser una clase OrderService con su IOrderService, que tiene una sola implementación y nunca tendrá otra, y una justificación: "por el principio de inversión de dependencias".
SOLID no es el problema. El problema es aplicarlo como una lista de reglas en lugar de como lo que es: un conjunto de heurísticas para que el código sea fácil de cambiar. Cada principio responde a un tipo de cambio concreto. Si ese cambio no ocurre en tu sistema, el principio no está aportando nada.
S: responsabilidad única (el que más se usa, y el más malinterpretado)
La definición popular es "una clase debe hacer una sola cosa". Llevada al extremo, produce clases de cinco líneas y un sistema donde seguir un caso de uso exige abrir veinte archivos.
La formulación original de Robert C. Martin es más útil: una clase debe tener una sola razón para cambiar, o sea, responder a un solo actor del negocio.
// ❌ Tres actores distintos cambian esta clase por motivos distintos
public class InvoiceService
{
public decimal CalculateTotal(Invoice invoice) { ... } // contabilidad
public byte[] RenderPdf(Invoice invoice) { ... } // diseño / marketing
public Task SaveAsync(Invoice invoice) { ... } // infraestructura
}
Cuando contabilidad cambia la regla de impuestos, no debería existir el riesgo de romper el PDF. Separar esas tres responsabilidades sí aporta valor. Separar CalculateSubtotal y CalculateTaxes en dos clases distintas, probablemente no.
En la práctica: es el principio que más evita problemas. La pregunta útil no es "¿hace una sola cosa?", sino "¿quién me va a pedir que cambie esto?".
O: abierto/cerrado (útil donde hay variación real)
"Abierto a extensión, cerrado a modificación." Tiene mucho sentido donde ya sabes que van a aparecer variantes:
public interface IShippingCalculator
{
bool AppliesTo(Order order);
decimal Calculate(Order order);
}
// Un transportador nuevo es una clase nueva, sin tocar las existentes
public class LocalCourierCalculator : IShippingCalculator { ... }
public class NationalCarrierCalculator : IShippingCalculator { ... }
Donde no hay variación, aplicarlo "por si acaso" crea puntos de extensión que nadie usa. Un switch sobre tres casos estables es más fácil de leer que tres clases y una fábrica.
En la práctica: se aplica cuando aparece la segunda o tercera variante, no antes.
L: sustitución de Liskov (el que más se ignora, y causa errores reales)
Si B hereda de A, cualquier código que funciona con A debe funcionar con B sin sorpresas. Las violaciones son errores reales:
public class ReadOnlyRepository : Repository
{
// ❌ Quien recibe un Repository no espera que Add explote
public override void Add(Entity e) => throw new NotSupportedException();
}
En .NET hay un ejemplo famoso en el propio framework: los arreglos implementan IList<T>, pero Add lanza una excepción porque un arreglo tiene tamaño fijo.
En la práctica: se aplica poco porque la herencia se usa poco, y eso está bien. Cuando una subclase necesita lanzar NotSupportedException o ignorar un método del padre, probablemente la herencia era la herramienta equivocada.
I: segregación de interfaces (útil en bibliotecas y en límites)
"Ningún cliente debe depender de métodos que no usa." Es muy valioso en interfaces públicas que implementan otros:
// ✅ Quien solo lee no tiene que implementar la escritura
public interface IFileReader { Task<Stream> OpenAsync(string path, CancellationToken ct); }
public interface IFileWriter { Task SaveAsync(string path, Stream content, CancellationToken ct); }
Dentro de una aplicación, con interfaces que tienen una sola implementación, dividirlas rara vez cambia algo.
En la práctica: importante en contratos con terceros y bibliotecas; secundario en el código interno.
D: inversión de dependencias (no significa "una interfaz por clase")
El principio dice que los módulos de alto nivel no deben depender de los detalles. No dice que cada clase necesite una interfaz.
// ✅ Aquí sí: el negocio no debe saber qué pasarela de pagos se usa
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(Money amount, string token, CancellationToken ct);
}
La pasarela de pagos es un detalle que puede cambiar (otro proveedor, un modo de pruebas) y que necesitas simular en pruebas. Eso justifica la interfaz. IOrderService con una sola implementación, que solo usa el controlador, no protege al negocio de ningún detalle: es una indirección más.
En la práctica: interfaces en los límites del sistema (servicios externos, almacenamiento, reloj, correo), no en cada clase.
Lo que pasa cuando se aplican todos, siempre
- Un caso de uso repartido en 15 archivos pequeños.
- Interfaces con una sola implementación por todas partes.
- Navegar el código con "Ir a la implementación" en lugar de "Ir a la definición".
- Pruebas que simulan todo y no verifican nada real.
El código queda "bien diseñado" según la lista y difícil de cambiar en la práctica, que es justo lo contrario de lo que SOLID busca.
El trade-off
Cada principio aplicado agrega una abstracción. Una abstracción paga su costo cuando el cambio que anticipa ocurre. Si no ocurre, queda como peso muerto que hay que leer, navegar y mantener.
Cuándo no aplicarlos
- En código que todavía está buscando su forma: primero que funcione y se entienda, después que se pueda extender.
- En scripts, herramientas internas y prototipos.
- Cuando la única justificación es "lo dice SOLID". Si no puedes nombrar el cambio concreto que el diseño facilita, probablemente no hace falta.
Conclusión
De los cinco, la responsabilidad única y la inversión de dependencias en los límites son los que más valor aportan en el día a día. Abierto/cerrado sirve cuando aparece la variación real. Liskov e interfaces segregadas importan sobre todo en jerarquías y en bibliotecas. Más que cumplir SOLID, el objetivo es tener código que se pueda cambiar sin miedo. Es la misma pregunta que nos hicimos con Clean Architecture.
¿Tu sistema necesita una revisión de arquitectura?
Te ayudamos a decidir qué estructura necesita tu software hoy y cómo prepararlo para crecer, sin sobreingeniería.
Ver desarrollo a medida