Be Software Architecture Wise
Be Wise Apps

Read in English · Lire en français · Auf Deutsch lesen

Sección 3.5 de 3.8 · Intermedio · 26 min de lectura

Los principios SOLID aplicados a los componentes

Videolección

5 min · narrado en inglés, con subtítulos
Solo escuchar (audio)
Leer la transcripción

SOLID es el conjunto de principios de diseño más conocido, y uno de los más discutidos. En esta lección aprenderás qué significa realmente cada uno de los cinco, cómo se aplica cada uno a componentes y servicios, y cuándo hay que tener cuidado con ellos.

Piensa en un grupo de restaurantes bien gestionado. Cada cocina responde ante un solo jefe de cocina, de modo que nunca hay dos jefes dándole órdenes contradictorias. Se añaden platos nuevos a la carta sin reconstruir la cocina. Un chef suplente debe cocinar cada plato exactamente como lo haría el chef habitual, o los comensales lo notan. Cada proveedor recibe solo el formulario de pedido que necesita, no el manual completo de la empresa. Y la sede central fija los estándares, mientras que los proveedores se adaptan a ellos. SOLID son estos cinco hábitos, escritos para el software. Robert C. Martin reunió los principios, y Michael Feathers se dio cuenta de que sus iniciales podían reordenarse para formar SOLID.

La S es el principio de responsabilidad única, el peor entendido de los cinco. No dice que un módulo deba hacer una sola cosa. Dice que un módulo debe ser responsable ante un actor: un grupo de personas que quieren que el sistema cambie de la misma manera. Imagina un servicio de restaurantes que contiene las cartas, las reglas de comisiones y la clasificación en las búsquedas. Los dueños de los restaurantes, finanzas y el equipo de crecimiento lo cambian, cada uno por motivos no relacionados. Divídelo según esos actores. Ten en cuenta que esto no es una regla para hacer pequeños los servicios.

La O es el principio abierto-cerrado, formulado por primera vez por Bertrand Meyer: abierto a la extensión, cerrado a la modificación. Cuando se espera un tipo de variación, la siguiente variante debe ser código nuevo, no otra rama más en el código antiguo. Un servicio de pedidos que publica un evento OrderPlaced está abierto a la extensión. El equipo de fidelización puede añadir un consumidor que otorgue puntos sin que el servicio de pedidos cambie en absoluto. Pero añade puntos de extensión solo para los cambios que realmente llegan. La L es el principio de sustitución de Liskov, de Barbara Liskov. El código que funciona con un tipo debe seguir funcionando con cualquier subtipo, sin saber cuál ha recibido. Los nombres de método correctos no bastan; el comportamiento debe ser compatible. Un sustituto no debe exigir más, prometer menos ni romper los invariantes. Esa es exactamente la prueba de una versión compatible de una API: ningún campo obligatorio nuevo, ningún campo eliminado de la respuesta.

La I es la segregación de interfaces: ningún cliente debe verse obligado a depender de operaciones que no usa. Una interfaz inflada acopla a cada cliente con cada operación. Entre servicios, eso aboga por API específicas para cada cliente y tipos de evento acotados. Una biblioteca común para toda la empresa tiene el mismo problema: una corrección en su código de PDF obliga a lanzar versiones nuevas de servicios que nunca generan PDF. La D es la inversión de dependencias. La política de negocio no debe depender de detalles como las bases de datos. Ambas deben depender de abstracciones. Y el punto que a menudo se pasa por alto es la propiedad, como muestra el siguiente diagrama.

Antes, el componente Orders importa y llama a un repositorio concreto de PostgreSQL del componente de persistencia. Así, un cambio en la base de datos se propaga a las reglas de negocio. Después, el componente Orders es dueño de una interfaz de repositorio de pedidos, escrita en sus propios términos. El componente de persistencia la implementa, así que la flecha del código fuente apunta ahora hacia la política. Si la interfaz se hubiera quedado en el paquete de persistencia, no se habría invertido nada.

