StackPractices
intermediate Por Mathias Paulenko

Principios SOLID Explicados con Ejemplos

Aprende los cinco principios SOLID con ejemplos de código malo vs bueno y un refactor completo antes/después de un servicio de pagos en TypeScript.

Temas: design

Introducción

Todo codebase longevo tiene una clase que empezó pequeña y creció hasta que nadie quiere tocarla. Un ajuste de validación rompe el reporte. Un método de pago nuevo obliga a volver a probar lógica que ya funcionaba. SOLID es un conjunto de cinco principios de diseño pensados para ese dolor exacto: mantener las clases pequeñas, los cambios locales y las dependencias reemplazables.

Robert C. Martin popularizó los principios en su paper de 2000 “Design Principles and Design Patterns” y después en Agile Software Development; el acrónimo SOLID en sí lo acuñó Michael Feathers. Son fundamentales para el diseño orientado a objetos, y la mayoría se traslada también a módulos y servicios. Esta guía es para quienes ya escriben código orientado a objetos y quieren los principios con ejemplos funcionales, no solo definiciones.

LetraPrincipioIdea central
SResponsabilidad ÚnicaUna clase debe tener una única razón para cambiar
OAbierto/CerradoAbierto para extensión, cerrado para modificación
LSustitución de LiskovLos subtipos deben ser sustituibles por sus tipos base
ISegregación de InterfacesLos clientes no deben depender de interfaces que no usan
DInversión de DependenciasDepender de abstracciones, no de implementaciones concretas

S — Principio de Responsabilidad Única (SRP)

Una clase debe tener solo una razón para cambiar.

La violación clásica: una clase que gestiona órdenes y produce reportes. Dos razones no relacionadas para modificarla, y cada cambio pone en riesgo a la otra mitad.

# Malo: una clase maneja lógica de órdenes Y reportes
class OrderManager:
    def __init__(self):
        self.orders: list[dict] = []

    def create_order(self, items: list[dict]) -> dict:
        order = {"id": len(self.orders) + 1, "items": items}
        self.orders.append(order)
        return order

    def cancel_order(self, order_id: int) -> None:
        self.orders = [o for o in self.orders if o["id"] != order_id]

    def generate_monthly_report(self) -> str:
        return "\n".join(
            f"Order {o['id']}: {len(o['items'])} items" for o in self.orders
        )
# Bueno: separar responsabilidades
class OrderService:
    def __init__(self):
        self.orders: list[dict] = []

    def create_order(self, items: list[dict]) -> dict:
        order = {"id": len(self.orders) + 1, "items": items}
        self.orders.append(order)
        return order

    def cancel_order(self, order_id: int) -> None:
        self.orders = [o for o in self.orders if o["id"] != order_id]


class ReportGenerator:
    def __init__(self, order_service: OrderService):
        self.order_service = order_service

    def generate_monthly_report(self) -> str:
        return "\n".join(
            f"Order {o['id']}: {len(o['items'])} items"
            for o in self.order_service.orders
        )

La señal que verás en bases de código reales es un método que no toca el mismo estado que el resto de la clase — como generate_monthly_report arriba, que solo lee self.orders. Ese método es una segunda responsabilidad pidiendo su propia clase. Ojo con el extremo opuesto: dividir cada método en su propia clase te da fragmentación, no foco. Consulta los principios de código limpio para prácticas relacionadas.

O — Principio Abierto/Cerrado (OCP)

Las entidades de software deben estar abiertas para extensión pero cerradas para modificación.

# Malo: modificar código existente para cada nuevo método de pago
class PaymentProcessor:
    def process(self, payment: dict) -> str:
        if payment["type"] == "credit_card":
            return f"Charged {payment['amount']} to card {payment['card']}"
        elif payment["type"] == "paypal":
            return f"Sent {payment['amount']} to {payment['email']}"
        # agregar "crypto" significa editar — y volver a probar — este método
# Bueno: extender mediante nuevas clases
from abc import ABC, abstractmethod


class PaymentMethod(ABC):
    @abstractmethod
    def process(self, amount: float) -> str:
        ...


class CreditCardPayment(PaymentMethod):
    def __init__(self, card_number: str):
        self.card_number = card_number

    def process(self, amount: float) -> str:
        return f"Charged {amount} to card {self.card_number}"


