Todos los artículos
javascript arquitectura solid buenas-practicas

Principios SOLID en JavaScript: qué son, para qué sirven y cuándo aplicarlos

Recorremos los cinco principios SOLID con ejemplos en JavaScript y TypeScript, mostrando cuándo mejoran tu código y cuándo pueden ser overkill.

beresiartejuan

@beresiartejuan

Autor

26 de noviembre de 2024 Actualizado: 4 de marzo de 2025 21 min de lectura

Si alguna vez trabajaste en un proyecto que arrancó simple y después se volvió imposible de modificar sin romper tres cosas, probablemente te hayan dicho “deberías aplicar SOLID”. Pero entre la definición formal y usarlo en el día a día hay bastante distancia.

Vamos a recorrer los cinco principios con ejemplos en JavaScript y TypeScript, desde la práctica. Mostrando los trade-offs, y siendo honestos sobre cuándo vale la pena aplicarlos y cuándo es overkill.

¿Qué es SOLID?

SOLID es un acrónimo acuñado por Robert C. Martin (Uncle Bob) que agrupa cinco principios de diseño de software:

  • S — Single Responsibility Principle (Principio de Responsabilidad Única)
  • O — Open/Closed Principle (Principio Abierto/Cerrado)
  • L — Liskov Substitution Principle (Principio de Sustitución de Liskov)
  • I — Interface Segregation Principle (Principio de Segregación de Interfaces)
  • D — Dependency Inversion Principle (Principio de Inversión de Dependencias)

No son reglas absolutas. Son heurísticas que te ayudan a tomar mejores decisiones de diseño. Aplicarlos dogmáticamente puede ser tan dañino como ignorarlos por completo. La clave está en entender por qué existen y usar ese criterio caso a caso.

S — Principio de Responsabilidad Única

Una clase (o módulo) debe tener una, y solo una, razón para cambiar.

Este es el principio más citado y también el más malinterpretado. No significa “una función debe hacer una sola cosa” (eso es más bien clean code general). Significa que un módulo debe ser responsable ante un solo actor o un solo aspecto del sistema.

El problema

class UserService {
  async createUser(data) {
    // Valida los datos
    if (!data.email.includes("@")) {
      throw new Error("Email inválido");
    }

    // Guarda en la base de datos
    const user = await db.users.insert(data);

    // Envía email de bienvenida
    await sendEmail(user.email, "Bienvenido", templateBienvenida(user));

    // Loguea la acción
    console.log(`Usuario creado: ${user.id}`);

    return user;
  }
}

Esta clase tiene cuatro razones para cambiar: si cambian las reglas de validación, si cambia la base de datos, si cambia el servicio de email, o si cambia cómo logueamos. Cuatro actores distintos pueden necesitar modificar este mismo archivo.

La solución

Separar responsabilidades en módulos independientes:

// validacion.js
export function validarUsuario(data) {
  const errores = [];
  if (!data.email?.includes("@")) errores.push("Email inválido");
  if (!data.nombre?.trim()) errores.push("Nombre requerido");
  return errores;
}

// user-repository.js
export class UserRepository {
  async crear(data) {
    return await db.users.insert(data);
  }
}

// notificaciones.js
export async function enviarBienvenida(user) {
  await sendEmail(user.email, "Bienvenido", templateBienvenida(user));
}

// user-service.js
export class UserService {
  constructor(repo, notificar, logger) {
    this.repo = repo;
    this.notificar = notificar;
    this.logger = logger;
  }

  async crearUsuario(data) {
    const errores = validarUsuario(data);
    if (errores.length) throw new Error(errores.join(", "));

    const user = await this.repo.crear(data);
    await this.notificar(user);
    this.logger.info(`Usuario creado: ${user.id}`);

    return user;
  }
}

Ahora cada módulo tiene una sola razón para cambiar. Si el equipo de infraestructura cambia la base de datos, solo toca user-repository.js. Si marketing cambia el template del email, solo toca notificaciones.js.

El trade-off

¿Es siempre mejor separar? No. Si tenés un script de 50 líneas que corre una vez por semana, separarlo en cinco archivos es agregar complejidad sin beneficio. SRP escala bien cuando el proyecto crece y cuando hay múltiples personas trabajando en el mismo código. Para un prototipo rápido, una función que hace todo puede estar perfectamente bien.

O — Principio Abierto/Cerrado

“Las entidades de software deben estar abiertas a la extensión pero cerradas a la modificación.”

Suena contradictorio, pero la idea es simple: cuando necesitás agregar funcionalidad nueva, deberías poder hacerlo sin tocar el código que ya existe y funciona.

El problema