Martin añade tres principios para decidir qué clases van juntas en un componente. Equivalencia entre reutilización y lanzamiento: la unidad de reutilización es la unidad de lanzamiento, así que las clases de un componente deben tener sentido lanzadas juntas. Cierre común: reúne las clases que cambian por los mismos motivos y en los mismos momentos. Es la responsabilidad única aplicada a los componentes. Reutilización común: las clases que se usan juntas van juntas, porque depender de un componente significa depender de todo él. Tiran en direcciones opuestas, y cada lado nombra el precio de renunciar al vértice opuesto. Al principio, los equipos favorecen el cierre común. A medida que otros pasan a depender de sus partes, el equilibrio se desplaza hacia la reutilización.

Vamos a aplicarlos. El paquete de una tienda en línea, shop core, lo usan cinco servicios. Contiene precios, impuestos, validación de direcciones, facturas en PDF y una función auxiliar de base de datos, y tuvo cuarenta lanzamientos el último trimestre. Cuatro equipos distintos piden sus cambios. Cuatro actores significan cuatro motivos independientes para cambiar. El servicio del almacén solo usa la validación de direcciones y, sin embargo, cada uno de esos cuarenta lanzamientos lo obligó a actualizarse y a volver a probarse. Y la función auxiliar de base de datos importa los propios modelos de los servicios, así que un paquete estable y muy usado depende de código volátil. La flecha apunta en la dirección equivocada. Dividirlo significa que un cambio en un tipo impositivo ahora lanza un solo paquete y solo afecta a los servicios que calculan impuestos. El costo es tener más paquetes que versionar. Observa que los principios no dictaron la respuesta. Dieron un vocabulario para explicar por qué causaba problemas. Aplicado de forma dogmática, SOLID produce una interfaz para cada clase y puntos de extensión que nadie usa. Úsalo para diagnosticar problemas reales.

La sección completa muestra cada principio en Python y expone las críticas en detalle. A continuación, en la Sección tres punto seis, conocemos la connascence, una medida más fina del acoplamiento. Nos vemos allí.

Lo que necesitas saber

Cinco principios con historia

SOLID es un acrónimo mnemotécnico de cinco principios de diseño que Robert C. Martin reunió y perfeccionó a finales de la década de 1990, de forma más completa en su artículo de 2000 Design Principles and Design Patterns. Michael Feathers se dio cuenta de que sus iniciales podían reordenarse para formar la palabra SOLID, y el acrónimo se quedó. Los principios se escribieron para las clases de la orientación a objetos, pero el libro posterior de Martin, Clean Architecture (2017), sostiene que cada uno se aplica también a los componentes, y los profesionales los aplican habitualmente también a servicios y API.

Por eso tienen cabida en un curso de arquitectura. Cada principio es en realidad una afirmación sobre dónde debe recaer el cambio y hacia dónde deben apuntar las dependencias, las dos cuestiones de las secciones anteriores sobre acoplamiento y cohesión y sobre gestión de dependencias. Esta sección trata cada principio por turno, lo muestra en unas pocas líneas de Python y después muestra lo que significa cuando la "clase" es un componente desplegable o un servicio que pertenece a otro equipo. Termina con los principios de cohesión a nivel de componente y con las críticas que recibe SOLID, que son sustanciales y vale la pena conocer.

S: responsabilidad única significa un actor

El principio de responsabilidad única es el más citado y el peor entendido de los cinco. No dice que un módulo deba hacer una sola cosa; esa es una buena regla para las funciones, pero es una regla distinta. La formulación del propio Martin evolucionó de decir que una clase debe tener un solo motivo para cambiar a decir, en Clean Architecture, que un módulo debe ser responsable ante un actor, y solo uno. Un actor es un grupo de personas (usuarios o partes interesadas) que quieren que el sistema cambie de la misma manera. El principio trata de las personas y de la dirección del cambio futuro.

La ilustración clásica es una clase Employee con tres métodos: calculate_pay, especificado por finanzas; report_hours, especificado por operaciones; y save, especificado por los administradores de bases de datos. Si el cálculo de la nómina y el informe de horas comparten una función auxiliar para calcular las horas ordinarias, un cambio que pide finanzas altera en silencio el informe del que depende operaciones. Nadie fue descuidado; el código simplemente mezcló las inquietudes de dos actores, de modo que un cambio solicitado por uno podía perjudicar al otro.

from dataclasses import dataclass

@dataclass
class EmployeeRecord:          # shared data, no behaviour
    id: str
    hourly_rate: float
    hours: list[float]

