Diseño incremental usando Ports&Adapters

El diseño incremental es una forma de trabajar que nos va a permitir ir añadiendo nueva funcionalidad a nuestras aplicaciones de forma progresiva. Introducir estas nuevas funcionalidades puede llegar a ser un dolor enorme para el equipo, si esto es sinónimo de muchos cambios.

Para solucionar esta problemática existe un patrón de arquitectura denominado Ports and Adapters que nos permitirá introducir estos cambios de forma casi transparente.

Porque usar Ports & Adapters?

En las últimas semanas he estado trabajando en un proyecto personal en el que he aprovechado a aplicar muchas de las técnicas que utilizo diariamente en el trabajo. Este proyecto consiste en una app que te permite subscribirte a determinados autores, títulos de libros o palabras clave en general. Cuando aparecen nuevos libros para estos términos los usuarios son notificados.

Desde el primer momento tuve claro que quería crear la app de forma incremental, en pequeños pasos y de tal forma que añadir nuevos componentes a la misma fuera realmente sencillo. Para ello utilicé una arquitectura en capas conocida como Ports and Apdaters, también llamada Arquitectura Hexagonal, y que es la que usamos en el trabajo.

Este tipo de arquitectura, entre otras muchas ventajas, nos va a permitir definir unos puertos que pueden ser implementados por N adaptadores según nuestras necesidades. De esta forma podremos crear nuevos componentes en nuestra app mientras respeten dichos contratos. Estos serán intercambiables, por ejemplo, la forma en la que se buscan los libros, cómo se notifican los cambios a los usuarios o cómo se guardan los datos.

Podemos pensar en los puertos como contratos que todo adaptador debe cumplir (normalmente estos puertos suelen ser interfaces a nivel de código). A su vez podemos pensar en los adaptadores como implementaciones concretas de estos puertos en función de las necesidades de nuestra aplicación.

Como ya podéis estar pensando esto encaja perfectamente con mi idea de ir haciendo la app poco a poco, incrementando su valor. ¡Vamos a por ello!

MVP (Minimum Viable Product)

Una vez que tenemos claro qué queremos construir y cómo lo queremos hacer lo que necesitamos definir es la cantidad mínima de producto que queremos entregar lo antes posible a nuestros usuarios, aportándoles valor.

Pensando en la funcionalidad mínima con la que empezar mi app tenía claro que habría 3 componentes fundamentales:

  • Un repositorio para almacenar y recuperar las subscripciones.
  • Un mecanismo para notificar a los usuarios.
  • Un cliente encargado de buscar libros.

Es por ello que uno de los primeros pasos fue definir estos tres puertos dentro de mi aplicación:

Puerto para persistir subscripciones

from abc import ABC, abstractmethod

from domain.subscriptions.subscription import Subscriptions


class SubscriptionsRepository(ABC):
    @abstractmethod
    def save(self, subscription: Subscription) -> None:
        raise NotImplementedError

Puerto para buscar libros

from abc import ABC, abstractmethod

from domain.book import Book


class BookSearcher(ABC):
    @abstractmethod
    def find(self, query: str) -> list[Book]:
        raise NotImplementedError

Puerto para notificar a los usuarios

from abc import ABC, abstractmethod

from domain.subscriptions.subscription import Subscriptions


class Notifier(ABC):
    @abstractmethod
    def notify(self, subscriptions: Subscriptions) -> None:
        raise NotImplementedError

Una vez definidos los puertos principales el resto de la app no es nada del otro mundo. Es más, seguí la misma estructura de carpetas que en mi repositorio de Domain Driven Design, por lo que me centraré más en esta parte de Ports&Adapters.

Iteración 1: Validar la idea

Para validar que la idea tenía sentido me propuse como requisitos mínimos:

  • Poder subscribirme a un autor/libro/termino.
  • La persistencia de los datos sería un fichero txt.
  • El input del usuario sería por consola.
  • El output al usuario sería por consola.

Para cumplir con estos 4 requisitos necesité:

  • Crear un adaptador llamada TxtSubscriptionsRepository para el puerto SubscriptionsRepository.
  • Crear un adaptador llamado ConsoleNotifier para el puerto Notifier.
  • Crear un mecanismo de delivery para interactuar con el usuario por consola.