function calcularDescuento(producto, tipoCliente) {
  if (tipoCliente === "regular") {
    return producto.precio * 0.05;
  } else if (tipoCliente === "premium") {
    return producto.precio * 0.15;
  } else if (tipoCliente === "vip") {
    return producto.precio * 0.25;
  }
  // Cada nuevo tipo de cliente = modificar esta función
  return 0;
}

Cada vez que aparece un nuevo tipo de cliente, hay que abrir esta función y agregar un else if. En un proyecto real, esto se convierte en un switch de 200 líneas donde nadie se anima a tocar nada por miedo a romper los casos existentes.

La solución

Usar un mecanismo de extensión — puede ser un mapa de estrategias, polimorfismo, o simplemente funciones:

// Estrategias de descuento como un mapa
const estrategiasDescuento = {
  regular: (precio) => precio * 0.05,
  premium: (precio) => precio * 0.15,
  vip: (precio) => precio * 0.25,
};

function calcularDescuento(producto, tipoCliente) {
  const estrategia = estrategiasDescuento[tipoCliente];
  if (!estrategia) return 0;
  return estrategia(producto.precio);
}

// Agregar un nuevo tipo NO modifica la función existente:
estrategiasDescuento.empleado = (precio) => precio * 0.30;

O con TypeScript y algo más de estructura:

interface EstrategiaDescuento {
  calcular(precio: number): number;
}

class DescuentoPremium implements EstrategiaDescuento {
  calcular(precio: number) {
    return precio * 0.15;
  }
}

class DescuentoVIP implements EstrategiaDescuento {
  calcular(precio: number) {
    return precio * 0.25;
  }
}

function calcularDescuento(
  producto: Producto,
  estrategia: EstrategiaDescuento
) {
  return estrategia.calcular(producto.precio);
}

Donde esto aparece en la vida real

En JavaScript, este principio lo ves todo el tiempo sin que se llame “OCP”:

  • Middleware de Express: app.use(cors()), app.use(auth()) — agregás funcionalidad sin modificar el framework.
  • Plugins de Vite/Rollup: extendés el build sin tocar el bundler.
  • Event emitters: emitter.on('user:created', fn) — cualquiera puede reaccionar sin modificar el emisor.

El patrón de “mapa de estrategias” que mostramos arriba es probablemente la implementación más práctica y común en JavaScript. No siempre necesitás clases con interfaces.

L — Principio de Sustitución de Liskov

“Si S es un subtipo de T, entonces los objetos de tipo T pueden ser reemplazados por objetos de tipo S sin alterar el comportamiento correcto del programa.”

En criollo: si una función espera recibir un tipo base, debería funcionar igual de bien con cualquier subtipo o implementación alternativa. El hijo no puede romper las promesas del padre.

El problema clásico

class Rectangulo {
  constructor(
    protected ancho: number,
    protected alto: number
  ) {}

  setAncho(ancho: number) {
    this.ancho = ancho;
  }

  setAlto(alto: number) {
    this.alto = alto;
  }

  area() {
    return this.ancho * this.alto;
  }
}

class Cuadrado extends Rectangulo {
  setAncho(ancho: number) {
    this.ancho = ancho;
    this.alto = ancho; // Fuerza que ambos lados sean iguales
  }

  setAlto(alto: number) {
    this.alto = alto;
    this.ancho = alto; // Idem
  }
}

Parece lógico: un cuadrado es un rectángulo (matemáticamente). Pero mirá qué pasa:

function duplicarAncho(rect: Rectangulo) {
  const altoOriginal = rect.area() / rect.ancho; // guardamos el alto
  rect.setAncho(rect.ancho * 2);
  // Esperamos: área = ancho * 2 * altoOriginal
  return rect.area();
}

const rect = new Rectangulo(5, 10);
console.log(duplicarAncho(rect)); // 100 ✓ (10 * 10)

const cuad = new Cuadrado(5, 5);
console.log(duplicarAncho(cuad)); // 100... pero esperábamos 50
// setAncho(10) también cambió el alto a 10

Cuadrado viola LSP porque cambia el comportamiento esperado de setAncho: la función que usa un Rectangulo asume que cambiar el ancho no afecta el alto. El subtipo rompió esa asunción.

La solución

No modelar Cuadrado como subtipo de Rectangulo. Son figuras con restricciones diferentes:

interface Figura {
  area(): number;
}

class Rectangulo implements Figura {
  constructor(
    public ancho: number,
    public alto: number
  ) {}
  area() {
    return this.ancho * this.alto;
  }
}

class Cuadrado implements Figura {
  constructor(public lado: number) {}
  area() {
    return this.lado * this.lado;
  }
}

// Cualquier función que use Figura funciona con ambos
function imprimirArea(fig: Figura) {
  console.log(`Área: ${fig.area()}`);
}