class PayCalculator:           # actor: finance
    def pay(self, e: EmployeeRecord) -> float:
        regular = sum(min(h, 8) for h in e.hours)
        overtime = sum(max(h - 8, 0) for h in e.hours)
        return (regular + 1.5 * overtime) * e.hourly_rate

class HoursReporter:           # actor: operations
    def weekly_report(self, e: EmployeeRecord) -> str:
        return f"{e.id}: {sum(e.hours):.1f} hours"

class EmployeeRepository:      # actor: database administrators
    def save(self, e: EmployeeRecord) -> None: ...
Separar por actor: cada clase cambia solo cuando lo piden sus propias partes interesadas, y los datos compartidos siguen siendo un simple registro.

A escala de componentes y de servicios, el principio se convierte en una prueba para los límites: ¿de quién son las solicitudes que cambian este componente? En una plataforma de reparto de comida, un único servicio "Restaurante" que contiene las cartas (que cambian a solicitud de los dueños de los restaurantes), las reglas de comisiones (que cambia el equipo de finanzas) y la clasificación en las búsquedas (que cambia el equipo de crecimiento) se modificará por tres motivos no relacionados, por tres grupos de personas, con tres ritmos de lanzamiento. Cada lanzamiento pone en riesgo los otros dos asuntos, y el equipo propietario se convierte en una cola de espera. Separar por actores, por ejemplo en un servicio de cartas, un componente de comisiones dentro del dominio de finanzas y un servicio de clasificación, alinea cada límite con una sola fuente de cambio. Esta es también la idea que hay detrás de alinear los servicios con las capacidades de negocio y, en el diseño guiado por el dominio, con los contextos delimitados (que se tratan en el Capítulo 5).

Observa que el principio no dice "haz que los servicios sean pequeños". Un servicio grande que sirve bien a un único actor está bien; un servicio diminuto que dos departamentos no dejan de cambiar en direcciones contradictorias no lo está.

O: abierto a la extensión, cerrado a la modificación

El principio abierto-cerrado lo formuló por primera vez Bertrand Meyer en Object-Oriented Software Construction (1988): un módulo debe estar abierto a la extensión (puedes añadir un comportamiento nuevo) pero cerrado a la modificación (su código fuente existente y probado no necesita editarse para ello). Meyer pensaba en la herencia; Martin lo reinterpretó en términos de interfaces y polimorfismo. La forma práctica es: cuando se espera un tipo de variación, dale un punto de extensión, para que la siguiente variante sea código nuevo y no otra rama más en el código antiguo.

from typing import Protocol

class Carrier(Protocol):
    def quote(self, weight_kg: float) -> int: ...   # price in pence

class RoyalMail:
    def quote(self, weight_kg: float) -> int:
        return 350 + int(weight_kg * 120)

class Courier:
    def quote(self, weight_kg: float) -> int:
        return 600 + int(weight_kg * 80)

CARRIERS: dict[str, Carrier] = {"royal_mail": RoyalMail(), "courier": Courier()}

def cheapest(weight_kg: float) -> tuple[str, int]:
    return min(((name, c.quote(weight_kg)) for name, c in CARRIERS.items()),
               key=lambda pair: pair[1])
Añadir un transportista significa registrar una clase nueva; el código que calcula la cotización nunca cambia.

A escala de arquitectura, la misma idea aparece como puntos de extensión que otros equipos pueden usar sin pedirte que cambies tu código. Un servicio de pedidos que publica un evento OrderPlaced está abierto a la extensión: el equipo de fidelización puede añadir un consumidor nuevo que otorgue puntos, y el servicio de pedidos no se modifica, ni se vuelve a probar, ni se vuelve a desplegar. Una arquitectura de plug-ins (el estilo microkernel) hace la misma promesa para las funcionalidades. En las API, el principio se traduce en una evolución aditiva: campos opcionales nuevos, endpoints nuevos y tipos de evento nuevos amplían un contrato sin modificar aquello de lo que dependen los clientes existentes.

La trampa es la predicción. Un punto de extensión solo tiene valor para los tipos de cambio que realmente llegan. Un registro de transportistas vale la pena si la tienda añade transportistas cada pocos meses; un "motor de reglas de precios" genérico construido porque los precios podrían variar algún día es maquinaria especulativa. Los profesionales añaden el punto de extensión la segunda o la tercera vez que aparece una variación, no antes.

