Cinco principios para que un cambio de requisito toque un solo lugar del código, en vez de media aplicación. Todos los ejemplos son de un sistema de punto de venta para un minimarket.
Avanza con → ← o con los botones de abajo.
Cada principio tiene un equivalente en cómo funciona un local. Si el local se organiza así, cambiar algo no obliga a cerrar la tienda.
El cajero cobra. No hace el inventario ni la contabilidad. Si cambia la forma de hacer inventario, nadie le cambia el trabajo al cajero.
Para aceptar un nuevo medio de pago, conectas otra máquina de tarjetas. No desarmas la caja registradora.
Cualquier cajero de reemplazo puede cubrir el turno siguiendo el mismo procedimiento. Si uno no sabe dar vuelto, no es un reemplazo.
Al proveedor de bebidas le llega el pedido de bebidas, no la lista completa de compras del local.
La caja se enchufa a la pared. No está soldada al cableado del edificio, así que puedes cambiarla de lugar.
Robert C. Martin reunió estos principios a partir del año 2000. Michael Feathers propuso el acrónimo SOLID unos años después.
"Razón para cambiar" significa una persona o área que te puede pedir cambios. Si una clase atiende a varias, un cambio para una arriesga a las demás.
class Venta:
def __init__(self, items):
self.items = items
def total(self) -> int: # Contabilidad
neto = sum(i.precio * i.cantidad for i in self.items)
return round(neto * 1.19)
def guardar(self): # TI
conn = sqlite3.connect("pos.db")
conn.execute("INSERT INTO ventas ...")
def imprimir_boleta(self): # SII
print(f"BOLETA Total: ${self.total()}")
def enviar_email(self, correo: str): # Marketing
smtplib.SMTP("smtp.gmail.com").sendmail(...)class Venta: # reglas de negocio
def total(self) -> int: ...
class RepositorioVentas: # persistencia
def guardar(self, venta: Venta) -> int: ...
class Boleta: # formato del documento
def generar(self, venta: Venta) -> str: ...
class Notificador: # avisos al cliente
def enviar(self, venta: Venta, correo: str) -> None: ...Venta calcula, guarda, imprime y envía correos. Cuatro áreas distintas la pueden obligar a cambiar.Boleta.Agregar un comportamiento nuevo debería significar escribir código nuevo, no editar una función que ya funciona y ya está probada.
def procesar_pago(metodo: str, monto: int):
if metodo == "efectivo":
abrir_cajon()
elif metodo == "debito":
transbank.cobrar(monto)
elif metodo == "webpay":
webpay.crear_transaccion(monto)
else:
raise ValueError(metodo)from typing import Protocol
class MedioPago(Protocol):
def cobrar(self, monto: int) -> bool: ...
class Efectivo:
def cobrar(self, monto: int) -> bool:
abrir_cajon()
return True
class Webpay:
def cobrar(self, monto: int) -> bool:
return webpay.crear_transaccion(monto).ok
def procesar_pago(medio: MedioPago, monto: int) -> bool:
return medio.cobrar(monto) # no vuelve a cambiarelif dentro de la misma función, y hay que volver a probar todas las ramas.Protocol define el contrato: cualquier clase con un método cobrar(monto) sirve, sin heredar de nada.procesar_pago queda cerrada.Si el código funciona con Producto, tiene que seguir funcionando con cualquier subclase de Producto, sin revisar de qué tipo es.
class Producto:
def descontar_stock(self, cantidad: float):
self.stock -= cantidad
class ProductoPesable(Producto): # respeta el contrato
unidad = "kg"
class RecargaCelular(Producto): # lo rompe
def descontar_stock(self, cantidad: float):
raise NotImplementedError("una recarga no tiene stock")
for item in carrito:
item.producto.descontar_stock(item.cantidad)class Vendible(Protocol):
precio: int
def al_vender(self, cantidad: float) -> None: ...
class Producto:
def al_vender(self, cantidad: float) -> None:
self.stock -= cantidad
class RecargaCelular:
def al_vender(self, cantidad: float) -> None:
operador.recargar(self.numero, self.precio)
for item in carrito:
item.producto.al_vender(item.cantidad) # todos cumplenRecargaCelular hereda una promesa que no puede cumplir. El bucle explota a mitad de la venta.NotImplementedError o un if isinstance(...) para esquivarla.Una interfaz grande obliga a cada clase a escribir métodos falsos. Varias interfaces pequeñas, una por rol, dejan que cada clase implemente solo lo que hace.
class DispositivoCaja(ABC):
@abstractmethod
def imprimir(self, texto: str): ...
@abstractmethod
def escanear(self) -> str: ...
@abstractmethod
def pesar(self) -> float: ...
@abstractmethod
def abrir_cajon(self): ...
class Balanza(DispositivoCaja):
def pesar(self) -> float: return self._leer_gramos() / 1000
def imprimir(self, texto): raise NotImplementedError
def escanear(self): raise NotImplementedError
def abrir_cajon(self): raise NotImplementedErrorclass Impresora(Protocol):
def imprimir(self, texto: str) -> None: ...
class Cajon(Protocol):
def abrir_cajon(self) -> None: ...
class Balanza(Protocol):
def pesar(self) -> float: ...
class ImpresoraTermica: # cumple Impresora y Cajon
def imprimir(self, texto: str) -> None: ...
def abrir_cajon(self) -> None: ... # pulso por el puerto RJ11
class BalanzaDigital: # cumple solo Balanza
def pesar(self) -> float: ...def cobrar(cajon: Cajon).La lógica de negocio no debería crear su propia base de datos. Debería recibir "algo que guarde ventas" y no saber si es SQLite, PostgreSQL o una lista en memoria.
class ServicioVentas:
def __init__(self):
self.repo = RepoSQLite("pos.db") # atado a SQLite
def registrar(self, venta: Venta) -> int:
return self.repo.guardar(venta)class RepositorioVentas(Protocol):
def guardar(self, venta: Venta) -> int: ...
class ServicioVentas:
def __init__(self, repo: RepositorioVentas):
self.repo = repo # recibe la abstracción
def registrar(self, venta: Venta) -> int:
return self.repo.guardar(venta)
# quien arma la aplicación decide la implementación
servicio = ServicioVentas(RepoPostgres(DATABASE_URL))
servicio_test = ServicioVentas(RepoEnMemoria())ServicioVentas.El mismo punto de venta escrito de dos formas. Marca los pedidos que llegan y mira qué archivos hay que abrir y cuántos tests existentes vuelves a poner en riesgo.
Cinco fragmentos de código real. Elige la letra y revisa la explicación.
SOLID resuelve problemas de cambio. Aplicado donde no hay cambios a la vista, solo agrega archivos y saltos al leer.
class IUsuarioService(ABC): ... class UsuarioService(IUsuarioService): ... # la única implementación
Si hay una sola implementación y ningún test la reemplaza, la abstracción es ruido. Créala cuando aparezca la segunda.
class SumadorDeNetos: ... class AplicadorDeIVA: ... class RedondeadorDeTotales: ...
Las tres cambian por el mismo motivo: cómo se calcula el total. Según SRP, van juntas en Venta.total().
class Cliente(ConexionBD): ... # un cliente no "es" una conexión
class Cliente:
def __init__(self, repo: RepoClientes): ... # composición
Heredar solo para reutilizar métodos crea jerarquías que rompen LSP. Prefiere recibir el objeto que necesitas.
# script de 80 líneas que corre una vez al mes class FabricaDeExportadores(ABC): ...
Si no hay una segunda variante a la vista, escribe lo simple. Refactorizar cuando llegue el segundo caso cuesta poco.
from typing import Protocol
from fastapi import APIRouter, Depends
class RepositorioVentas(Protocol):
def guardar(self, venta: Venta) -> int: ...
def get_repo(session: Session = Depends(get_session)) -> RepositorioVentas:
return RepoPostgres(session)
router = APIRouter(prefix="/ventas") # un router por dominio
@router.post("/")
def crear_venta(datos: VentaIn,
repo: RepositorioVentas = Depends(get_repo)):
venta = Venta.desde(datos) # reglas de negocio
return {"id": repo.guardar(venta)}
# en los tests
app.dependency_overrides[get_repo] = lambda: RepoEnMemoria()
APIRouter por dominio (ventas, productos, clientes) y esquemas de Pydantic separados de los modelos de la base de datos.Depends es inyección de dependencias. El endpoint pide un RepositorioVentas y FastAPI decide cuál entregar.app.dependency_overrides reemplazas la base real por una en memoria sin editar el endpoint.Protocol valida la forma (tipado estructural, lo revisa mypy). ABC obliga a heredar y falla al instanciar si falta un método.| Síntoma en tu código | Principio | Qué hacer |
|---|---|---|
| Cambiar el formato de la boleta rompió el cálculo del total | S | Separa la clase según quién pide los cambios. |
Cada variante nueva agrega otro elif en la misma función | O | Define un Protocol y crea una clase por variante. |
Hay subclases que lanzan NotImplementedError o código con isinstance para esquivarlas | L | Revisa qué promete la clase base. Muchas veces conviene composición en vez de herencia. |
| Clases que implementan métodos vacíos solo para cumplir la interfaz | I | Divide la interfaz en roles pequeños. |
| No puedes testear sin levantar la base de datos o una API externa | D | Recibe la dependencia por constructor o con Depends. |
| Un script corto que hace una sola tarea y nadie más va a tocar | — | Mantenlo simple. SOLID no aplica si no hay cambios a la vista. |
SOLID no busca escribir más clases. Busca que un cambio del negocio toque un solo lugar del código.
Una razón para cambiar por clase.
Lo nuevo se agrega en código nuevo.
Las subclases cumplen lo que promete la base.
Interfaces pequeñas, una por rol.
La lógica recibe abstracciones, no las crea.
Ejercicio sugerido: toma un endpoint de un proyecto tuyo que valide datos, guarde en la base y envíe un aviso en la misma función. Sepáralo en tres clases, inyecta el repositorio con Depends y escribe un test que use un repositorio en memoria.