Saltar al contenido principal

La dirección de una dependencia importa más que su existencia

7 min de lectura

En una revisión de código, es fácil fijarse en cuántas dependencias tiene un módulo: cuántos imports aparecen, cuántas clases recibe por el constructor o cuántas capas hay que atravesar para seguir una operación. Pero contar dependencias no nos dice demasiado sobre lo difícil que será cambiar ese código.

Dos módulos pueden estar conectados por una sola dependencia y estar fuertemente acoplados. Otros pueden tener varias dependencias y, aun así, poder cambiarse de forma independiente. La diferencia no está tanto en cuántas dependencias existen, sino en hacia dónde apuntan.

Porque una dependencia no solo conecta dos bloques de código. También determina cuál de las dos queda expuesta cuando la otra cambia.

La pregunta no es cuántas dependencias hay, sino hacia dónde apuntan

Imagina que un módulo depende de otro. El primero utiliza sus funciones o asume que tiene una determinada interfaz. Si el segundo cambia, el primero puede dejar de funcionar. En cambio, el segundo tiene bastante más libertad: puede cambiar cómo está implementado por dentro siempre que siga ofreciendo lo que el primero necesita.

Por eso, una dependencia siempre tiene una dirección. Uno de los dos lados queda expuesto a los cambios y el otro queda protegido.

El problema es que, cuando no prestamos atención al diseño, las dependencias suelen aparecer siguiendo el flujo de control. Una función llama a otra y, de forma natural, pensamos: "ok, esta función depende de aquella".

Pero el flujo de control y la dirección de las dependencias no tienen por qué coincidir:

  • El flujo de control nos dice qué llama a qué.
  • La dirección de las dependencias debería ayudarnos a decidir qué queremos que pueda cambiar sin romper el resto.

Y esta diferencia es importante. Diseñar bien las dependencias no consiste en conseguir que haya pocas, sino en conseguir que estén orientadas de forma que los cambios importantes queden aislados y las partes que queremos proteger no dependan directamente de los detalles que pueden cambiar.

El coste de una mala dirección de dependencias

Imagina un servicio de pedidos que, al terminar un checkout, envía al cliente un recibo por email. La forma más obvia de escribirlo es que el propio servicio que completa el pedido utilice directamente el SDK del proveedor de email que ya tenemos instalado:

class OrderService {
  constructor(
    private db: Database,
    private sendgrid: SendGridClient
  ) {}

  async completeOrder(orderId: string) {
    const order = await this.db.getOrder(orderId);
    order.status = "completed";
    await this.db.save(order);

    await this.sendgrid.send({
      to: order.customerEmail,
      template: "order-confirmation",
      data: { orderId: order.id, total: order.total },
    });
  }
}

No hay nada especialmente malo en este código. Es sencillo, funciona y probablemente seguirá funcionando durante mucho tiempo. El problema aparece cuando cambia algo que está fuera de la responsabilidad del pedido. Marketing decide cambiar de proveedor de email. O el equipo técnico decide que los emails transaccionales deben enviarse mediante una cola en lugar de hacerlo de forma síncrona. Ahora tenemos que modificar OrderService.

Y eso es lo interesante: no ha cambiado la forma de completar un pedido. El pedido sigue teniendo que guardarse como completado y el cliente sigue teniendo que recibir una confirmación. Lo que ha cambiado es cómo enviamos esa confirmación. Sin embargo, OrderService conoce directamente a SendGridClient, así que el cambio de infraestructura acaba entrando en nuestro código de negocio.

La dependencia es sencilla:

OrderService ──────> SendGrid

OrderService depende de SendGrid porque conoce su API y la utiliza directamente. Por tanto, cualquier cambio en esa integración puede obligarnos a modificar OrderService.

Ese es el coste de una mala dirección de dependencias: los detalles externos terminan teniendo capacidad para imponer cambios sobre el código que contiene las reglas del negocio. Y cuanto más código dependa de esos detalles, más caro resulta cambiar de proveedor, introducir una cola o modificar la infraestructura.

La cuestión, por tanto, no es evitar todas las dependencias. Es conseguir que las dependencias apunten de forma que los detalles que pueden cambiar no dicten cómo debe escribirse el código que queremos proteger.

Hay otro coste, menos técnico pero igual de importante: el coste para las personas que tienen que trabajar con ese código.

Un archivo que mezcla las reglas de negocio con los detalles de un proveedor concreto se vuelve más difícil de entender y de modificar. Si alguien quiere cambiar cuándo se considera completado un pedido, también tiene que entender cómo funciona la API de SendGrid, aunque ese conocimiento no tenga nada que ver con el cambio que quiere hacer.

Con el tiempo, ese tipo de archivos empieza a dar respeto. La gente evita tocarlos porque sabe que cualquier cambio puede tener consecuencias inesperadas. Y así es como aparecen los archivos que todo el mundo conoce, pero que nadie quiere modificar.

Cambia la flecha, no el comportamiento

Para arreglar esto no hace falta que cambie nada en tiempo de ejecución. Lo que tiene que cambiar es quién llama a quién. OrderService debería saber que necesita avisar a alguien cuando un pedido se completa, pero no debería saber ni importarle que ese aviso, ahora mismo, salga por SendGrid.

interface OrderNotifier {
  notifyCompletion(order: Order): Promise<void>;
}