L: sustitución de Liskov y contratos de comportamiento

El principio de sustitución de Liskov procede de la conferencia magistral de Barbara Liskov Data Abstraction and Hierarchy en la conferencia OOPSLA de 1987, y Liskov y Jeannette Wing lo precisaron en A Behavioral Notion of Subtyping (ACM Transactions on Programming Languages and Systems, 1994). Dicho de manera informal: si un código funciona con objetos del tipo T, debe seguir funcionando cuando recibe un objeto de cualquier subtipo S, sin saber cuál ha recibido. Tener los nombres de método correctos no basta; el comportamiento debe ser compatible.

Las reglas del subtipado conductual conectan directamente con el diseño por contrato (Sección 3.3). Un subtipo no puede reforzar las precondiciones (exigir más al llamante), no puede debilitar las postcondiciones (prometer menos a cambio) y debe preservar los invariantes (los hechos que siempre se cumplen para el tipo). Liskov y Wing añadieron una restricción de historia: un subtipo no debe permitir cambios de estado que el supertipo prohíbe, y por eso un punto mutable no puede hacerse pasar por uno inmutable.

class Discount:
    """Postcondition: 0 <= result <= total."""
    def apply(self, total: int) -> int:
        return total

class PercentOff(Discount):
    def __init__(self, pct: int):
        self.pct = pct
    def apply(self, total: int) -> int:
        return total - total * self.pct // 100      # keeps the contract

class FixedVoucher(Discount):
    def __init__(self, pence: int):
        self.pence = pence
    def apply(self, total: int) -> int:
        return total - self.pence                    # can go below zero!

# FixedVoucher(500).apply(300) == -200: the checkout now issues a refund.
# Fix: return max(total - self.pence, 0), honouring the base postcondition.
El contrato base dice que un descuento nunca hace que el total sea negativo. Un subtipo que lo incumple hace fallar a los llamantes que confiaban en el contrato.

El síntoma de una violación del LSP es la comprobación de tipos en los llamantes: if isinstance(d, FixedVoucher): .... Cada una de esas comprobaciones significa que una abstracción ya no es fiable, y el conocimiento de sus excepciones se extiende por todo el código.

A escala de servicios, la sustituibilidad es la pregunta que hay detrás de todo cambio compatible de una API. Si la versión 2.3 de una API de pagos debe sustituir a la 2.2 sin que los clientes lo noten, es un subtipo conductual de la 2.2 y se aplican las mismas reglas. No debe reforzar las precondiciones: ningún campo obligatorio nuevo en la solicitud, ninguna validación más estricta de los campos existentes, ningún límite de tasa más bajo. No debe debilitar las postcondiciones: ningún campo eliminado de la respuesta, ningún tiempo de respuesta garantizado más lento, ninguna pérdida de idempotencia. Debe preservar los invariantes: un importe sigue expresado en unidades menores, un identificador de pedido sigue sin cambiar nunca. Añadir un valor nuevo a una enumeración en una respuesta es un caso sutil: amplía lo que el cliente puede recibir, lo que rompe a los clientes que daban por hecho que el conjunto anterior estaba completo. El mismo razonamiento se aplica cuando una implementación de un servicio sustituye a otra detrás de un balanceador de carga, o cuando cambias el adaptador de un proveedor de pagos por otro. Las pruebas de contrato dirigidas por el consumidor son, en la práctica, comprobaciones de Liskov automatizadas, y la ley de Hyrum (Sección 3.3) recuerda que los clientes pueden depender de un comportamiento que nunca prometiste.

I: segregación de interfaces y API específicas para cada cliente

El principio de segregación de interfaces dice que ningún cliente debe verse obligado a depender de operaciones que no usa. Una interfaz inflada acopla a cada cliente con cada operación: cuando cambia una operación, todos los clientes se ven afectados, como mínimo porque tienen que recompilar, volver a probar o volver a desplegar, incluso los que nunca la llaman. En los lenguajes compilados ese acoplamiento es literal (un cambio en un archivo de cabecera o en un JAR compartido obliga a recompilar todo lo que depende de él); en Python se manifiesta en dobles de prueba que deben implementar veinte métodos para probar código que llama a dos.

from typing import Protocol