Un ejemplo real en JavaScript

LSP aparece mucho cuando trabajás con abstracciones de storage o data access:

interface Cache {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ttl?: number): Promise<void>;
}

class RedisCache implements Cache {
  async get(key: string) {
    return await redis.get(key);
  }
  async set(key: string, value: string, ttl?: number) {
    if (ttl) await redis.setex(key, ttl, value);
    else await redis.set(key, value);
  }
}

class MemoryCache implements Cache {
  private store = new Map<string, { value: string; expires?: number }>();

  async get(key: string) {
    const entry = this.store.get(key);
    if (!entry) return null;
    if (entry.expires && Date.now() > entry.expires) {
      this.store.delete(key);
      return null;
    }
    return entry.value;
  }

  async set(key: string, value: string, ttl?: number) {
    this.store.set(key, {
      value,
      expires: ttl ? Date.now() + ttl * 1000 : undefined,
    });
  }
}

Cualquier código que use Cache funciona igual con RedisCache o MemoryCache. En tests usás MemoryCache, en producción RedisCache. Eso es LSP en acción — podés sustituir la implementación sin que el consumidor se entere.

Si MemoryCache lanzara una excepción en set cuando le pasás un ttl, estaría violando LSP porque cambia el contrato que Cache promete.

I — Principio de Segregación de Interfaces

“Los clientes no deben estar obligados a depender de interfaces que no usan.”

Si tenés una interfaz con 15 métodos y un consumidor solo necesita 2, algo anda mal. Forzar implementaciones innecesarias genera código muerto, confusión y acopla cosas que no deberían estar juntas.

El problema

interface Animal {
  comer(): void;
  dormir(): void;
  volar(): void;
  nadar(): void;
  correr(): void;
}

class Pato implements Animal {
  comer() { /* ✓ */ }
  dormir() { /* ✓ */ }
  volar() { /* ✓ */ }
  nadar() { /* ✓ */ }
  correr() { /* ✓ */ }
}

class Pez implements Animal {
  comer() { /* ✓ */ }
  dormir() { /* ✓ */ }
  volar() { throw new Error("Un pez no vuela"); } // 💀
  nadar() { /* ✓ */ }
  correr() { throw new Error("Un pez no corre"); } // 💀
}

El pez se ve obligado a implementar volar() y correr() — métodos que no tienen sentido para él. Esto es exactamente lo que ISP dice que no hagas.

La solución

Interfaces pequeñas y específicas que se componen según necesidad:

interface Alimentable {
  comer(): void;
}

interface Dormible {
  dormir(): void;
}

interface Volador {
  volar(): void;
}

interface Nadador {
  nadar(): void;
}

interface Corredor {
  correr(): void;
}

class Pato implements Alimentable, Dormible, Volador, Nadador, Corredor {
  comer() { /* ... */ }
  dormir() { /* ... */ }
  volar() { /* ... */ }
  nadar() { /* ... */ }
  correr() { /* ... */ }
}

class Pez implements Alimentable, Dormible, Nadador {
  comer() { /* ... */ }
  dormir() { /* ... */ }
  nadar() { /* ... */ }
  // No necesita volar ni correr — ni siquiera aparecen
}

Cómo se aplica en JavaScript (sin interfaces formales)

JavaScript no tiene interfaces en el lenguaje, pero el principio aplica igual. Lo ves en cómo diseñás las props de un componente React o los parámetros de una función:

// ❌ ISP violado: el componente recibe todo el usuario
// aunque solo necesita nombre y avatar
function AvatarCard({ user }: { user: User }) {
  return (
    <div>
      <img src={user.avatarUrl} />
      <span>{user.nombre}</span>
    </div>
  );
}

// ✅ ISP aplicado: solo recibe lo que necesita
function AvatarCard({
  nombre,
  avatarUrl,
}: {
  nombre: string;
  avatarUrl: string;
}) {
  return (
    <div>
      <img src={avatarUrl} />
      <span>{nombre}</span>
    </div>
  );
}

La segunda versión es más fácil de testear, más reutilizable, y no se rompe si le agregás un campo nuevo a User. En TypeScript, Pick<User, 'nombre' | 'avatarUrl'> te da lo mismo.

Otro caso común: configuraciones de librerías. Si tu función acepta un objeto de config con 20 propiedades opcionales, pero el 90% de los usos necesita solo 3, considerá dividirlo o tener defaults sensatos.

D — 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.”

Este es probablemente el principio que más impacto tiene en la arquitectura de un proyecto. La idea es que tu lógica de negocio no debería saber ni importarle si estás usando PostgreSQL o MongoDB, si mandás emails con SendGrid o con Resend.

