Comprendiendo los principios SOLID a través de ejemplos prácticos de código
Esta guía explica en detalle los cinco principios SOLID con ejemplos de código concretos, mostrando cómo se aplican en proyectos reales y aplicaciones React.
Cuando se desarrolla software, hacer que el código funcione correctamente es solo la mitad del trabajo.
Una vez que una aplicación comienza a crecer, su base de código suele volverse más difícil de leer, de modificar y de mantener en funcionamiento adecuadamente. Un pequeño cambio en una parte del sistema puede dañar silenciosamente algo sin relación.
Este es exactamente el tipo de problema para el cual se diseñaron los principios SOLID.
SOLID es un conjunto de cinco principios de diseño provenientes de la programación orientada a objetos que buscan lograr un código que sea:
- Más sencillo de mantener
- Más sencillo de probar
- Más sencillo de extender
- Más desacoplado
- Más fácil de comprender para el equipo
El acrónimo se desglosa de la siguiente manera:
S — Principio de Responsabilidad Única
O — Principio de Apertura/Cierre
L — Principio de Sustitución de Liskov
I — Principio de Segregación de Interfaces
D — Principio de Inversión de Dependencias
Analicemos cada uno de ellos con ejemplos sencillos.
1. S — Principio de Responsabilidad Única
“Una clase debe tener solo una razón para cambiar.”
En términos sencillos, cada clase o módulo debe estar diseñado para realizar una sola tarea.
Considere una clase User que es responsable de:
- Almacenar datos del usuario
- Comunicarse con la base de datos
- Enviar correos electrónicos
- Generar informes
Eso es demasiado para una sola clase.
class User {
createUser() {
// create user
}
saveToDatabase() {
// save user
}
sendEmail() {
// send email
}
generateReport() {
// generate report
}
}
Si la lógica de envío de correos necesita cambios, se debe editar la clase User.
Si cambian las operaciones con la base de datos, nuevamente se modifica esa misma clase.
Un enfoque más limpio consiste en separar estas tareas.
class User {
createUser() {
// create user
}
}
class UserRepository {
saveToDatabase() {
// database logic
}
}
class EmailService {
sendEmail() {
// email logic
}
}
class ReportService {
generateReport() {
// report logic
}
}
Con esta división, cada clase ahora se ocupa exactamente de una sola responsabilidad.
¿Por qué es útil?
Cada vez que cambia un requisito, sabes de inmediato qué parte del código modificar.
Una tarea por clase significa una sola razón para que esa clase cambie.
2. O — Principio de Apertura/Cierre
“Las entidades de software deben estar abiertas para la extensión, pero cerradas para la modificación.”
La redacción suena abstracta, pero el concepto detrás de ella no lo es.
El objetivo es introducir nuevas funcionalidades sin tener que reescribir repetidamente el código que ya funciona.
Tomemos como ejemplo el procesamiento de pagos:
function processPayment(type, amount) {
if (type === "card") {
// card payment
} else if (type === "upi") {
// UPI payment
} else if (type === "paypal") {
// PayPal payment
}
}
Ahora supongamos que necesitas soportar:
- Stripe
- Razorpay
- Apple Pay
- Google Pay
Cada nueva opción hace que esa función sea más grande y desordenada.
Una estrategia mejor es asignar a cada método de pago su propia clase.
class CardPayment {
pay(amount) {
console.log(`Card payment: ${amount}`);
}
}
class UpiPayment {
pay(amount) {
console.log(`UPI payment: ${amount}`);
}
}
class PaypalPayment {
pay(amount) {
console.log(`PayPal payment: ${amount}`);
}
}
Con esa estructura en lugar, agregar otra opción de pago no requiere modificar las clases que ya se han escrito.
class StripePayment {
pay(amount) {
console.log(`Stripe payment: ${amount}`);
}
}
Las implementaciones originales permanecen exactamente como estaban.
La idea
Ampliar el sistema añadiendo nuevo código, no editando repetidamente código que ya es estable.
3. L — Principio de Sustitución de Liskov
“Los subtipos deben ser reemplazables por sus tipos base.”
En esencia, este principio establece:
Si
Bes un subtipo deA, debe ser posible reemplazarBdondequiera que se utiliceA, y la aplicación debe seguir funcionando correctamente.
Una ilustración clásica involucra a las aves.
Supongamos que definimos:
class Bird {
fly() {
console.log("Flying");
}
}
Luego lo extiendes:
class Sparrow extends Bird {
fly() {
console.log("Sparrow is flying");
}
}
Hasta ahora, todo bien.
Pero, ¿qué pasa con un pingüino?
class Penguin extends Bird {
fly() {
throw new Error("Penguins cannot fly");
}
}
Esto revela una falla en el diseño.
Si otras partes del código asumen que todo Bird puede volar, pasarle una instancia de Penguin romperá esa expectativa.
Un diseño más sensato separa el comportamiento de vuelo en una parte distinta.
class Bird {
eat() {
console.log("Eating");
}
}
class FlyingBird extends Bird {
fly() {
console.log("Flying");
}
}
class Sparrow extends FlyingBird {}
class Penguin extends Bird {}
De esta manera, los pingüinos ya no se ven obligados a soportar comportamientos que no les aplican.
La lección
Evita crear jerarquías de herencia que no tengan sentido lógico.
Una subclase debe funcionar correctamente en cualquier lugar donde se espere que funcione su clase padre.
4. I — Principio de segregación de interfaces
“Los clientes no deben verse obligados a depender de métodos que no utilizan.”
Imagina una interfaz construida de esta manera:
print()
scan()
fax()
copy()
Imagínese ahora una impresora básica que solo puede, bueno, imprimir.
¿Por qué debería esa impresora también implementar scan(), fax() y copy()?
No hay una buena razón para ello.
El enfoque mejor es dividir la interfaz según lo que hace realmente cada funcionalidad.
Por ejemplo:
class Printer {
print() {
console.log("Printing...");
}
}
class Scanner {
scan() {
console.log("Scanning...");
}
}
class FaxMachine {
fax() {
console.log("Faxing...");
}
}
Una impresora simple solo necesita implementar el comportamiento que realmente soporta.
En JavaScript moderno
JavaScript no tiene interfaces formales como Java o C#, pero la idea subyacente sigue vigente.
Puede ponerla en práctica a través de:
- Módulos pequeños
- APIs pequeñas
- Composición
- Servicios separados
- Componentes React enfocados
En lugar de agrupar todo en un único servicio gigantesco:
userService.getUser();
userService.createUser();
userService.deleteUser();
userService.sendEmail();
userService.generateReport();
Separar las responsabilidades:
userService.getUser();
userService.createUser();
emailService.sendEmail();
reportService.generateReport();
La lección
Nunca fuerce a un componente, clase o módulo a depender de funcionalidades que no utiliza.
5. D — Principio de Inversión de Dependencias
“Los módulos de alto nivel no deben depender directamente de los módulos de bajo nivel. Ambos deben depender de abstracciones.”
Este principio existe para reducir el acoplamiento estricto.
Tome este ejemplo:
class MongoDB {
save(data) {
console.log("Saving to MongoDB");
}
}
class UserService {
constructor() {
this.database = new MongoDB();
}
saveUser(user) {
this.database.save(user);
}
}
El problema aquí es que UserService está conectado directamente a MongoDB.
Cambiarlo posteriormente a PostgreSQL significaría tener que modificar UserService mismo.
Un enfoque mejor es inyectar la dependencia en su lugar.
class UserService {
constructor(database) {
this.database = database;
}
saveUser(user) {
this.database.save(user);
}
}
Ahora se pueden pasar libremente diferentes implementaciones de base de datos.
const mongoDB = new MongoDB();
const userService = new UserService(mongoDB);
Y más adelante, reemplazarla es sencillo:
const postgresDB = new PostgreSQL();
const userService = new UserService(postgresDB);
UserService nunca necesita saber qué base de datos está detrás de él.
¿Por qué es útil esto?
Esto permite tener un código que sea:
- Más fácil de probar
- Más fácil de reemplazar
- Más fácil de mantener con el tiempo
SOLID en un proyecto real
Seguir los principios SOLID no significa crear una clase dedicada para cada cosa.
Esa distinción es muy importante.
SOLID se refiere a tomar decisiones de diseño sólidas, no a acumular abstracciones solo por su cuenta.
En una aplicación React, por ejemplo, estas ideas surgen naturalmente cuando se separan:
Components
↓
Hooks
↓
Services
↓
API Layer
↓
Database
La función principal de un componente es manejar la interfaz de usuario.
Un hook personalizado puede encargarse de la lógica de estado reutilizable.
Un servicio API puede gestionar la comunicación HTTP.
El backend se ocupa de la lógica de negocio.
La capa de base de datos se encarga de la persistencia.
Dividir las cosas de esta manera mantiene la aplicación manejable a medida que crece.
SOLID y React
Aunque SOLID surgió del diseño orientado a objetos, varias de sus ideas se aplican bien en el trabajo con React.
Responsabilidad única
En lugar de crear un componente masivo:
Dashboard.jsx
que intenta hacerlo todo, divídelo en:
Dashboard
UserProfile
Statistics
RecentOrders
Notifications
Cada parte entonces tiene una función mucho más clara.
Abierto/Cerrado
Diseña componentes reutilizables que adquieran nuevos comportamientos a través de props, en lugar de tener que reescribir su lógica interna cada vez que se necesita una nueva variante.
<Button variant="primary">
Save
</Button>
<Button variant="danger">
Delete
</Button>
Inversión de dependencias
En lugar de vincular un componente directamente a una forma específica de obtener datos, guarda la lógica de la API dentro de un servicio o hook.
const users = await userService.getUsers();
El propio componente no necesita saber cómo se lleva a cabo esa solicitud en el fondo.
Por qué es importante SOLID
Los beneficios reales de SOLID tienen poco que ver con hacer que el código se vea elegante.
Se trata de hacer que los cambios futuros sean menos problemáticos.
Imagínese un proyecto que involucre:
10 desarrolladores → 100 funcionalidades → miles de archivos → cambios constantes
Sin un diseño deliberado, un pequeño requisito puede desencadenar una cascada de problemas no relacionados.
Con una separación clara y un acoplamiento laxo, los cambios resultan mucho más predecibles.
SOLID puede ayudarlo a:
- Reducir la duplicación de código
- Disminuir el acoplamiento estricto
- Mejorar la posibilidad de pruebas
- Hacer que las funcionalidades sean más fáciles de extender
- Simplificar la depuración
- Mejorar la colaboración dentro del equipo
- Mantener las aplicaciones grandes mantenibles
SOLID no significa sobreingeniería
Este podría ser el punto más importante que se debe recordar.
No aplique SOLID como una lista de verificación rígida.
Tome una función simple como esta:
function add(a, b) {
return a + b;
}
No necesita cinco clases, tres interfaces ni un contenedor de inyección de dependencias para funcionar.
Nunca fue el objetivo hacer el código más elaborado.
El objetivo es hacer que el código verdaderamente complejo sea más fácil de trabajar.
Utilice SOLID solo cuando la complejidad del sistema justifique realmente esa estructura adicional.
Resumen rápido
S — Responsabilidad única: una clase o módulo debe tener una responsabilidad principal.
O — Abierto/Cerrado: extienda el comportamiento en lugar de editar repetidamente código que ya funciona.
L — Sustitución de Liskov: las subclases deben funcionar correctamente en cualquier lugar donde se espere la clase padre.
I — Segregación de interfaces: no haga que los clientes dependan de funcionalidades que no necesitan.
D — Inversión de dependencias: dependa de abstracciones en lugar de implementaciones concretas fijas.
Pensamientos finales
SOLID nunca fue concebido como cinco definiciones que memorizar para una entrevista.
Es una forma de razonar sobre el diseño de software.
Mientras escribe código, ayuda detenerse y preguntarse:
¿Este módulo está intentando hacer demasiadas cosas a la vez?
¿Añadir una nueva función obligará a reescribir el código existente?
¿Se están introduciendo aquí dependencias innecesarias?
¿Se puede probar este código sin dificultades?
¿Se está forzando a algo a soportar un comportamiento que en realidad no necesita?
Abordar estas preguntas suele ser más importante que saber recitar qué significa cada letra de SOLID.
Un buen software no es solo aquel que funciona en este momento.
Un buen software es aquel que sigue cambiando de manera elegante en lugar de convertirse en una pesadilla.
Lecturas relacionadas
- Cuando la IA escribe tu app React pero salta los principios de código limpio — Aprende siete hábitos de código limpio—DRY, responsabilidad única, cláusulas de protección y más—que el código React generado por IA suele violar y cómo corregirlos.