class PayPalPayment(PaymentMethod):
    def __init__(self, email: str):
        self.email = email

    def process(self, amount: float) -> str:
        return f"Sent {amount} to {self.email}"


class PaymentProcessor:
    def __init__(self, method: PaymentMethod):
        self.method = method

    def process(self, amount: float) -> str:
        return self.method.process(amount)


# Agregar un método nuevo requiere cero cambios en el código existente
class CryptoPayment(PaymentMethod):
    def __init__(self, wallet: str):
        self.wallet = wallet

    def process(self, amount: float) -> str:
        return f"Transferred {amount} to wallet {self.wallet}"

El beneficio aparece la tercera vez que agregas un método de pago: en vez de tocar código ya probado, escribes una clase nueva y nada más. El costo es real, eso sí — cada punto de extensión suma una interfaz, y las prematuras son la forma más común de abusar de OCP. Si un valor solo tuvo una o dos variantes, un if simple está bien. Consulta el patrón Strategy para comportamiento intercambiable.

L — Principio de Sustitución de Liskov (LSP)

Los objetos de una superclase deben ser reemplazables por objetos de sus subclases sin romper el programa.

La violación de manual es Square heredando de Rectangle: un cuadrado “es” un rectángulo matemáticamente, pero el contrato de set_width/set_height no sobrevive.

# Malo: Square viola LSP cuando se usa como Rectangle
class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    def set_width(self, w):
        self._width = w

    def set_height(self, h):
        self._height = h

    def area(self):
        return self._width * self._height


class Square(Rectangle):  # viola LSP
    def set_width(self, w):
        self._width = w
        self._height = w  # ¡efecto secundario sorprendente!

    def set_height(self, h):
        self._width = h   # ¡efecto secundario sorprendente!
        self._height = h


# Una función que espera comportamiento de Rectangle se rompe con Square
def resize_rectangle(rect: Rectangle):
    rect.set_width(5)
    rect.set_height(4)
    assert rect.area() == 20  # ¡falla para Square!
# Bueno: modelar Square de forma independiente o como objeto de valor
from dataclasses import dataclass


class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height


@dataclass(frozen=True)
class Square:
    side: int

    def area(self):
        return self.side * self.side

Las violaciones de LSP son los bugs más silenciosos de los cinco: el código compila, el polimorfismo funciona, y el fallo solo aparece cuando un llamador confía en el contrato del padre. En la práctica se delatan como checks de isinstance regados por los llamadores, o métodos sobrescritos que lanzan NotImplementedError. Ambos son señales de que la herencia está mal y los dos tipos solo comparten una interfaz.

I — Principio de Segregación de Interfaces (ISP)

Los clientes no deben verse forzados a depender de interfaces que no usan.

# Malo: una interfaz sobrecargada
from abc import ABC, abstractmethod


class Worker(ABC):
    @abstractmethod
    def work(self) -> None:
        ...

    @abstractmethod
    def eat(self) -> None:    # los robots no comen
        ...

    @abstractmethod
    def sleep(self) -> None:  # los robots no duermen
        ...


class RobotWorker(Worker):
    def work(self) -> None:
        print("Assembling widgets")

    def eat(self) -> None:
        raise NotImplementedError  # forzado a implementar un método sin sentido

    def sleep(self) -> None:
        raise NotImplementedError
# Bueno: dividir en interfaces enfocadas
class Workable(ABC):
    @abstractmethod
    def work(self) -> None:
        ...


class Feedable(ABC):
    @abstractmethod
    def eat(self) -> None:
        ...


class HumanWorker(Workable, Feedable):
    def work(self) -> None:
        print("Writing code")

    def eat(self) -> None:
        print("Lunch break")


class RobotWorker(Workable):
    def work(self) -> None:
        print("Assembling widgets")
    # no necesita implementar eat() ni sleep()

Un NotImplementedError en un override es ISP avisándote que la interfaz es demasiado ancha. Las interfaces sobrecargadas crean acoplamiento innecesario: cuando un cliente depende de métodos que nunca llama, los cambios en esos métodos pueden forzar recompilaciones o volver a probar código. Tampoco conviene cortar demasiado fino — un método por interfaz suele ser exceso en lenguajes dinámicos.