El problema

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

class UserService {
  async obtenerUsuario(id) {
    // Acoplado directamente a Prisma
    return await prisma.user.findUnique({ where: { id } });
  }

  async crearUsuario(data) {
    return await prisma.user.create({ data });
  }
}

Si el día de mañana querés migrar de Prisma a Drizzle, o necesitás un mock para tests, tenés que modificar UserService. Tu lógica de negocio está atada a un detalle de implementación.

La solución

Definir una abstracción (interfaz o contrato) y hacer que tanto el servicio como la implementación dependan de ella:

// ports/user-repository.ts — la abstracción
interface UserRepository {
  findById(id: string): Promise<User | null>;
  create(data: CreateUserDTO): Promise<User>;
}

// adapters/prisma-user-repository.ts — implementación concreta
class PrismaUserRepository implements UserRepository {
  constructor(private prisma: PrismaClient) {}

  async findById(id: string) {
    return await this.prisma.user.findUnique({ where: { id } });
  }

  async create(data: CreateUserDTO) {
    return await this.prisma.user.create({ data });
  }
}

// services/user-service.ts — lógica de negocio
class UserService {
  constructor(private repo: UserRepository) {}

  async obtenerUsuario(id: string) {
    const user = await this.repo.findById(id);
    if (!user) throw new Error("Usuario no encontrado");
    return user;
  }
}

Ahora UserService depende de UserRepository (la abstracción), no de Prisma. Podés intercambiar la implementación:

// En producción
const service = new UserService(new PrismaUserRepository(prisma));

// En tests
const service = new UserService(new InMemoryUserRepository());

// Si migrás a Drizzle
const service = new UserService(new DrizzleUserRepository(db));

Sin clases también se puede

No necesitás clases para aplicar DIP. En JavaScript funcional, basta con inyectar funciones:

type ObtenerUsuario = (id: string) => Promise<User | null>;

function crearUserService(obtenerUsuario: ObtenerUsuario) {
  return {
    async getUser(id: string) {
      const user = await obtenerUsuario(id);
      if (!user) throw new Error("No encontrado");
      return user;
    },
  };
}

// Uso
const service = crearUserService((id) =>
  prisma.user.findUnique({ where: { id } })
);

// Test
const service = crearUserService(async (id) =>
  id === "1" ? { id: "1", nombre: "Test" } : null
);

La idea es la misma: tu lógica de negocio recibe sus dependencias, no las importa directamente. Eso es inversión de dependencias.

¿Cuándo aplicar SOLID y cuándo no?

Seamos honestos: no todo código necesita SOLID. Si estás haciendo un script de una sola vez, un prototipo rápido o un componente de UI simple, aplicar estos principios puede ser overengineering.

SOLID brilla cuando:

  • El proyecto va a crecer: más features, más desarrolladores, más complejidad.
  • Necesitás testear: DIP e ISP hacen que el testing sea dramáticamente más fácil.
  • Hay múltiples implementaciones: si hoy usás Redis y mañana puede ser Memcached, DIP te salva.
  • Trabajás en equipo: SRP y OCP reducen conflictos de merge y facilitan el trabajo en paralelo.

SOLID puede ser overkill cuando:

  • Es un prototipo o MVP: la velocidad importa más que la arquitectura perfecta.
  • El módulo es pequeño y estable: no todo necesita abstracciones de tres capas.
  • Solo hay un consumidor: si solo un lugar usa tu módulo, una interfaz puede ser ruido.

El error más común no es ignorar SOLID — es aplicarlo prematuramente. Creás interfaces, repositorios y servicios para un CRUD que nunca va a cambiar de base de datos. El código termina más complejo de lo necesario.

Una heurística que nos funciona: empezá simple, refactorizá cuando el código te lo pida. Si ves que un módulo tiene tres razones para cambiar, aplicá SRP. Si necesitás un segundo storage, aplicá DIP. Pero no lo hagas “por si acaso”.

Para llevar

SOLID no es un checklist que hay que completar en cada archivo. Son principios que te dan un vocabulario para pensar sobre el diseño de tu código:

  • SRP: ¿este módulo tiene más de una razón para cambiar?
  • OCP: ¿puedo agregar funcionalidad sin editar código existente?
  • LSP: ¿puedo intercambiar implementaciones sin romper nada?
  • ISP: ¿estoy forzando a alguien a depender de cosas que no necesita?
  • DIP: ¿mi lógica de negocio depende de detalles de infraestructura?

Si podés responder esas preguntas con criterio, estás aplicando SOLID — aunque nunca escribas una interface o una clase abstracta. Al final, se trata de escribir código que sea fácil de cambiar, porque si algo es seguro en software, es que todo va a cambiar.