Resumen
SOLID son cinco principios de diseño orientado a objetos para escribir código fácil de cambiar, de testear y de entender. Los reunió Robert C. Martin (“Uncle Bob”) alrededor del año 2000, y Michael Feathers armó el acrónimo con sus iniciales.
| Letra | Principio | En una frase |
|---|---|---|
| S | Single Responsibility (responsabilidad única) | Una clase debería tener un solo motivo para cambiar |
| O | Open/Closed (abierto/cerrado) | Abierto a extensión, cerrado a modificación |
| L | Liskov Substitution (sustitución de Liskov) | Una subclase tiene que poder reemplazar a su clase padre sin romper nada |
| I | Interface Segregation (segregación de interfaces) | Nadie debería depender de métodos que no usa |
| D | Dependency Inversion (inversión de dependencias) | Depender de abstracciones, no de implementaciones concretas |
Analogía: una casa bien construida
En una casa bien hecha podés cambiar la heladera sin tocar la instalación eléctrica, porque todo se conecta a un enchufe estándar. SOLID busca lo mismo en el código: que cambiar una pieza no obligue a romper las paredes.
Key points
- El objetivo de fondo es bajo acoplamiento y alta cohesión: piezas independientes, cada una enfocada en una cosa
- Son guías, no leyes: aplicarlos al extremo produce código lleno de clases diminutas
- Se notan cuando el código cambia: un buen diseño hace que un cambio toque un solo lugar
- Muchos patrones de diseño son aplicaciones concretas de estos principios
flowchart LR S[S: una razón<br/>para cambiar] --> OBJ((Código fácil de<br/>cambiar y testear)) O[O: extender sin<br/>modificar] --> OBJ L[L: subclases<br/>intercambiables] --> OBJ I[I: interfaces<br/>chicas] --> OBJ D[D: depender de<br/>abstracciones] --> OBJ
S: Single Responsibility Principle
Definición
“Una clase debería tener una, y solo una, razón para cambiar.” — Robert C. Martin
“Responsabilidad” no significa “hacer una sola cosa”, sino responder a un solo actor: una sola persona o área del negocio que podría pedir cambios. Si el área de contabilidad y el área de diseño pueden pedir cambios sobre la misma clase, esa clase tiene dos responsabilidades.
Problema
Una clase
Reportecalcula las ventas, las formatea como HTML, las guarda en disco y las manda por email. Si cambia el formato, el cálculo o el servidor de email, siempre se toca la misma clase, y un cambio puede romper algo que no tenía nada que ver.
Antes:
class Reporte:
def __init__(self, ventas: list[float]):
self.ventas = ventas
def calcular_total(self) -> float: # lógica de negocio
return sum(self.ventas)
def a_html(self) -> str: # presentación
return f"<h1>Total: ${self.calcular_total():,.2f}</h1>"
def guardar(self, ruta: str) -> None: # persistencia
with open(ruta, "w") as f:
f.write(self.a_html())
def enviar_email(self, destino: str) -> None: # comunicación
print(f"Enviando a {destino}: {self.a_html()}")Después: cada clase tiene un solo motivo para cambiar.
classDiagram class Reporte { +ventas +calcular_total() float } class FormateadorHTML { +formatear(reporte) str } class GuardadorArchivo { +guardar(contenido, ruta) } class EnviadorEmail { +enviar(contenido, destino) } FormateadorHTML ..> Reporte
class Reporte:
def __init__(self, ventas: list[float]):
self.ventas = ventas
def calcular_total(self) -> float:
return sum(self.ventas)
class FormateadorHTML:
def formatear(self, reporte: Reporte) -> str:
return f"<h1>Total: ${reporte.calcular_total():,.2f}</h1>"
class GuardadorArchivo:
def guardar(self, contenido: str, ruta: str) -> None:
with open(ruta, "w") as f:
f.write(contenido)
class EnviadorEmail:
def enviar(self, contenido: str, destino: str) -> None:
print(f"Enviando a {destino}: {contenido}")
reporte = Reporte([1500, 2300.5, 980])
html = FormateadorHTML().formatear(reporte)
EnviadorEmail().enviar(html, "contabilidad@empresa.com")Señal de que se está violando
- Te cuesta describir qué hace la clase sin usar “y” (“calcula y formatea y guarda…”)
- El nombre termina en
Manager,HandleroUtilsy tiene 30 métodos- Los imports de la clase mezclan cosas sin relación: base de datos, email, HTML, PDF
O: Open/Closed Principle
Definición
“Las entidades de software deberían estar abiertas a la extensión, pero cerradas a la modificación.” — Bertrand Meyer (1988)
Agregar comportamiento nuevo debería hacerse escribiendo código nuevo, no editando código que ya funciona y está testeado.
Problema
Cada vez que se agrega un tipo de cliente, hay que abrir la función de descuentos y sumar otro
elif. Cuantos más casos, más riesgo de romper los anteriores.
Antes:
def calcular_descuento(tipo_cliente: str, monto: float) -> float:
if tipo_cliente == "regular":
return 0
elif tipo_cliente == "premium":
return monto * 0.10
elif tipo_cliente == "mayorista":
return monto * 0.25
# cada tipo nuevo = modificar esta función
raise ValueError(tipo_cliente)Después: cada tipo de cliente es una clase nueva que cumple la misma interfaz. Agregar un tipo no toca las existentes.
classDiagram class PoliticaDescuento { <<interface>> +descuento(monto) float } class Regular { +descuento(monto) float } class Premium { +descuento(monto) float } class Mayorista { +descuento(monto) float } class Estudiante { +descuento(monto) float } PoliticaDescuento <|.. Regular PoliticaDescuento <|.. Premium PoliticaDescuento <|.. Mayorista PoliticaDescuento <|.. Estudiante note for Estudiante "Nueva: se agrega sin tocar las demás"
from typing import Protocol
class PoliticaDescuento(Protocol):
def descuento(self, monto: float) -> float: ...
class Regular:
def descuento(self, monto: float) -> float:
return 0
class Premium:
def descuento(self, monto: float) -> float:
return monto * 0.10
class Mayorista:
def descuento(self, monto: float) -> float:
return monto * 0.25
# Extensión: una clase nueva, cero cambios en el código existente
class Estudiante:
def descuento(self, monto: float) -> float:
return min(monto * 0.15, 5000)
def total_a_pagar(monto: float, politica: PoliticaDescuento) -> float:
return monto - politica.descuento(monto)
print(total_a_pagar(20_000, Estudiante())) # 17000.0Es la idea detrás de Strategy
Esta solución es literalmente el patrón Strategy. Decorator, Observer y los sistemas de plugins también aplican Open/Closed.
No hace falta anticipar todo
“Cerrado a modificación” no significa diseñar puntos de extensión para cada cambio imaginable. Lo razonable es aplicarlo cuando aparece el segundo o tercer caso, no el primero.
L: Liskov Substitution Principle
Definición
Si
Ses un subtipo deT, los objetos de tipoTpueden reemplazarse por objetos de tipoSsin alterar el funcionamiento correcto del programa. — Barbara Liskov (1987)
En criollo: si algo funciona con la clase padre, tiene que seguir funcionando con cualquier subclase. La herencia no es “es un” en el sentido del mundo real, sino “se comporta como”.
Problema: el clásico cuadrado
Matemáticamente, un cuadrado es un rectángulo. Pero si
Cuadradohereda deRectangulo, el código que asume que ancho y alto son independientes se rompe.
Antes:
class Rectangulo:
def __init__(self, ancho: float, alto: float):
self.ancho = ancho
self.alto = alto
def set_ancho(self, valor: float) -> None:
self.ancho = valor
def set_alto(self, valor: float) -> None:
self.alto = valor
def area(self) -> float:
return self.ancho * self.alto
class Cuadrado(Rectangulo):
def __init__(self, lado: float):
super().__init__(lado, lado)
# Para seguir siendo cuadrado, tiene que cambiar los dos lados
def set_ancho(self, valor: float) -> None:
self.ancho = self.alto = valor
def set_alto(self, valor: float) -> None:
self.ancho = self.alto = valor
def agrandar(r: Rectangulo) -> None:
r.set_ancho(5)
r.set_alto(4)
assert r.area() == 20, f"Esperaba 20, obtuve {r.area()}"
agrandar(Rectangulo(1, 1)) # OK
try:
agrandar(Cuadrado(1)) # AssertionError: Esperaba 20, obtuve 16
except AssertionError as e:
print(e)Después: no forzar la herencia. Ambas figuras comparten solo lo que realmente tienen en común.
classDiagram class Figura { <<interface>> +area() float } class Rectangulo { +ancho +alto +area() float } class Cuadrado { +lado +area() float } Figura <|.. Rectangulo Figura <|.. Cuadrado
from typing import Protocol
class Figura(Protocol):
def area(self) -> float: ...
class Rectangulo:
def __init__(self, ancho: float, alto: float):
self.ancho, self.alto = ancho, alto
def area(self) -> float:
return self.ancho * self.alto
class Cuadrado:
def __init__(self, lado: float):
self.lado = lado
def area(self) -> float:
return self.lado ** 2
figuras: list[Figura] = [Rectangulo(5, 4), Cuadrado(3)]
print([f.area() for f in figuras]) # [20, 9]Señales de que se está violando
- Una subclase sobrescribe un método para lanzar
NotImplementedErroro para no hacer nada- El código cliente pregunta
if isinstance(x, SubclaseRara):antes de usar un objeto- Una subclase exige más en sus parámetros o promete menos en su resultado que el padre
I: Interface Segregation Principle
Definición
“Los clientes no deberían verse obligados a depender de interfaces que no usan.” — Robert C. Martin
Es preferible tener varias interfaces chicas y específicas que una grande que lo abarque todo.
Problema
Una interfaz
DispositivoMultifuncionexige imprimir, escanear y enviar fax. Una impresora simple está obligada a implementar métodos que no puede cumplir.
Antes:
from abc import ABC, abstractmethod
class DispositivoMultifuncion(ABC):
@abstractmethod
def imprimir(self, doc: str) -> None: ...
@abstractmethod
def escanear(self) -> str: ...
@abstractmethod
def enviar_fax(self, doc: str, numero: str) -> None: ...
class ImpresoraSimple(DispositivoMultifuncion):
def imprimir(self, doc: str) -> None:
print(f"Imprimiendo {doc}")
def escanear(self) -> str:
raise NotImplementedError("No puedo escanear") # ojo: también viola Liskov
def enviar_fax(self, doc: str, numero: str) -> None:
raise NotImplementedError("No tengo fax")Después: una interfaz por capacidad. Cada dispositivo implementa solo las que tiene.
classDiagram class Impresora { <<interface>> +imprimir(doc) } class Escaner { <<interface>> +escanear() str } class ImpresoraSimple { +imprimir(doc) } class Multifuncion { +imprimir(doc) +escanear() str } Impresora <|.. ImpresoraSimple Impresora <|.. Multifuncion Escaner <|.. Multifuncion
from typing import Protocol
class Impresora(Protocol):
def imprimir(self, doc: str) -> None: ...
class Escaner(Protocol):
def escanear(self) -> str: ...
class ImpresoraSimple:
def imprimir(self, doc: str) -> None:
print(f"Imprimiendo {doc}")
class Multifuncion:
def imprimir(self, doc: str) -> None:
print(f"Imprimiendo {doc}")
def escanear(self) -> str:
return "documento escaneado"
# Cada función pide solo la capacidad que necesita
def imprimir_factura(impresora: Impresora) -> None:
impresora.imprimir("factura.pdf")
imprimir_factura(ImpresoraSimple())
imprimir_factura(Multifuncion())ISP y Python
Con duck typing y
Protocol, una función puede pedir exactamente los métodos que usa, sin que las clases tengan que declarar nada. Por eso en Python este principio se aplica casi solo si se tipan bien los parámetros.
D: Dependency Inversion Principle
Definición
- Los módulos de alto nivel no deberían depender de módulos de bajo nivel. Ambos deberían depender de abstracciones.
- Las abstracciones no deberían depender de los detalles. Los detalles deberían depender de las abstracciones.
— Robert C. Martin
- Alto nivel: la lógica de negocio (qué hace la app: “confirmar un pedido”).
- Bajo nivel: los detalles técnicos (cómo: Postgres, SendGrid, el disco).
La lógica de negocio es lo más valioso y lo que menos debería cambiar cuando cambia una herramienta.
Problema
ServicioPedidoscrea adentro una conexión a Postgres. Para testearlo hace falta una base de datos real, y cambiar de base de datos obliga a modificar la lógica de negocio.
flowchart LR subgraph antes["Antes"] direction TB A1[ServicioPedidos] -->|depende de| A2[RepositorioPostgres] end subgraph despues["Después"] direction TB B1[ServicioPedidos] -->|depende de| B2[["RepositorioPedidos<br/>(interfaz)"]] B3[RepositorioPostgres] -.->|implementa| B2 B4[RepositorioEnMemoria] -.->|implementa| B2 end
La “inversión” está en la flecha: antes, el negocio apuntaba a Postgres; después, Postgres apunta a una interfaz definida por el negocio.
Antes:
class RepositorioPostgres:
def guardar(self, pedido: dict) -> None:
print(f"INSERT INTO pedidos ... {pedido}")
class ServicioPedidos:
def __init__(self):
self.repo = RepositorioPostgres() # ojo: dependencia concreta creada adentro
def confirmar(self, pedido: dict) -> None:
pedido["estado"] = "confirmado"
self.repo.guardar(pedido)Después: el servicio recibe cualquier cosa que cumpla la interfaz.
from typing import Protocol
class RepositorioPedidos(Protocol):
def guardar(self, pedido: dict) -> None: ...
class RepositorioPostgres:
def guardar(self, pedido: dict) -> None:
print(f"INSERT INTO pedidos ... {pedido}")
class RepositorioEnMemoria:
"""Ideal para tests: no necesita base de datos"""
def __init__(self):
self.pedidos: list[dict] = []
def guardar(self, pedido: dict) -> None:
self.pedidos.append(pedido)
class ServicioPedidos:
def __init__(self, repo: RepositorioPedidos): # la dependencia llega desde afuera
self.repo = repo
def confirmar(self, pedido: dict) -> None:
pedido["estado"] = "confirmado"
self.repo.guardar(pedido)
# En producción
ServicioPedidos(RepositorioPostgres()).confirmar({"id": 1})
# En un test
repo = RepositorioEnMemoria()
ServicioPedidos(repo).confirmar({"id": 2})
assert repo.pedidos[0]["estado"] == "confirmado"
print("Test OK")Inversión ≠ inyección
- Inversión de dependencias (el principio): depender de abstracciones.
- Inyección de dependencias (la técnica): pasar las dependencias desde afuera, por ejemplo por el constructor.
La inyección es la forma más común de cumplir la inversión, pero no son lo mismo. Frameworks como FastAPI, Spring o NestJS automatizan la inyección.
La base de la arquitectura limpia
Llevado a toda una aplicación, este principio es el corazón de la Arquitectura limpia y la arquitectura hexagonal: el dominio en el centro, sin depender de frameworks ni bases de datos.
SOLID y los patrones de diseño
| Principio | Patrones que lo aplican |
|---|---|
| S | Facade (separa coordinación de ejecución), Command (separa pedir una acción de ejecutarla) |
| O | Strategy, Decorator, Observer, Factory Method |
| L | Cualquier patrón basado en polimorfismo depende de que las implementaciones sean intercambiables |
| I | Adapter (expone solo la interfaz que el cliente necesita) |
| D | Factory Method, Strategy, Observer (todos dependen de interfaces, no de clases concretas) |
Ventajas y límites
- Los cambios quedan localizados: tocar una cosa no rompe otra
- El código se vuelve testeable (sobre todo gracias a la D)
- Facilita trabajar en equipo: las piezas se pueden desarrollar por separado
- Aplicado al extremo genera demasiadas clases chicas y hay que saltar entre diez archivos para seguir un flujo
- Abstraer “por las dudas” agrega complejidad que quizás nunca se use
- Balancearlo con KISS (keep it simple) y YAGNI (you aren’t gonna need it): aplicar un principio cuando el dolor de no aplicarlo es real
- Aunque nacieron para POO, las ideas aplican a funciones y módulos: una función con una responsabilidad, funciones que reciben sus dependencias como parámetros
Cómo usarlo en la práctica
No hace falta pensar en SOLID al escribir la primera versión. Sirve como checklist al revisar código o al hacer Refactoring: cuando algo cuesta cambiar o testear, alguno de los cinco principios suele explicar por qué.