SOLID no es una religión
Hay un momento en casi toda revisión de código en el que alguien suelta “esto viola el principio de responsabilidad única” como si citara las Escrituras. Fin del debate. Nadie pregunta si la abstracción que propone a cambio mejora algo o solo añade tres ficheros y una interfaz que jamás tendrá una segunda implementación.
SOLID son cinco heurísticas que Robert Martin destiló de la experiencia de la industria. Son buenos consejos. No son leyes de la física. Y como todo buen consejo, fuera de contexto se vuelve malo. Estas son mis notas de campo sobre cuándo cada principio te ayuda y cuándo te está cobrando un peaje que no necesitabas pagar.
Responsabilidad única, o el arte de no trocearlo todo
El principio dice que una clase debería tener una sola razón para cambiar. La interpretación de andar por casa —“una clase, una función”— es donde empieza el problema. Acabas con un UserNameValidator, un UserEmailValidator y un UserOrchestrator que solo existe para volver a juntar lo que separaste sin motivo.
La pregunta útil no es “¿esto hace más de una cosa?”. Es “¿quién pide los cambios?”. Si la lógica de validación y la de persistencia las tocan equipos distintos por razones distintas, sepáralas. Si las tocas siempre tú, en el mismo commit, por la misma historia de usuario, juntarlas no es pecado: es localidad.
# Sobreingeniería: tres clases para una regla de negocio que cabe en una.
class PriceCalculator: ...
class DiscountApplier: ...
class TaxAdder: ...
# Suficiente: una sola razón para cambiar (cómo se cobra un pedido).
class Checkout:
def total(self, cart: Cart) -> Money:
subtotal = sum(line.price * line.qty for line in cart.lines)
subtotal = self._apply_discount(subtotal, cart.coupon)
return self._add_tax(subtotal, cart.region)
Cuando esa lógica fiscal empiece a cambiar por su cuenta —porque entra un país nuevo cada trimestre— la extraes. Antes de eso, extraerla es adivinar el futuro, y casi nunca aciertas.
Abierto/cerrado: paga la abstracción cuando la necesites
“Abierto a extensión, cerrado a modificación” suena a que cada if es una deuda y deberías sustituirlo por polimorfismo. En la práctica, el coste de adivinar mal el punto de extensión es mayor que el de tocar un switch el día que aparezca el tercer caso.
La regla que sigo: el primer caso es un if. El segundo, sigue siendo un if. Al tercero ya ves la forma del eje que varía, y ahí sí merece la pena abrir una abstracción —una estrategia, un registro de handlers— porque ahora sabes por dónde va a crecer. Abstraer con un solo caso conocido es diseñar para un futuro imaginario.
Sustitución de Liskov: el único que casi nunca negocio
Si un Cuadrado hereda de Rectángulo y al cambiarle el ancho le cambia solo el alto, has roto a quien esperaba un rectángulo. Este principio es distinto de los demás: cuando lo violas, el código miente. Una subclase que lanza NotImplementedError en un método que la base promete, o que endurece las precondiciones, es una trampa esperando a explotar en producción.
Aquí no hay matices cómodos. Si una herencia no respeta el contrato de su base, el problema no es de estilo: es un bug latente. La salida casi siempre es composición en lugar de herencia. Liskov es el principio que más respeto y el que menos veces relajo.
Segregación de interfaces e inversión de dependencias: con cabeza
La segregación de interfaces —no obligues a nadie a depender de métodos que no usa— es sensata, pero en lenguajes con duck typing como Python pierde fuerza. No necesitas declarar diez Protocol diminutos si nadie va a implementar la mitad. Crea la interfaz cuando tengas dos consumidores reales con necesidades distintas, no por simetría.
La inversión de dependencias es la que más se malinterpreta. “Depende de abstracciones, no de concreciones” se traduce con demasiada frecuencia en envolver cada librería en una interfaz propia por si algún día la cambias. Spoiler: casi nunca cambias de base de datos. Y si lo haces, el adaptador que escribiste a ciegas tres años antes no encaja con la nueva.
# Inversión útil: el caso de uso no sabe de qué proveedor de LLM tira.
class LLMClient(Protocol):
def complete(self, prompt: str) -> str: ...
class Summarizer:
def __init__(self, llm: LLMClient): # inyectas la dependencia
self._llm = llm
def run(self, text: str) -> str:
return self._llm.complete(f"Resume: {text}")
Esto sí lo abstraigo, y por una razón concreta: cambiar de proveedor de LLM es real, lo he hecho, y testear sin pegarle a una API externa es innegociable. La frontera vale la abstracción cuando vas a cruzarla de verdad o cuando necesitas un doble en los tests. Si no se da ninguna de las dos, es decoración.
La regla práctica: mito contra realidad
| El mito SOLID | La realidad en producción |
|---|---|
| Más clases pequeñas siempre es mejor | Más clases es más saltos para entender un flujo; la localidad también es legible |
| Toda condición debería ser polimorfismo | Un if honesto gana a una jerarquía que esconde la lógica en cinco ficheros |
| Abstrae siempre la dependencia externa | Abstrae cuando vas a cambiarla o testearla, no “por si acaso” |
| SOLID = buen diseño | SOLID es una herramienta de diseño; el criterio lo pones tú |
Cómo decido en el momento
Cuando dudo si aplicar un principio, me hago tres preguntas. ¿Este eje varía de verdad, o me lo estoy imaginando? ¿La abstracción reduce el coste de cambiar algo que va a cambiar, o solo añade indirección? ¿El siguiente que lea esto tardará menos o más en entender el flujo?
Si la abstracción no responde “sí” a alguna, la dejo para cuando el código me pida a gritos esa costura. El código duplicado es barato de arreglar cuando aparece el patrón; la abstracción equivocada es cara de desmontar cuando descubres que adivinaste mal.
SOLID merece la pena precisamente porque son heurísticas con criterio detrás. Trátalas como tales —preguntas que te haces, no cajas que tachas— y dejarán de estorbarte. El objetivo nunca fue cumplir cinco letras. Era escribir código que el equipo pueda cambiar sin miedo dentro de seis meses. Si un principio te acerca a eso, aplícalo. Si solo te da puntos en una checklist, déjalo pasar.