class OrderReader(Protocol):
    def get(self, order_id: str) -> dict: ...
    def list_for_customer(self, customer_id: str) -> list[dict]: ...

class OrderWriter(Protocol):
    def save(self, order: dict) -> None: ...
    def cancel(self, order_id: str) -> None: ...

def monthly_statement(orders: OrderReader, customer_id: str) -> int:
    return sum(o["total"] for o in orders.list_for_customer(customer_id))

# The reporting job cannot cancel an order, and its tests
# need a fake with two read methods, not the whole repository.
Cada cliente depende solo del rol que necesita; una misma clase puede seguir implementando ambos roles.

A escala de servicios, la segregación de interfaces es el argumento a favor de las API específicas para cada cliente. Una única API de propósito general que da servicio a la aplicación móvil, a la tienda web, al sistema del almacén y a las integraciones de socios tiende a convertirse en un diseño de mínimo común denominador: cada cliente recibe campos que ignora, y cada cambio tiene que negociarse con todos ellos. El patrón backend for frontend (bautizado por Phil Calçado en SoundCloud y descrito por Sam Newman) da a cada tipo de cliente su propia capa de API delgada, propiedad del equipo que construye ese cliente, que compone llamadas a los servicios subyacentes. GraphQL aborda el mismo problema de otro modo, permitiendo que cada cliente seleccione exactamente los campos que necesita. En el caso de los eventos, sugiere publicar varios tipos de evento acotados (OrderPlaced, OrderShipped) en lugar de un único evento OrderChanged con todos los campos, que obliga a cada consumidor a analizarlo y filtrarlo todo.

La versión arquitectónica también se aplica a las bibliotecas compartidas. Una biblioteca common de toda la empresa que contiene logging, manejo de importes monetarios, generación de PDF y clientes HTTP acopla a cada servicio con cada una de sus partes: una corrección de seguridad en el código de PDF obliga a lanzar una versión nueva de servicios que nunca generan PDF. Dividirla en bibliotecas pequeñas que los clientes adoptan de forma independiente es segregación de interfaces a nivel de paquetes, y anticipa el principio de reutilización común que se ve más abajo.

D: inversión de dependencias y quién es el dueño de la interfaz

El principio de inversión de dependencias tiene dos partes: los módulos de alto nivel (la política de negocio) no deben depender de los módulos de bajo nivel (detalles como bases de datos, intermediarios de mensajes y frameworks web); ambos deben depender de abstracciones. Y las abstracciones no deben depender de los detalles. La palabra "inversión" se refiere a la dirección de la dependencia del código fuente: en un diseño ingenuo, la política llama al detalle y, por tanto, lo importa; tras la inversión, el detalle importa una interfaz que define la política, de modo que la flecha del código fuente apunta en sentido contrario al flujo de control.

El punto crucial, y que a menudo se pasa por alto, es la propiedad. La interfaz pertenece al componente de alto nivel y se expresa en sus términos (OrderRepository.save(order), no PostgresClient.execute(sql)). Si la interfaz vive en el paquete de persistencia, has añadido una abstracción pero no has invertido nada: la política sigue dependiendo del paquete de persistencia.

# orders/ports.py  (high-level policy owns this)
from typing import Protocol
class PaymentPort(Protocol):
    def charge(self, order_id: str, pence: int) -> str: ...  # returns payment id

# orders/service.py
class Checkout:
    def __init__(self, payments: PaymentPort):
        self.payments = payments
    def pay(self, order_id: str, pence: int) -> str:
        return self.payments.charge(order_id, pence)

# infrastructure/stripe_adapter.py  (detail depends on policy)
from orders.ports import PaymentPort
class StripeAdapter(PaymentPort):
    def charge(self, order_id: str, pence: int) -> str:
        ...  # call the provider's API, map its errors to ours
El paquete de pedidos define el puerto que necesita; la infraestructura lo implementa y se conecta al arrancar.

Invertir una dependencia a través del límite de un componente

Inversión de dependencias entre un componente Orders y un componente Persistence, antes y despuésAntes: política depende de detalleComponente PedidosOrderServiceimporta y llama ala clase concretaComponente PersistenciaPostgresOrderRepositoryUn cambio de BD se propaga a OrdersAhora: detalle depende de políticaComponente Orders (dueño de la interfaz)OrderService«interfaz»OrderRepositoryimplementaComponente PersistenciaPostgresOrderRepositoryLa flecha ya apunta a la política
Llevar la interfaz al componente Orders invierte la dependencia del código fuente, de modo que la persistencia puede cambiar sin tocar la política de negocio.

