Creando un adaptador driver en una aplicación hexagonal

por Fran Iglesias

Vamos a ver cómo se podría crear un adaptador para un driver port. En este caso, queremos manejar la aplicación desde la línea de comandos.

Retomo el hilo de este proyecto casi cuatro meses después. Por razones personales que no vienen al caso, dejé de trabajar en él y me he desconectado bastante. En cualquier caso, el interés de la serie no está tanto en el proyecto en sí, como en la forma de trabajar con él desde la perspectiva de aplicar el patrón Arquitectura Hexagonal.

Y uno de los puntos que sería importante tocar es el de los adaptadores. Por tanto, me propongo crear un adaptador para usar la aplicación a través de la línea de comandos.

En este momento, los contratos de los puertos están más o menos bien definidos, lo que nos permite paralelizar el desarrollo. Este es un punto muy importante. Aunque la aplicación como tal no está completamente terminada, contamos con elementos suficientes como para iniciar el trabajo en los adaptadores que nos permitirán tener acceso por línea de comandos, una API o cualquier otro medio.

Los adaptadores driven los veremos en otro momento, aunque en realidad ya introdujimos algunos para poder ejecutar los tests.

Adaptadores, frameworks y librerías

En una arquitectura hexagonal los adaptadores son el territorio en el que podemos introducir frameworks y librerías. En unos casos, necesitaremos librerías del lenguaje que nos permitan hablar con las tecnologías necesarias. En otros casos, algunos frameworks nos facilitarán la vida a la hora de construir nuestros adaptadores al resolver algunos elementos básicos.

Podemos considerar un adaptador como un programa, casi me atrevería a decir una mini-aplicación, cuyo propósito es traducir o mapear las intenciones de un actor al lenguaje establecido mediante los puertos de la aplicación. Y, también, las respuestas provistas por la aplicación a la modalidad adecuada para ese adaptador.

Manejando la aplicación a través de la línea de comandos

Vamos a empezar. Mi primer intento será tener un comando que me permita registrar paquetes. Mi idea sería poder tener algo como esto:

storage register <locator> <size>

Esto debería mostrar una respuesta que me indique en qué contenedor tiene que guardarse:

package <locator> to be stored in container <id>

Y si no se puede guardar, debería mostrar algo así:

no space available in containers
package <locator> has to wait in the queue

Por ejemplo:

storage register 123basdfec small
package 123basdfec to be stored in container 2

Así que lo que necesito es un comando storage, que sepa interpretar un subcomando register y tomar dos parámetros: el localizador del envío y su tamaño.

En general, todos los lenguajes van a permitir crear este tipo de comando de consola de forma nativa. Sin embargo, también es muy frecuente disponer de librerías o frameworks cuyo objetivo es facilitar nuestra labor, especialmente para aplicaciones grandes, de modo que no tengamos que reinventar la rueda constantemente.

Walking skeleton

Walking Skeleton es un concepto del que creo que ya he hablado alguna vez. Se trata de una primera implementación que, aunque no aporte valor de negocio, nos garantiza que tenemos todos los elementos necesarios para que nuestra aplicación pueda funcionar. De hecho, si este esqueleto no funciona, no vamos a poder aportar valor.

En un proyecto real esto significaría incluir también todo lo que tiene que ver con el despliegue. Lo que queremos conseguir es que sea posible interactuar con nuestra aplicación o servicio, y poder ir montando la funcionalidad sobre esos elementos estructurales.

¿Qué definiría un Walking Skeleton para nuestro comando?

  • Puede escribir el nombre del comando en la consola y se muestra alguna respuesta, por ejemplo, un texto de ayuda.
  • Puedo escribir un comando y subcomando y obtengo alguna respuesta generada por el propio comando.
  • Puedo escribir un comando y subcomando y obtener alguna respuesta generada por la aplicación.

Dicho de forma más genérica, los puntos anteriores nos garantizarían que el adaptador CLI puede procesar subcomandos y usar la aplicación para generar respuestas a través de alguno de sus puertos. Vamos a verlo, paso a paso.

Script mínimo en Ruby

Se podría decir que ahora voy a hacer el Waking Skeleton del Waking Skeleton. Voy a escribir el script más pequeño que pueda ejecutarse desde la línea de comandos usando Ruby.

Añado el siguiente archivo en la carpeta bin del proyecto: storage.rb con este contenido:

# frozen_string_literal: true

puts "OK"

Ahora en la línea de comandos, pruebo:

ruby bin/storage.rb

