SOLID me ha convertido en el desarrollador que soy hoy. Entenderlo en profundidad ha mejorado enormemente mi código. Todo gracias a Robert C. MartinSe abre en una nueva pestaña, quien acuñó el acrónimo SOLIDSe abre en una nueva pestaña en los 2000. Llevo aplicando este concepto desde 2015. Déjame mostrarte cómo.
#¿Qué es SOLID?
SOLID es un acrónimo que representa cinco conceptos aplicados al Diseño de Software:
- Single Responsibility Principle (SRP): Una clase debería tener un único motivo para cambiar.
- Open/Closed Principle (OCP): Las entidades de software deben estar abiertas a la extensión pero cerradas a la modificación.
- Liskov Substitution Principle (LSP): Los subtipos deben poder sustituir a sus tipos base sin alterar la corrección del programa.
- Interface Segregation Principle (ISP): Los clientes no deben verse obligados a depender de interfaces que no usan.
- Dependency Inversion Principle (DIP): Los módulos de alto nivel no deben depender de los de bajo nivel. Ambos deben depender de abstracciones.
Estas definiciones están tomadas literalmente del texto original de Robert. Sin embargo, para mí, eran demasiado abstractas y difíciles de asimilar al principio. Así que intentemos simplificarlas.
#Single Responsibility Principle
“”Una clase debería tener un único motivo para cambiar.
Imagínate siendo un Arquitecto Frontend. Tu tarea principal es convertir requisitos en código funcional. No solo código que funcione hoy, sino que funcione mañana.
Ahora imagina que además tienes que encargarte de marketing.
Y de soporte al cliente.
Y de gestionar problemas de facturación.
Sí, quizás estoy describiendo a un freelancer...
Nada de esto está relacionado con la Arquitectura Frontend, y lo que acaba ocurriendo es que cualquier cambio en esas áreas afecta directamente a la Arquitectura Frontend.
Puede distraerte e incluso frenar el progreso en la tarea que se te encomendó.
Queremos construir sistemas de tal forma que un cambio en ciertas áreas no afecte a otras.
El código se comporta igual.
class UserService { createUser(name: string, email: string) { // validation if (!email.includes('@')) { throw new Error('Invalid email'); } // save to database console.log('Saving user to DB'); // send email console.log(`Sending welcome email to ${email}`); return { name, email }; } }
Este código tiene 3 motivos diferentes para cambiar:
- Cambia la validación del email
- Cambia la lógica de guardado en base de datos
- Cambia la lógica del email
Si necesitas describir lo que hace tu clase/función/módulo/sistema usando múltiples "y además", ¡está haciendo demasiadas cosas!
Apliquemos el principio de Responsabilidad Única.
class UserValidator { validateEmail(email: string) { if (!email.includes('@')) { throw new Error('Invalid email'); } } } class UserRepository { save(name: string, email: string) { console.log('Saving user to DB'); return { name, email }; } } class EmailService { sendWelcome(email: string) { console.log(`Sending welcome email to ${email}`); } } class UserService { constructor( private validator: UserValidator, private repository: UserRepository, private emailService: EmailService ) {} createUser(name: string, email: string) { this.validator.validateEmail(email); const user = this.repository.save(name, email); this.emailService.sendWelcome(email); return user; } }
Aunque es más código, merece completamente la pena.
#Open Closed Principle
“”Las entidades de software deben estar abiertas a la extensión pero cerradas a la modificación.
Este principio nos dice que deberíamos poder añadir nuevas funcionalidades sin tocar el código existente.
En un mundo ideal, si tenemos una funcionalidad completamente nueva que requiere crear código completamente nuevo, y tenemos una arquitectura sólida, solo necesitaríamos añadir código nuevo, no modificar el anterior.
Tomemos, por ejemplo, el siguiente código:
type PaymentMethod = 'card' | 'cash'; function processPayment(method: PaymentMethod, amount: number) { if (method === 'card') { console.log(`Processing card payment of ${amount}`); } else if (method === 'cash') { console.log(`Processing cash payment of ${amount}`); } }
Cada vez que necesitamos añadir un nuevo sistema de pago, tenemos que modificar el código existente.
Sin embargo, con algunos cambios y la ayuda del polimorfismo (que es una forma elegante de decir "tratar cosas diferentes de la misma manera"):
interface PaymentProcessor { process(amount: number): void; } class CardPayment implements PaymentProcessor { process(amount: number) { console.log(`Processing card payment of ${amount}`); } } class CashPayment implements PaymentProcessor { process(amount: number) { console.log(`Processing cash payment of ${amount}`); } } function processPayment(processor: PaymentProcessor, amount: number) { processor.process(amount); }
Ahora cuando añadimos un nuevo pago, es solo cuestión de añadir código nuevo:
class CryptoPayment implements PaymentProcessor { process(amount: number) { console.log(`Processing crypto payment of ${amount}`); } }
Este principio se aplica a más conceptos, como módulos o sistemas completos. Incluso frameworks.
En las Rutas de NextJSSe abre en una nueva pestaña, cuando añadimos una nueva ruta en la aplicación, sigue este principio, ya que carga la carpeta automágicamente y crea una nueva ruta. No necesitas registrar la ruta en ningún sitio, simplemente la añades.


#Liskov Substitution
“”Los subtipos deben poder sustituir a sus tipos base sin alterar la corrección del programa.
Este principio se encuentra con menos frecuencia y hace referencia a supertipos y subtipos, lo cual tiene que ver con la herencia.
Cuando tenemos una clase en la que hemos definido un método base, y en una subclase lo sobreescribimos, el programa debería funcionar correctamente incluso si los intercambiamos.
class Bird { fly() { console.log('Flying'); } } class Penguin extends Bird { fly() { throw new Error('Penguins cannot fly'); } } function makeBirdFly(bird: Bird) { bird.fly(); } makeBirdFly(new Penguin()); // Rompe la expectativa
Uso correcto:
interface Bird {} interface FlyingBird extends Bird { fly(): void; } class Sparrow implements FlyingBird { fly() { console.log('Flying'); } } class Penguin implements Bird { swim() { console.log('Swimming'); } } function makeBirdFly(bird: FlyingBird) { bird.fly(); } makeBirdFly(new Sparrow());
Este principio también cubre casos más sutiles.
Si un subtipo lanza un error en el método heredado, también lo rompería. Por ejemplo, si hacemos algo como esto:
class Bird { fly() { console.log('Flying'); } } class Penguin extends Bird { fly() { throw new Error('Cannot fly'); } } function makeBirdFly(bird: Bird) { bird.fly(); } makeBirdFly(new Penguin()); // 💥 runtime error
O incluso si cambiamos el tipo de retorno, podría considerarse que no estamos siguiendo este principio:
class Bird { speak(): string { return 'chirp'; } } class Penguin extends Bird { speak(): any { return { sound: 'honk' }; // ❌ different type } } function makeBirdSpeak(bird: Bird) { const sound = bird.speak(); console.log(sound.toUpperCase()); // expects string } makeBirdSpeak(new Penguin()); // 💥 runtime error
Pero, ¿por qué debería importarnos esto?
Porque nos protege de sorpresas al intercambiar implementaciones. Cuando programamos, deberíamos seguir la Ley de la Mínima SorpresaSe abre en una nueva pestaña. A los desarrolladores, en general, no nos gustan las sorpresas.
#Interface Segregation Principle
“”Los clientes no deben verse obligados a depender de interfaces que no usan.
Para mí, este principio resulta redundante con el de Responsabilidad Única. De todas formas, veámoslo en detalle.
Una interfaz que puede ser útil crear en tus proyectos para abstraer la forma en que nos conectamos con repositorios es la siguiente:
export interface CrudRepository<Entity extends { id: Id }, CreateEntity> { findAll(): Promise<Entity[]>; findById(id: Id): Promise<Entity | null>; create(data: CreateEntity): Promise<void>; update(entity: Entity): Promise<void>; delete(id: Id): Promise<void>; }
De esta forma siempre podríamos interactuar con los datos (ya sea una API, base de datos o cualquier otro tipo) a través de los mismos métodos.
¡Olvídate de dudar si tu método de creación debería llamarse create o save!
Sin embargo, es posible que nos encontremos pronto con un problema.
Digamos que encontramos un repositorio que tiene todas las operaciones CRUD excepto delete. Ugh. ¿Y ahora qué?
¿Implementamos un método vacío?
¿Lanzamos una excepción?
¡No parece haber una solución elegante!
Pues bien, el problema está en la raíz. Añadimos demasiados métodos a la interfaz. Vamos a dividirla.
interface FindableAll<Entity> { findAll(): Promise<Entity[]>; } interface FindableById<Entity extends { id: Id }> { findById(id: Id): Promise<Entity | null>; } interface Creatable<CreateEntity> { create(data: CreateEntity): Promise<void>; } interface Updatable<Entity extends { id: Id }> { update(entity: Entity): Promise<void>; } interface Deletable { delete(id: Id): Promise<void>; }
Ahora podemos seguir teniendo el CrudRepository, pero podemos construirlo de forma diferente.
export interface CrudRepository<Entity extends { id: Id }, CreateEntity> extends FindableAll<Entity>, FindableById<Entity>, Creatable<CreateEntity>, Updatable<Entity>, Deletable {}
No solo es más semántico, sino que ahora, cuando tenemos un repositorio que no necesita todos los métodos, ¡simplemente no los incluimos!
export interface UserRepository extends FindableAll<User>, FindableById<User>, Creatable<CreateUser> {}
#Dependency Inversion Principle
“”Los módulos de alto nivel no deben depender de los de bajo nivel. Ambos deben depender de abstracciones.
El principio de inversión de dependencias, además de hacer los sistemas más flexibles, es por sí solo el principio que hace que las pruebas sean más sencillas.
En cualquier código, hacer pruebas es realmente fácil, el problema es que a veces nuestro código es tan rígido que hace que las pruebas sean increíblemente difíciles.
class DieselEngine { getPower(): number { return Math.random() * 100; } } class Car { private engine = new DieselEngine(); getSpeed(): number { return this.engine.getPower() * 2; } }
¿Cómo probarías el código anterior?
El SUTSe abre en una nueva pestaña (Subject Under Test) es que necesitamos verificar que se ha llamado a this.engine.getPower() y que el resultado se multiplica por 2.
¡Incluso tenemos un Math.random()! El problema con este código es que las dependencias están fijadas; las usamos directamente, lo que hace que sean muy difíciles de sustituir.
Si seguimos este tipo de código, cuando hagamos pruebas, vamos a necesitar mockear imports. Eso es tan frágil como puede llegar a ser con las pruebas.
No, hay una mejor manera.
class DieselEngine { getPower(): number { return Math.random() * 100; } } class Car { constructor(private dieselEngine: DieselEngine) {} getSpeed(): number { return this.dieselEngine.getPower() * 2; } }
Ahora, si queremos probar el Car, ¡simplemente le pasamos un FakeEngine!
class FakeEngine implements Engine { getPower(): number { return 50; // deterministic } } const car = new Car(new FakeEngine()); console.log(car.getSpeed()); // 100 ✅ always the same
¿Y qué hay de Math.random()? ¡Eso también es una dependencia! También podemos abstraerla.
interface RandomGenerator { next(): number; } class DefaultRandom implements RandomGenerator { next(): number { return Math.random(); } } class DieselEngine { constructor(private random: RandomGenerator) {} getPower(): number { return this.random.next() * 100; } } class Car { constructor(private engine: { getPower(): number }) {} getSpeed(): number { return this.engine.getPower() * 2; } }
Este principio también se aplica a las funciones:
type RandomFn = () => number; function calculateDiscount(price: number, random: RandomFn): number { return price * random(); }
Prefiero las clases, así puedo decir que tengo más clase.
En serio, es porque me permiten agrupar distintas funciones, construirlas reutilizando los parámetros del constructor y definir visibilidad pública/privada.
Objetivamente, para proyectos grandes, las clases aportan más valor. Eso no significa que no tenga alguna que otra función de utilidad aquí y allá, las tengo, pero la mayor parte de mi código son clases.
Lo genial de los principios es que se aplican a múltiples lenguajes de programación, paradigmas y también con IA.
#Conclusión
Como has visto, los principios en esencia son bastante fáciles de recorrer, pero difíciles de interiorizar.
- Single Responsibility: Mantén las cosas enfocadas para que los cambios queden aislados.
- Open/Closed: Añade nuevo comportamiento sin romper el código existente.
- Liskov Substitution: Haz que las implementaciones sean intercambiables sin sorpresas.
- Interface Segregation: Depende solo de lo que realmente usas.
- Dependency Inversion: Apóyate en abstracciones para mantener el código flexible y testeable.
Como en la mayoría de las cosas en la vida, todo es cuestión de equilibrio, haciendo que nuestro código sea agradable de trabajar con él ahora y en el futuro.
¡Ahora es el momento de votar el próximo número de la newsletter!
P.D: Recientemente di una charla en Codemotion Madrid sobre cómo construí esta newsletter. Aunque la grabación aún no está disponible, puedes ver las slides aquíSe abre en una nueva pestaña. Creé un clon de Gmail. Bastante chulo.