D — Principio de Inversión de Dependencias (DIP)

Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones.

# Malo: módulo de alto nivel depende de un módulo de bajo nivel concreto
class EmailService:
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"SMTP -> {to}: {subject}")  # una impl real habla con un servidor


class NotificationManager:  # alto nivel
    def __init__(self):
        self.email = EmailService()  # dependencia fija en el código

    def notify_user(self, user: dict) -> None:
        self.email.send(user["email"], "Hello", "Welcome!")
# Bueno: depender de abstracciones
class NotificationChannel(ABC):
    @abstractmethod
    def send(self, to: str, subject: str, body: str) -> None:
        ...


class EmailService(NotificationChannel):
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"SMTP -> {to}: {subject}")


class SMSService(NotificationChannel):
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"SMS -> {to}: {subject}")


class NotificationManager:
    def __init__(self, channel: NotificationChannel):
        self.channel = channel

    def notify_user(self, user: dict) -> None:
        self.channel.send(user["email"], "Hello", "Welcome!")


# Cambiar implementaciones sin tocar NotificationManager
email_notifier = NotificationManager(EmailService())
sms_notifier = NotificationManager(SMSService())

Depender de abstracciones te permite intercambiar implementaciones —para pruebas, distintos entornos o requisitos nuevos— sin tocar la lógica de negocio de alto nivel. También es lo que abarata los tests unitarios: pasas un canal falso en lugar de parchear SMTP. No conviene inyectar todo, igual: utilidades estables como datetime o un parser JSON se pueden llamar directamente. Consulta el patrón Factory para crear abstracciones.

Cuándo aplicar SOLID (y cuándo no)

Los principios rinden donde ocurre el cambio. Aplícalos cuando veas estas señales:

  • Una clase se toca por motivos sin relación. Cada sprint alguien edita UserService por una responsabilidad distinta —auth, emails, exports. Eso es SRP pidiendo una división.
  • Un if/elif o switch gana una rama en cada release. Métodos de pago, canales de notificación, formatos de reporte — ese es el caso OCP para una abstracción tipo plugin.
  • Los llamadores se defienden con checks de isinstance. Compensan una subclase que no honra el contrato del padre (LSP).
  • Los tests necesitan parchear un cliente de red o una conexión a base de datos. La dependencia debió invertirse (DIP).
  • Implementar una interfaz exige métodos vacíos que lanzan excepción. La interfaz es demasiado ancha para ese cliente (ISP).

Y los casos donde SOLID suele ser exceso:

  • Scripts y herramientas de un solo uso. Si el código corre una vez y es fácil de reescribir, la abstracción cuesta más de lo que ahorra.
  • Prototipos tempranos. Todavía no conoces los ejes de cambio; las interfaces prematuras congelan los límites equivocados.
  • Operaciones CRUD simples. Un controlador delgado sobre un modelo de datos no necesita cinco capas de indirección.
  • Dependencias estables y aburridas. Envolver math o json no compra nada.

La regla honesta: aplica SOLID donde la volatilidad está probada, no donde algún día podría existir. La sección de errores comunes muestra cómo esto sale mal.

Aplicar SOLID en conjunto

Los principios se refuerzan entre sí — las clases pequeñas y enfocadas (SRP) son más fáciles de extender (OCP), el subtipado correcto (LSP) es lo que hace segura la inyección polimórfica (DIP), y las interfaces angostas (ISP) achican la superficie que expone cada dependencia:

flowchart diagram: SRP[

La inyección de dependencias de DIP es también donde todo converge en un codebase: un composition root conecta abstracciones con implementaciones concretas, y por eso frameworks como NestJS o ASP.NET Core son, en esencia, mecanismos de entrega de SOLID.

Ejemplo completo: refactor de un servicio de pagos

Toma la forma típica del código de pagos heredado: una clase que valida, cobra, loguea, envía emails y persiste, todo en un método. La versión “antes” de abajo viola SRP, OCP y DIP a la vez:

// Antes: viola SRP, OCP y DIP
class PaymentProcessor {
  processPayment(payment: Payment) {
    // Valida (violación de SRP)
    if (!payment.amount || payment.amount <= 0) throw new Error("Inválido");
    // Procesa con Stripe (violación de OCP - fijo en el código)
    const stripe = new StripeClient("sk_live_xxx");
    const result = stripe.charge(payment.amount, payment.currency);
    // Loguea en la consola (violación de SRP)
    console.log("Pago procesado:", result);
    // Envía un email (violación de SRP)
    const email = new EmailService();
    email.send(payment.customerEmail, "Pago recibido");
    // Guarda en MySQL (violación de DIP - fijo en el código)
    const db = new MySQLConnection("localhost", "payments");
    db.query("INSERT INTO payments ...", result);
    return result;
  }
}
// Después: refactor conforme a SOLID

// SRP: cada clase tiene una responsabilidad
interface PaymentValidator {
  validate(payment: Payment): void;
}
class PaymentValidatorImpl implements PaymentValidator {
  validate(payment: Payment) {
    if (!payment.amount || payment.amount <= 0)
      throw new Error("Monto inválido");
    if (!payment.currency || payment.currency.length !== 3)
      throw new Error("Moneda inválida");
  }
}

// OCP: nuevos gateways sin modificar código existente
interface PaymentGateway {
  charge(amount: number, currency: string): Promise<PaymentResult>;
}
class StripeGateway implements PaymentGateway {
  constructor(private client: StripeClient) {}
  async charge(amount: number, currency: string) {
    return this.client.charge(amount, currency);
  }
}
class PayPalGateway implements PaymentGateway {
  constructor(private client: PayPalClient) {}
  async charge(amount: number, currency: string) {
    return this.client.createOrder(amount, currency);
  }
}

// ISP: interfaces segregadas
interface PaymentRepository {
  save(result: PaymentResult): Promise<void>;
}
interface NotificationService {
  notify(email: string, subject: string, body: string): Promise<void>;
}
interface Logger {
  log(level: string, message: string, meta?: object): void;
}

// DIP: depende de abstracciones, no de implementaciones concretas
class PaymentProcessor {
  constructor(
    private validator: PaymentValidator,
    private gateway: PaymentGateway,
    private repo: PaymentRepository,
    private notifier: NotificationService,
    private logger: Logger
  ) {}

  async processPayment(payment: Payment): Promise<PaymentResult> {
    this.validator.validate(payment);
    this.logger.log("info", "Procesando pago", { amount: payment.amount });

    const result = await this.gateway.charge(payment.amount, payment.currency);

    await this.repo.save(result);
    await this.notifier.notify(payment.customerEmail, "Pago recibido",
      `Tu pago de ${payment.amount} ${payment.currency} fue procesado.`);
    this.logger.log("info", "Pago completado", { id: result.id });
    return result;
  }
}

// LSP: cualquier implementación de PaymentGateway funciona
const processor = new PaymentProcessor(
  new PaymentValidatorImpl(),
  new StripeGateway(stripeClient),  // o new PayPalGateway(paypalClient)
  new PostgresPaymentRepository(db),
  new EmailNotificationService(smtp),
  new WinstonLogger()
);

// Pruebas: simula todo mediante las interfaces
const testProcessor = new PaymentProcessor(
  mockValidator, mockGateway, mockRepo, mockNotifier, mockLogger
);
// Cada dependencia es intercambiable y se puede probar por separado

El refactor no es gratis — cinco interfaces donde había una clase — así que solo se paga solo si los gateways realmente cambian o se agregan. Donde rinde de inmediato es en las pruebas: PaymentProcessor ahora acepta simulacros sin ningún framework de patching. Ambas versiones están disponibles como mini-proyecto ejecutable en el repositorio companion. Para un tratamiento específico de TypeScript de los mismos principios (decoradores, contenedores de DI, tipado estructural), consulta Principios SOLID en TypeScript.

SOLID más allá de las clases

Los principios se trasladan limpiamente a unidades de diseño más grandes:

  • Microservicios. SRP se convierte en límites de servicio: cada servicio posee una capacidad de negocio. OCP significa que las funcionalidades nuevas se entregan como servicios o endpoints nuevos, no como ediciones a los estables. LSP es la compatibilidad de contratos de API entre versiones — un endpoint v2 que rompe clientes de v1 es una violación de LSP a nivel HTTP. ISP aparece como backend-for-frontend: cada cliente recibe solo los endpoints que necesita. DIP significa que los servicios dependen de contratos publicados (especificaciones OpenAPI, esquemas de mensajes), no de las entrañas de los demás.
  • Programación funcional. SRP se aplica igual a módulos y funciones. DIP se logra con funciones de orden superior — pasas el comportamiento en vez de inyectar un colaborador. LSP importa menos sin herencia, pero sigue aplicando a funciones que declaran la misma firma.
  • Configuración e infraestructura. DIP es la razón por la que los módulos de infraestructura como código toman parámetros inyectados en vez de fijar entornos en el código.

Si estás evaluando si un límite pertenece a nivel clase o servicio, la respuesta suele ser SRP: una razón para cambiar, un equipo que lo posee.

Buenas prácticas

  • Extrae por razón de cambio, no por cantidad de métodos. Dos métodos que cambian juntos pertenecen juntos; un método que responde a otro interesado pertenece a otro lado.
  • Introduce la interfaz cuando aparezca la segunda implementación. Escribir PaymentGateway antes de que exista PayPal es adivinar; escribirla cuando llega PayPal es OCP.
  • Prefiere composición sobre herencia profunda. La mayor parte del dolor de LSP desaparece cuando las subclases dejan de intentar ser sus padres y pasan a usar colaboradores.
  • Mantén el composition root delgado. Conecta las dependencias en un solo lugar (main, un contenedor de DI) y deja los constructores sin new para todo lo intercambiable.
  • Deja que el dolor guíe el refactor. Aplica el principio cuando el cambio demuestra la necesidad — un if/elif que crece, un test que no se puede escribir, una subclase que sorprende.

Errores comunes

  • Crear una clase por método para forzar SRP. No toda función necesita su propia clase; SRP habla de razones para cambiar, no de tamaño.
  • Usar OCP como excusa para abstracción prematura. YAGNI sigue aplicando — una interfaz con una sola implementación durante dos años es una apuesta, no un diseño.
  • Aplicar mal LSP a objetos de valor. Los tipos inmutables como Money o DateRange no están pensados para ser sustituibles; forzar sustituibilidad crea la trampa Square/Rectangle.
  • Dividir interfaces hasta fragmentar el sistema. Diez interfaces de un método cada una suelen ser más difíciles de navegar que la interfaz sobrecargada original.
  • Inyectar dependencias en todas partes. Las utilidades triviales y estables (JSON.stringify, uuid) no necesitan inyección; inyecta lo que cambia, falla o toca la red.

Lectura adicional

Preguntas frecuentes

¿Debería aplicar todos los principios SOLID a cada clase?

No — trátalos como guías, no como leyes, y aplícalos donde reduzcan complejidad y acoplamiento. Los scripts pequeños y las operaciones CRUD a menudo no necesitan el tratamiento SOLID completo — el costo de la indirección extra supera el beneficio cuando el cambio es improbable.

¿Los principios SOLID aplican solo a la POO?

No, los conceptos se traducen bien. La programación funcional logra DIP con funciones de orden superior, y SRP aplica a módulos y funciones en cualquier modelo. Consulta la guía de patrones de diseño para ejemplos prácticos en otros paradigmas.

¿Cómo aparecen los principios SOLID en entrevistas?

Espera una definición por principio más un ejercicio de "detecta la violación". Las dos preguntas más comunes son el problema Square/Rectangle para LSP y una interfaz sobrecargada estilo Worker para ISP. Poder refactorizar un ejemplo antes/después — como el servicio de pagos de arriba — es lo que separa una respuesta memorizada de una de verdad.

¿Cómo aplican los principios SOLID a los microservicios?

SRP se corresponde con límites de servicio, OCP con entregar servicios nuevos en vez de editar los estables, LSP con la compatibilidad de contratos de API entre versiones, ISP con endpoints específicos por cliente (el patrón BFF) y DIP con depender de contratos publicados en vez de detalles internos.

¿Cómo convenzo a mi equipo de refactorizar hacia SOLID?

No refactorices por los principios en sí. Espera hasta que haga falta un cambio y entonces úsalos para guiar un diseño más limpio. Las comparaciones antes/después en los PRs — sobre todo las que muestran un test que antes era imposible de escribir — convencen más rápido que cualquier definición.