Iteración 2: Búsqueda Múltiple

Una vez validé la idea inicial me propuse incrementar ligeramente la funcionalidad de la aplicación con las siguientes novedades:

  • Poder subscribirme a uno o varios autor/libro/termino.
  • Poder darse de baja de uno o a varios autor/libro/termino.

Para poder almacenar de forma sencilla múltiples términos decidí cambiar el fichero donde se persistían los datos, un json en lugar de un txt. Para ello solo tuve que crear un nuevo adaptador llamado JSONSubscriptionsRepository para el puerto SubscriptionsRepository que se encargará de la manipulación de dicho fichero.

Como podéis ver, la diferencia entre adaptadores radica su lógica interna. Pero como todos respetan un puerto común, estos se pueden intercambiar libremente.

Iteración 3: Notificaciones por email

Una vez que mi app ya era capaz de subscribirse a múltiples términos, me pareció que era el momento de cambiar la forma en la que el usuario es notificado. En lugar de tener que ejecutar la app cada X tiempo, la propia app comprobará periódicamente si existen nuevos libros para las subscripciones y si esto ocurre enviará un email al usuario.

Este cambio fue tan sencillo como crear un adaptador llamado EmailNotifier para el puerto Notifier que remplazaba al ConsoleNotifier. El desarrollo de este nuevo adaptador lo hice completamente en paralelo aplicando TDD. Cuando lo tuve acabado, simplemente fue cambiar un adaptador por otro.

Iteración 4: Crear una API REST

Llegados a este punto, la aplicación ya tenía el suficiente cuerpo como para seguir utilizándola por consola ya que limitaba bastante su experiencia de uso.

Al estar utilizando una arquitectura en capas he podido crear un mecanismo de delivery nuevo, en este caso una API, sin romper el funcionamiento actual de la app y llegando incluso a tener dos mecanismos de delivery en paralelo. Cuando validé que todo estaba correcto remplace la consola por la API.

Otro punto muy importante, y que no tiene tanto que ver con los puertos y los adaptadores, sino más bien con la arquitectura en capas, es que los diferentes mecanismos de delivery pueden reutilizar todas las capas internas a ellos, siendo realmente sencillo cambiar uno por otro.

Iteración 5: Persistencia en base de datos

Una vez que la app ya disponía de una API, era capaz de subscribirse a múltiples términos y notificar los cambios a través de un email al usuario, decidí que era buen momento para cambiar la persistencia a algo más tradicional. En este caso aposté por MySQL usando PlanetScale.

Para ello, y como había hecho previamente con otros puertos, simplemente creé un nuevo adaptador llamado MySQLSubscriptionsRepository, que implementaba el puerto SubscriptionsRepository.

Una vez más, este nuevo componente lo desarrollé completamente en paralelo haciendo TDD y cuando tenía la seguridad de que funcionaba como esperaba simplemente remplace el adaptador JSONSubscriptionsRepository por este nuevo MySQLSubscriptionsRepository.

Siguiente, ¿usuarios? ¿Crear una interfaz?

Llegados a este punto, las expectativas de lo que inicialmente quería crear ya están más que superadas. Ahora simplemente queda seguir pensando qué rumbo tomar y qué próximas funcionalidades añadir.

Una idea interesante puede ser crear el concepto de usuario y que las subscripciones estén vinculadas a este mismo. O tal vez crear una interfaz para consumir la API.

Como podéis ver las posibilidades son infinitas…

Conclusiones

Lo primero, gracias por llegar hasta aquí, ha sido un post denso en el que he contado muchas cosas y seguramente dejándome otras tantas por el camino.

Como hemos visto gracias al uso de ports y adapters ha sido realmente sencillo añadir nuevas funcionalidades a la aplicación.

En todos los casos estas se desarrollaron en paralelo haciendo TDD y cuando estaban listas simplemente había que remplazar un adapter por otro y listo.

Espero que os haya gustado este ejemplo real y si nunca habéis usado este tipo de arquitectura os animéis a darle una oportunidad.

!Un saludo¡