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.
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.
| Letra | Principio | Idea central |
|---|---|---|
| S | Responsabilidad Única | Una clase debe tener una única razón para cambiar |
| O | Abierto/Cerrado | Abierto para extensión, cerrado para modificación |
| L | Sustitución de Liskov | Los subtipos deben ser sustituibles por sus tipos base |
| I | Segregación de Interfaces | Los clientes no deben depender de interfaces que no usan |
| D | Inversión de Dependencias | Depender 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
UserServicepor una responsabilidad distinta —auth, emails, exports. Eso es SRP pidiendo una división. - Un
if/elifoswitchgana 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
mathojsonno 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:
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
PaymentGatewayantes 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 sinnewpara todo lo intercambiable. - Deja que el dolor guíe el refactor. Aplica el principio cuando el cambio demuestra la necesidad — un
if/elifque 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
MoneyoDateRangeno 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
- Robert C. Martin, “Design Principles and Design Patterns” (2000) — el paper que popularizó los principios.
- Robert C. Martin, Clean Architecture (2017) — los capítulos 11 a 15 cubren SOLID en profundidad.
- SOLID en Wikipedia — historia del acrónimo acuñado por Michael Feathers.
- Principios SOLID en TypeScript — los mismos principios con decoradores, contenedores de DI y tipado estructural.
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.
Recursos Relacionados
Principios de Código Limpio: Nombres, Funciones, Clases
Guía práctica de los principios de código limpio: nombres con intención, funciones pequeñas, responsabilidad única, comentarios útiles, manejo de errores, DRY y formato.
GuideGuía Práctica de Design Patterns
Elige el design pattern correcto para cada problema: Singleton, Factory, Observer, Strategy y más, con código ejecutable y trade-offs honestos.
PatternPrincipios SOLID en TypeScript con Ejemplos Practicos
Aplica los cinco principios SOLID a codigo TypeScript para mejorar mantenibilidad, testeabilidad y reducir acoplamiento en disenos orientados a objetos
GuideGuía de Clean Architecture: Capas y Regla de Dependencia
Guía práctica de Clean Architecture de Uncle Bob: organiza el código en capas para que frameworks, UI y bases de datos sean detalles, no dependencias.
GuideDesarrollo Guiado por Pruebas (TDD)
Aprende TDD paso a paso: escribe un test que falle, hazlo pasar, refactoriza. Red-Green-Refactor con ejemplos reales en Python, JavaScript y Java.