class OrderService {
  constructor(private db: Database, private notifier: OrderNotifier) {}

  async completeOrder(orderId: string) {
    const order = await this.db.getOrder(orderId);
    order.status = "completed";
    await this.db.save(order);
    await this.notifier.notifyCompletion(order);
  }
}

class SendGridOrderNotifier implements OrderNotifier {
  constructor(private sendgrid: SendGridClient) {}

  async notifyCompletion(order: Order): Promise<void> {
    await this.sendgrid.send({
      to: order.customerEmail,
      template: "order-confirmation",
      data: { orderId: order.id, total: order.total },
    });
  }
}

OrderService sigue acabando llamando a SendGrid, indirectamente, cada vez que un pedido se completa: el comportamiento en tiempo de ejecución no se ha movido ni un milímetro. Lo que se ha movido es la costura1: OrderService ahora depende de una interfaz cuya forma controla él mismo, y SendGridOrderNotifier depende de esa misma interfaz para saber qué se espera de él. Ninguno depende directamente del otro. Cambia de proveedor, y el único archivo que cambia es la nueva implementación, la regla de negocio que decide que un pedido está completo nunca se entera de que hubo un cambio de proveedor.

Esto tiene, además, una ventaja inmediata en los tests. Antes, para comprobar que completeOrder guarda el pedido como completado, había que lidiar con SendGrid de alguna manera: mockear el SDK entero, apuntar a un entorno de pruebas del proveedor, o resignarse a que el test mande un email real cada vez que se ejecuta. Con la interfaz de por medio, un test puede pasarle a OrderService un OrderNotifier que se limita a anotar que lo han llamado, sin tocar la red ni depender de que SendGrid esté disponible ese día. La regla de negocio se testea sola, separada de si el proveedor de turno funciona.

No hace falta invertir todo

Es tentador, en cuanto ves esto funcionar, empezar a poner una interfaz delante de cada dependencia del código. No lo hagas. Si OrderService llama a una función interna de dominio, escrita por el mismo equipo, que cambia por las mismas razones que OrderService, invertir esa dependencia no protege nada: solo añade una interfaz que mantener y un archivo más que leer para entender qué está pasando.

La pregunta que merece la pena hacerse antes de invertir una dependencia no es "¿esto es una dependencia?", sino "¿este detalle puede cambiar por un motivo que no tiene nada que ver con la regla de negocio?". SendGrid puede cambiar porque marketing decide otra cosa, sin que a OrderService le importe. Un colaborador interno de dominio normalmente cambia junto con el propio OrderService, porque forma parte de la misma decisión de negocio.

La clave es aplicar esta técnica allí donde realmente nos ayude a separar motivos de cambio. Si la aplicamos en todas partes, acabamos añadiendo abstracciones y complejidad sin obtener nada a cambio. Y es entonces cuando una buena práctica termina convirtiéndose en una carga.

El diagrama que vale la pena guardar

Todo el argumento cabe en cuatro cajas:

Una dependencia que no puedes evitar rara vez es el problema. Una dependencia que solo puede apuntar a una cosa, sí lo es.

Por qué esto es más mantenible

Esta manera de programar no tiene por qué generar bugs, y menos el primer día. La dirección directa y la invertida se comportan igual a nivel de negocio: haces un pedido y se envía el email. Todo el coste dependerá de la necesidad de cambios, que es justo por lo que dicho coste se acumula y nos cuesta detectarlo a tiempo. Una base de código donde cada regla de negocio apunta directamente a sus detalles concretos es una base de código que no desacopla2 nada, realmente es mucho más cómodo y directo, hasta que cada cambio en un driver de base de datos, un proveedor de email o una pasarela de pago se propaga hasta una lógica que ya teníamos como código estable.

Es la misma rigidez que tenía el servicio de reservas del último Expediente antes de que su regla de conflicto dejara de depender de SQLite directamente. Ahí el arreglo no fue una reescritura, fue exactamente este mismo movimiento, aplicado a una función cada vez: mantener el comportamiento fijo y cambiar quién pasa a depender de quién.

Conclusión

Las dependencias son inevitables. No podemos hacer que desaparezcan, pero sí podemos decidir hacia dónde apuntan.

La pregunta importante no es cuántas dependencias tiene nuestro código, sino qué parte queda expuesta cuando algo cambia. Si hacemos que las dependencias apunten hacia las reglas que queremos proteger, los detalles —un proveedor, un driver, una base de datos o una librería— pueden cambiar sin obligarnos a modificar el código de negocio.

En definitiva, diseñar bien las dependencias consiste en proteger lo que es estable de aquello que puede cambiar. Cuando conseguimos eso, cambiar un proveedor deja de ser un cambio en nuestro negocio y pasa a ser simplemente un cambio en la infraestructura que lo rodea.

Notas

  1. Costura: un punto del código donde puedes cambiar el comportamiento sin tocar el código de ahí mismo, normalmente pasando algo distinto como parámetro.

  2. Desacoplamiento: reducir cuánto necesita saber una parte de un sistema sobre otra, para que cada una pueda cambiar sin arrastrar a la otra con ella.

Recibe nuevos artículos en tu correo

Escritura sobre arquitectura pragmática, sistemas mantenibles y trade-offs reales de ingeniería. Publicado cuando está listo.