Lo que me devuelve el lacónico:

OK

Es un primer paso. Me gustaría poder ejecutar el script sin invocar ruby ni tener la extensión en el nombre.

Para lo primero, necesitamos introducir el shebang

#!/usr/bin/env ruby
# frozen_string_literal: true

puts "OK"

Para lo segundo, renombramos el archivo y cambiamos sus permisos para que sea ejecutable:

mv bin/storage.rb bin/storage
chmod +x storage

Finalmente, lo podemos invocar así:

bin/storage

Igualmente, el resultado es:

OK

Esto me garantiza un script mínimo que puedo ejecutar y en el que empezar a trabajar en el código de mi adaptador.

Creando un adaptador

Mi siguiente objetivo es desarrollar el adaptador. La cuestión es que quiero que el script storage no sea más que un entry point, (o bootstrap, o setup) mientras que el adaptador debería estar en su carpeta correspondiente.

Al ir paso a paso, puedo avanzar de una forma controlada. Por ejemplo, podría empezar poniendo código en el script para establecer su funcionamiento y luego llamarlo desde ahí. Pero, por otro lado, no quiero acoplarme específicamente al uso de una librería.

Así que vamos a analizar esto un momento. Básicamente, crear una aplicación CLI consiste en procesar la lista de argumentos recibida por el script, que en Ruby se representa por el array ARGV y actuar en consecuencia.

Si hacemos un pequeño cambio en bin/storage:

#!/usr/bin/env ruby
# frozen_string_literal: true

puts ARGV

Cuando ejecutamos el script podemos ver su contenido:

register
123basdfec
small

Es fácil ver que el primer item de ARGV nos proporciona el subcomando, y el resto de valores son los parámetros. Resumiendo mucho: lo que vamos a hacer es recibir el array de argumentos y obtener el primero para conocer el subcomando. A partir de ahí se trata de hacer lo necesario para comunicárselo a la aplicación.

La aplicación no debe exponer sus tripas

He definido los puertos driver de la aplicación usando el patrón command + handler. El comando es un DTO y el handler es el objeto que realiza el comportamiento usando como datos de entrada los que transporta el DTO.

Instanciar los comandos es fácil. Son objetos simples que se pueblan con los datos necesarios obtenidos a partir de los parámetros recibidos.

Hacer que el adaptador tenga que instanciar cada uno de los handlers necesarios complica enormemente el diseño y desarrollo. Pero, afortunadamente, tenemos otra solución:

Mensajería

En general, podemos hablar de tres tipos de mensajes:

  • Command: que tiene como objetivo provocar un cambio en el sistema.
  • Query: que tiene como objetivo obtener una información del sistema, por lo que devuelve una respuesta.
  • Event: que tiene como objetivo comunicar algo que ha sucedido.

Los tres tipos de mensajes se pueden enviar a sus destinatarios potenciales, o handlers, a través de un Bus de mensajes. Cada tipo de mensaje utiliza un tipo de bus diferente porque tienen intenciones distintas y la relación con sus destinatarios es diferente también.

  • Command Bus: para enviar Command a un handler. Los command no devuelven respuestas y su intención es provocar un cambio en el sistema. Normalmente, tienen un destinatario único, por lo que la relación es 1:1.
  • Query Bus: para enviar Query a un handler. Los mensajes query devuelven una respuesta, por lo que no deben provocar modificaciones en el sistema. También tienen una relación 1:1 con su handler.
  • Event bus: para enviar Events. Los eventos notifican algo que ha sucedido al resto del sistema. Por lo tanto, pueden tener muchos destinatarios interesados, aunque a priori no sepamos quién son. La relación es 1:N. En una aplicación hexagonal, los eventos suelen tener interés dentro del propio hexágono.

Lo anterior aplica conceptualmente tanto a mensajería interna como a sistemas distribuidos, y tanto a procesamiento síncrono como asíncrono. Estos ya serían detalles de implementación.

Adaptadores y mensajería

Al adaptador le interesa solamente la idea de un lugar al que le pasa comandos o queries según sus intereses y un bus de mensajes es ideal para eso. Para desarrollar un adaptador nos basta con tener una interfaz del bus de mensajes, a partir de la cual podemos obtener dobles de test con los que simular el comportamiento de la aplicación.

Siguiendo esta línea de diseño, por lo general vamos a necesitar dos buses: uno para los comandos y otro para las queries. Esto es con respecto a interfaces o roles, ya que muchas veces podremos implementar ambas usando la misma librería.