La inversión de dependencias no es lo mismo que la inyección de dependencias, aunque suelen ir juntas. La inyección es una técnica para proporcionar a un módulo sus colaboradores desde fuera (el argumento del constructor payments de más arriba); la inversión es una decisión sobre hacia dónde apuntan las dependencias del código fuente. Puedes inyectar una clase concreta sin invertir nada.

A escala de arquitectura, la inversión de dependencias es la regla que organiza las arquitecturas hexagonal, de cebolla y limpia: el dominio está en el centro y define puertos; los adaptadores para bases de datos, colas e interfaces de usuario están fuera y dependen hacia dentro. Entre servicios, la misma pregunta se formula como "¿quién es el dueño del contrato?". Si un servicio central de facturación define un esquema BillingEvent y cada equipo de producto debe adaptarse a él, la facturación es la política estable y los productos dependen de ella, lo que a menudo es correcto. Si, en cambio, la facturación lee las tablas internas de cada producto, la dependencia apunta de lo estable a lo volátil, y cada cambio en el esquema de un producto rompe la facturación. La Sección 3.4 mostró cómo cuantificar esto con el principio de dependencias estables.

Los principios de cohesión de componentes: REP, CCP y CRP

SOLID te dice cómo dar forma a las clases y a sus dependencias. Martin lo combina con tres principios para decidir qué clases van juntas en un mismo componente, donde un componente es una unidad de lanzamiento y despliegue, como un JAR, un paquete de Python o un ensamblado de .NET.

El principio de equivalencia entre reutilización y lanzamiento (REP) dice que la unidad de reutilización es la unidad de lanzamiento: si otros van a reutilizar un componente, este necesita números de versión, notas de la versión y un tema coherente, así que las clases que contiene deben tener sentido lanzadas juntas. El principio de cierre común (CCP) es el principio de responsabilidad única para los componentes: reúne en un componente las clases que cambian por los mismos motivos y en los mismos momentos, para que un cambio típico toque un solo componente y solo haya que lanzar ese. El principio de reutilización común (CRP) es el principio de segregación de interfaces para los componentes: las clases que se usan juntas van juntas, y las que no, no deben empaquetarse juntas, porque depender de un componente significa depender de todo él.

Los tres tiran en direcciones opuestas. REP y CCP son inclusivos (hacen los componentes más grandes); CRP es exclusivo (los hace más pequeños). Martin los dibuja como un triángulo en el que cada lado nombra el precio de abandonar el principio del vértice opuesto.

El triángulo de tensiones

Triángulo de tensiones entre principios: equivalencia reutilización/lanzamiento, cierre común y reutilización comúnREPEquivalencia reutil./lanzam.CCPCierre comúnCRPReutil. comúnMuchos lanzamientossuperfluosCambianmuchos componentesDifícil reutil.Cada lado indica el costode renunciar al principiodel vértice opuesto
Concentrarse en dos principios de cohesión cualesquiera conlleva el costo escrito en el lado que los une; los arquitectos eligen una posición, y la posición se desplaza a medida que el proyecto madura.

La observación de Martin es que la posición de un proyecto en el triángulo debe desplazarse con el tiempo. Al principio, lo que más importa es la facilidad de desarrollo, así que los equipos favorecen el CCP: agrupar por cambio y aceptar que los componentes son incómodos de reutilizar. A medida que un sistema madura y otros equipos empiezan a depender de sus partes, el equilibrio se desplaza hacia el REP y el CRP. La misma tensión aparece entre servicios. Fusionar dos servicios que siempre cambian juntos (CCP) reduce los lanzamientos coordinados, pero hace que el resultado sea menos reutilizable; dividir un servicio para que los consumidores dependan solo de lo que usan (CRP) aumenta el número de desplegables a los que afecta un único cambio de negocio.

Ejemplo práctico

Ejemplo práctico: revisar un componente compartido en una tienda en línea

Una tienda en línea tiene un paquete shop-core que usan cinco servicios. Contiene reglas de precios, cálculo de impuestos, validación de direcciones, generación de facturas en PDF y una función auxiliar de base de datos. En el último trimestre tuvo 40 lanzamientos. Aplica los principios.

Actores (SRP, CCP)Los precios los cambia el equipo comercial; los impuestos, finanzas cuando cambian los tipos; el diseño del PDF, marketing; la función auxiliar de base de datos, el equipo de plataforma. Cuatro actores significan cuatro motivos independientes para cambiar, así que el paquete no tiene cierre común.
Usuarios (ISP, CRP)El servicio del almacén solo usa la validación de direcciones y, sin embargo, cada uno de los 40 lanzamientos lo obligó a actualizarse y a volver a probarse. Según el CRP, aproximadamente 40 lanzamientos menos los pocos que afectaron a las direcciones fueron innecesarios para él.
Lanzamientos (REP)Las notas de la versión no tienen un tema coherente, así que los consumidores no pueden juzgar si un lanzamiento les afecta.
Dependencias (DIP, SDP)La función auxiliar de base de datos importa los modelos ORM de los servicios, así que un paquete estable y muy usado depende de código volátil de los servicios: la flecha apunta en la dirección equivocada.
División propuestapricing (propiedad del dominio comercial), tax (finanzas), address, invoice-pdf, y la función auxiliar de base de datos trasladada a cada servicio o a una biblioteca de plataforma sin importaciones del dominio.
Efecto esperadoCada servicio depende solo de los paquetes que usa. Un cambio en un tipo impositivo lanza un solo paquete y solo afecta a los servicios que calculan impuestos.

Los principios no dictaron la respuesta; dieron un vocabulario para explicar por qué el paquete actual causa problemas y qué cuesta cada opción.

La división aumenta el número de paquetes que hay que versionar y publicar: el lado "cambian muchos componentes" del triángulo. Es aceptable aquí porque los cambios son, en su mayoría, independientes.

Críticas y límites

SOLID se enseña mucho y se critica mucho, y ambas reacciones tienen su parte de razón. Las críticas más serias son estas.

Los principios son lo bastante vagos como para justificar casi cualquier cosa. Dan North ha defendido esta idea en charlas de conferencias y en su ensayo de 2022 que presenta CUPID (composable, Unix philosophy, predictable, idiomatic, domain-based). Sostiene que "un solo motivo para cambiar" es arbitrario, ya que de casi cualquier código puede decirse que cambia por muchos motivos, y que aplicarlo con entusiasmo crea separaciones artificiales entre código que en realidad cambia junto, como el contenido de un informe y su formato. Propone propiedades a las que aspirar en lugar de reglas que obedecer. Martin respondió a esta línea de críticas en una publicación de 2020 sobre la vigencia de SOLID, en la que sostiene que los principios siguen siendo válidos porque los problemas de fondo de la gestión de dependencias no han cambiado, y que decir a la gente que "simplemente lo mantenga simple" sin ninguna orientación produce un desastre.

La aplicación dogmática produce un exceso de abstracción. Una interfaz para cada clase, una fábrica para cada interfaz y un punto de extensión para cada variación concebible conducen a un código en el que seguir un solo comportamiento obliga a abrir diez archivos. La distinción de John Ousterhout entre módulos profundos y superficiales (Sección 3.3) es un contrapeso útil: una abstracción debe ocultar mucho detrás de una interfaz pequeña, y muchas clases inspiradas en SOLID son superficiales. Los puntos de extensión especulativos violan YAGNI (Sección 3.8) y tienen un costo real.

Los principios se escribieron para un solo paradigma. En los lenguajes con funciones de primera clase y tipado estructural, muchos patrones SOLID se reducen a pasar una función o a confiar en el duck typing. Las ideas de fondo (separar por actor, depender de contratos estables, mantener pequeñas las interfaces) siguen siendo válidas; la ceremonia, a menudo, no.

La visión profesional equilibrada es tratar SOLID como un vocabulario de diagnóstico: cuando un diseño causa problemas, los principios ayudan a explicar por qué y sugieren una dirección. No son una lista de comprobación que toda clase deba cumplir, y "viola el OCP" no es por sí solo un argumento; "cada transportista nuevo ha obligado a editar esta función y ha causado dos incidentes" sí lo es.

Guía de decisión

Guía de decisión: ¿debo añadir una abstracción aquí?