En cualquier caso, para diseñar los adaptadores no basta con la interfaz y los dobles correspondientes.

Mensajes como Structs (Ruby… y otros lenguajes)

En este ejercicio estoy aprendiendo Ruby por lo que le he estado dando vueltas a como conseguir que el código sea interesante desde el punto de vista de mi aprendizaje del lenguaje. Pero, por otro lado, tendría que ser un código accesible para personas que no lo conozcan.

Un tema que genera un poco de lío en algunos lenguajes orientados a objetos (caso de Ruby o Java) es que las instancias tienen identidad. Esto es, tienen un identificador y al comparar objetos que tienen los mismos valores, el lenguaje los considera diferentes por ser diferente instancia.

Hay casos de uso en lo que esto es perfecto, pero cuando los objetos son DTO e incluso Value Objects, se convierte en una molestia porque necesitamos implementar la igualdad para que ignore la identidad.

Sin embargo, en Ruby es posible usar Structs, que es un tipo ideal para DTO. Tiene estas ventajas principales:

  • Son muy fáciles de definir, tan solo necesitamos darles un nombre y la lista de propiedades
  • Podemos acceder fácilmente a sus propiedades
  • No tienen identidad, por lo que dos Structs que tengan los mismos valores se consideran iguales

Por esa razón, he decidido cambiar las definiciones de los comandos y queries en los puertos por structs. Veamos un ejemplo. He aquí el comando RegisterPackage, definido como clase:

class RegisterPackage
  attr_reader :locator, :size
  def initialize(locator, size)
    @locator = locator
    @size = size
  end
end

Y aquí, definido como Struct:

RegisterPackage = Struct.new(:locator, :size)

Por lo demás, RegisterPackage es una clase Ruby en la que podríamos definir métodos.

Este cambio, por otro lado, nos facilitará mucho el desarrollo. Los tests que impliquen verificar que se envían los comandos correctos serán mucho más sencillos.

Diseñando el adaptador

Lo anterior nos permite definir una especificación como la que sigue para el adaptador. En ella, podemos ver como doblamos como mocks tanto el Command Bus como el Query Bus. Lo que nos interesa verificar es la instanciación y envío el comando correcto para el subcomando pasado por la CLI.

# frozen_string_literal: true

require "rspec"

require_relative "../../../../lib/adapter/for_registering_packages/cli/cli_adapter"
require_relative "../../../../lib/app/for_registering_packages/register_package/register_package"

RSpec.describe "CliAdapter" do
  before do
    @command_bus = double("CommandBus")
    @query_bus = double("QueryBus")

    @cli = CliAdapter.new(@command_bus, @query_bus)
  end

  after do
    # Do nothing
  end

  context "when receives register subcommand" do
    it "invokes register package command" do
      expect(@command_bus).to receive(:execute).with(RegisterPackage.new("locator", "small"))
      args = ["register", "locator", "small"]
      @cli.run(args)
    end
  end
end

Esta sería una implementación inicial muy básica, que nos da una idea de por donde van los tiros. Obviamente, tendríamos que chequear que se reciben los parámetros adecuados.

class CliAdapter
  def initialize(command_bus, query_bus)
    @command_bus = command_bus
    @query_bus = query_bus
  end

  def run(args)
    sub_command = args[0]
    if sub_command == "register"
      command = RegisterPackage.new(args[1], args[2])
      @command_bus.execute(command)
    end
  end
end

Ahora bien, hemos dicho que queríamos obtener también el contenedor disponible para almacenar el paquete. Tendríamos que invocar la query AvailableContainer.

Esta sería una posible especificación. En ella, el Query Bus es doblado como stub para que nos proporcione una respuesta. Esperamos que el comando muestre un output por pantalla.

require "rspec"

require_relative "../../../../lib/adapter/for_registering_packages/cli/cli_adapter"
require_relative "../../../../lib/app/for_registering_packages/register_package/register_package"
require_relative "../../../../lib/app/for_registering_packages/available_container/available_container"
require_relative "../../../../lib/app/for_registering_packages/available_container/available_container_response"

RSpec.describe "CliAdapter" do
  before do
    @command_bus = double("CommandBus")
    @query_bus = double("QueryBus")

    @cli = CliAdapter.new(@command_bus, @query_bus)
  end

  after do
    # Do nothing
  end

  context "when receives register subcommand" do
    it "invokes register package command" do
      expect(@command_bus).to receive(:execute).with(RegisterPackage.new("locator", "small"))
      allow(@query_bus).to receive(:execute).with(AvailableContainer.new).and_return(AvailableContainerResponse.new(2))

      args = ["register", "locator", "small"]

      expect {
        @cli.run(args)
      }.to output(/package locator to be stored in container 2/).to_stdout
    end
  end
end

Y como primera implementación tendríamos lo siguiente:

class CliAdapter
  def initialize(command_bus, query_bus)
    @command_bus = command_bus
    @query_bus = query_bus
  end

  def run(args)
    sub_command = args[0]
    if sub_command == "register"
      register_command = RegisterPackage.new(args[1], args[2])
      @command_bus.execute(register_command)
      available_container = AvailableContainer.new
      response = @query_bus.execute(available_container)
      puts("package #{args[1]} to be stored in container #{response.container}")
    end
  end
end

Esto es solo una primera versión sucia para probar el concepto. Si tiramos la especificación podemos ver que pasa correctamente.

Personalmente, me gustaría tener una versión un poco más limpia y orientada a objetos. Se puede anticipar el problema que se va a generar a medida que tengamos que implementar subcomandos dado que cada subcomando implica introducir código que se ocupa de otras responsabilidades. En mi opinión, lo adecuado sería separar la identificación del subcomando de su implementación.

Como primer paso, lo voy a extraer a un método. Antes de nada, extraigo los argumentos a variables:

class CliAdapter
  def initialize(command_bus, query_bus)
    @command_bus = command_bus
    @query_bus = query_bus
  end

  def run(args)
    sub_command = args[0]
    if sub_command == "register"
      locator = args[1]
      size = args[2]
      register_command = RegisterPackage.new(locator, size)
      @command_bus.execute(register_command)
      available_container = AvailableContainer.new
      response = @query_bus.execute(available_container)
      puts("package #{locator} to be stored in container #{response.container}")
    end
  end
end

Esto tiene una pinta un poco más limpia:

class CliAdapter
  def initialize(command_bus, query_bus)
    @command_bus = command_bus
    @query_bus = query_bus
  end

  def run(args)
    sub_command = args[0]
    if sub_command == "register"
      locator = args[1]
      size = args[2]
      output = register_package(locator, size)
      puts(output)
    end
  end

  private

  def register_package(locator, size)
    register_command = RegisterPackage.new(locator, size)
    @command_bus.execute(register_command)
    available_container = AvailableContainer.new
    response = @query_bus.execute(available_container)
    "package #{locator} to be stored in container #{response.container}"
  end
end

En este punto se plantean cosas interesantes sobre el diseño, ya que no me gusta demasiado como está quedando. Pero siempre es positivo que el propio desarrollo vaya cuestionando lo que tenemos.

Los puertos son conversaciones

Los puertos, en este caso los puertos driver, representan las conversaciones que establece un actor con la aplicación con el fin de cumplir sus intenciones.

En este caso, el actor quiere registrar un paquete para almacenar. El sistema le dice donde almacenarlo. Una pregunta que me surge es: ¿debería ser una única query?

Por un lado, prefiero separar la parte command de la query. Me explico. En el estado actual del código:

  • RegisterPackage introduce un paquete en la cola de espera (produce un cambio en el sistema)
  • AvailableContainer pregunta cuál es el siguiente contenedor disponible para el paquete (obtiene una información del sistema)

Si juntamos los dos tenemos una query que modifica el sistema. Esto atenta contra el principio Command/Query Separation. La principal consecuencia negativa de realizar queries con side-effects es que se genera una incertidumbre en el resultado.

En el aspecto negativo, esta separación parece poco natural desde el punto de vista del actor que, en este caso, contempla la acción como una unidad.

Sin embargo, podemos plantear que disponer de operaciones atómicas, que solo hacen una cosa, en los puertos nos permite componerlas para obtener comportamientos más elaborados cuando es necesario. Además, proporciona una flexibilidad muy útil para que crear ciertos adaptadores.

Rediseño del adaptador

En todo caso, hay cuestiones de diseño del adaptador que me gustaría reconsiderar.

  • CliAdapter recibe los buses como dependencia, que pueden ser usados o no en los diferentes subcomandos. Esto puede ser un mal uso, ya que básicamente cargamos con dependencias que pueden ser usadas o no.
  • Igualmente, CliAdapter tiene que ocuparse de identificar los distintos subcomandos y ejecutar las acciones que correspondan. Puede que esté teniendo demasiadas responsabilidades. Podríamos reducirlas si solo se ocupa de coordinar ambas partes, delegando en una factoría, que identifique la acción y ejecutando esta.
  • De hecho, el método register_package en el que aislamos la interacción del actor con la aplicación para registrar paquetes podría extraerse a un objeto, revelando un patrón que podremos aplicar a futuros subcomandos (como store, etc., …). Tendría más sentido construir estos objetos con sus dependencias.