Úsala cuando un principio parezca pedir una interfaz nueva, un punto de extensión o una división.

  1. ¿Este tipo de variación ha ocurrido realmente al menos dos veces, o es segura (por ejemplo, se ha firmado un contrato con un segundo proveedor)?
    SíProbablemente esté justificado un punto de extensión; continúa.
    NoMantén el código concreto, mantenlo fácil de refactorizar y espera a tener pruebas reales (YAGNI).
  2. ¿Las partes que quieres separar las cambian actores distintos o con calendarios distintos?
    SíSepáralas (SRP, CCP); el límite reducirá la coordinación.
    NoMantenlas juntas; separarlas añadiría costuras que cada cambio tendría que cruzar.
  3. ¿Una política de alto nivel importa actualmente un detalle volátil, como una base de datos, el SDK de un proveedor o un framework?
    SíDefine una interfaz que pertenezca al lado de la política e impleméntala en el detalle (DIP).
    NoNo hace falta ninguna inversión; una dependencia directa de un módulo estable está bien.
  4. ¿La interfaz nueva ocultará una complejidad significativa detrás de unas pocas operaciones?
    SíEs un módulo profundo; vale la pena tenerlo.
    NoEs un simple intermediario superficial; replantéatelo o fusiónalo con quien lo llama.

SOLID en tres escalas

PrincipioEscala de claseEscala de componente o de servicio
Responsabilidad únicaUn actor por claseServicio o componente alineado con una capacidad de negocio y un equipo propietario
Abierto-cerradoComportamiento nuevo mediante nuevas implementaciones de una interfazEventos, plug-ins y cambios aditivos en la API que permiten a otros extender sin modificarte
Sustitución de LiskovLas subclases respetan el contrato de la clase baseLas nuevas versiones de la API y las implementaciones de sustitución respetan el contrato anterior: ninguna entrada obligatoria nueva, ninguna salida eliminada
Segregación de interfacesInterfaces de rol pequeñasAPI específicas para cada cliente (backend for frontend), tipos de evento acotados, bibliotecas compartidas pequeñas
Inversión de dependenciasDepender de abstracciones que te pertenecenEl dominio define puertos; los adaptadores y la infraestructura dependen hacia dentro; los propietarios estables definen los contratos

Comprueba lo que has entendido

Una clase da formato a una factura y además calcula sus impuestos. ¿Viola eso automáticamente el principio de responsabilidad única?

No automáticamente. Lo viola si el formato y las reglas fiscales los cambian actores distintos (por ejemplo, marketing y finanzas), porque entonces los cambios solicitados por uno pueden romper lo del otro; si un mismo grupo es responsable de ambos, la clase puede estar bien.

¿Por qué añadir un campo obligatorio nuevo a una solicitud de una API rompe la sustituibilidad?

Refuerza la precondición: los llamantes que cumplían el contrato anterior ahora fallan, así que la versión nueva ya no puede ocupar el lugar de la anterior.

¿Cuál es la diferencia entre la inversión de dependencias y la inyección de dependencias?

La inversión trata de la dirección de las dependencias del código fuente, con el lado de la política como dueño de la abstracción; la inyección es una técnica para proporcionar colaboradores desde fuera. Puedes inyectar una clase concreta sin invertir nada.

Un equipo agrupa las clases estrictamente según lo que cambia junto, y otros equipos se quejan de que es imposible reutilizar los componentes. ¿En qué lado del triángulo de tensiones están?

En el lado CCP-CRP, con la etiqueta difícil de reutilizar: han descuidado el principio de equivalencia entre reutilización y lanzamiento.

Siguiente · 3.6ConnascenceLa connascence de Meilir Page-Jones pone nombre a los acuerdos ocultos entre las partes de un sistema, los clasifica por fuerza y los valora por localidad y grado, desde los parámetros posicionales hasta la temporización entre servicios.Continuar →

Fuentes y lecturas recomendadas

¿Tienes una pregunta sobre esta sección? ArchitectBot, el asistente de estudio con IA del curso, responde primero a partir del curso.

Pregunta: “Nuestra biblioteca compartida 'common' la usan doce microservicios y cada lanzamiento los obliga a todos a actualizarse. ¿Cómo deberíamos dividirla y qué principios deberían guiar la división?”