Lo cierto es que podríamos plantear que el método register_package es el verdadero adaptador, más que el propio CliAdapter, que sería el entry point. Y del mismo modo, otras intenciones del actor se materializarán mediante otros adaptadores.

Veamos este nuevo planteamiento. Primero en forma de especificación:

RSpec.describe "CliAdapter" do
  before do
    @command_bus = double("CommandBus")
    @query_bus = double("QueryBus")

    action_factory = ActionFactory.new(@command_bus, @query_bus)

    @cli = CliAdapter.new(action_factory)
  end

  after do
    # Do nothing
  end

  context "when receives register subcommand" do
    it "invokes register package command" do
      expect(@command_bus).to receive(:execute).with(RegisterPackage.new("locator", "small"))
      allow(@query_bus).to receive(:execute).with(AvailableContainer.new).and_return(AvailableContainerResponse.new(2))

      args = ["register", "locator", "small"]

      expect {
        @cli.run(args)
      }.to output(/package locator to be stored in container 2/).to_stdout
    end
  end
end

He aquí como quedaría CliAdapter. Ahora habrá una factoría que se encarga de identificar y configurar la acción (o adaptador) requerida:

class CliAdapter
  def initialize(action_factory)
    @action_factory = action_factory
  end

  def run(args)
    action = @action_factory.for_subcommand(args)
    puts(action.execute)
  end
end

Esto lo hace procesando los argumentos en lugar de CliAdapter e inyectando los buses:

class ActionFactory
  def initialize(command_bus, query_bus)
    @command_bus = command_bus
    @query_bus = query_bus
  end

  def for_subcommand(args)
    sub_command = args[0]
    case sub_command
    when "register"
      locator = args[1]
      size = args[2]
      RegisterPackageAction.new(@command_bus, @query_bus, locator, size)
    else
      NoAction.new
    end
  end
end

NoAction es una implementación del patrón Null Object, de modo que representa que no se ha logrado identificar ninguna acción.

class NoAction
  def execute
    "Nothing was executed."
  end
end

Y aquí tenemos RegisterPackageAction.

class RegisterPackageAction
  def initialize(command_bus, query_bus, locator, size)
    @command_bus = command_bus
    @query_bus = query_bus
    @locator = locator
    @size = size
  end

  def execute
    @command_bus.execute(RegisterPackage.new(@locator, @size))
    response = @query_bus.execute(AvailableContainer.new)
    "package #{@locator} to be stored in container #{response.container}"
  end
end

Aunque todavía no tenemos ni CommandBus, ni QueryBus, podemos hacernos una idea de como podría quedar el script storage para producción:

command_bus = CommandBus.new
query_bus = QueryBus.new
factory = ActionFactory.new(command_bus, query_bus)

storage = CliAdapter.new(factory)
storage.run(ARGV)

Sigo teniendo una sensación agridulce con este diseño, así que probablemente le seguiré dando algunas vueltas en el futuro.

RegisterPackageAction usa un patrón Command. Su mayor inconveniente es que no hemos separado el handler, lo que hace que su constructora resulte un tanto extraña, al mezclar las dependencias y los parámetros. Esto quizá se podría mejorar con Parameter Object.

Por otro lado, tiene la ventaja de mantenerse abierto a extensión. Para soportar nuevos subcomandos en la herramienta cli no tengo más que añadir una clase y una entrada en la factoría.

Conclusiones

En este artículo he intentado mostrar la construcción de un adaptador a un puerto de arquitectura hexagonal.

El patrón ports and adapters nos permite trabajar separadamente el core y los adaptadores, toda vez que hayamos definido los puertos. En ese sentido, usar un bus de mensajes asegura tanto un acoplamiento mínimo, como poder evolucionar los puertos si vemos necesidad, reduciendo el coste del cambio en ambos lados.

October 11, 2023

Etiquetas: good-practices   design-patterns   ruby   bdd   hexagonal  

Temas

good-practices

refactoring

php

testing

tdd

design-patterns

python

blogtober19

design-principles

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

tips

hexagonal

ddd

bbdd

soft-skills

books

oop

javascript

api

sql

ethics

typescript

swift

java

agile