Como añadir features en arquitectura hexagonal

por Fran Iglesias

En un equipo de desarrollo orientado a producto las nuevas prestaciones solo tienen sentido si la usuaria o cliente puede usarlas.

La mejor definition of done que he conocido dice algo así como: la usuaria o cliente puede usar la prestación. Cualquier otra consideración es superflua… y he tenido la oportunidad de conocer algunas defintions of done realmente complejas con un montón de requisitos. Por supuesto, hay que tener en cuenta que en algunos dominios altamente regulados, como puede ser el sector de la salud, sí que debemos fijarnos en cumplir ciertos requisitos antes de dar por terminada una historia de usuario. En cualquier caso, muchos de ellos podrían incluirse como tales en la propia especificación de la historia.

Pero bueno, centrándonos en el objetivo del artículo, lo que querría desarrollar en esta ocasión es cómo trabajar una prestación en la que un actor está interesado cuando el proyecto está desarrollado sobre el patrón de arquitectura hexagonal.

Sobre la partición de proyectos

Desarrollar una prestación en Arquitectura Hexagonal requiere que trabajemos en:

  • la lógica de negocio o dominio dentro del hexágono
  • el puerto implicado
  • el adaptador

Por supuesto, dependiendo del estado de desarrollo del proyecto podemos encontrarnos con que ya existen algunos elementos que podemos utilizar, como podría ser que el puerto esté definido, que exista ya un adaptador que podemos extender, etc.

Por supuesto, esto me lleva a considerar también la forma en que definimos las prestaciones que vamos a implementar y cómo abordamos los proyectos.

En la lista que acabo de escribir hago referencia a un único puerto y un único adaptador. Hay prestaciones que podrían implicar varios puertos y/o varios adaptadores. O que sería interesante que una cierta prestación pueda ejecutarse vía interfaz web, que esté disponible como API o interfaz CLI.

Desde un punto de vista de desarrollo iterativo e incremental, yo las trataría como tres prestaciones diferentes. De hecho, es probable que estemos hablando de tres actores distintos. Imaginemos estas situaciones:

  • La interfaz web se usa en una aplicación interna de atención al cliente.
  • La API sería necesaria para desarrollar una aplicación móvil, que es como acceden nuestras usuarias.
  • La interfaz CLI podríamos necesitarla para poder lanzar esa prestación en lotes, o mediante un cron, por la razón que sea.

Por eso, aunque la lógica de dominio sea exactamente la misma, estaríamos hablando de prestaciones diferentes que interesan a actores diferentes.

¿Cómo priorizar este desarrollo? Puedes encontrar ideas en este artículo sobre desarrollo iterativo incremental. En líneas generales, el planteamiento es el siguiente:

El requerimiento de que la prestación tiene que estar en manos de las usuarias nos dice que tenemos que trabajar toda la conversación. Como mencionamos antes: lógica de negocio, puerto y adaptador, que permitan entregar una prestación usable. Pero una vez que hayamos desarrollado la prestación para un actor, para los otros será añadir tan solo el adaptador.

En este caso, la cuestión es decidir a qué actor servir primero, para lo cual podríamos atender a una variedad de criterios que también se comentan en el artículo enlazado.

En resumen, todo esto para argumentar que necesitamos decidir por dónde empezar el desarrollo y que debemos contemplarlo globalmente, poniéndonos en el lugar del actor. La pregunta siempre tiene que ser ¿qué es lo más importante que deberíamos estar haciendo ahora que aporte valor? Y una vez que lo sabemos, ¿cómo podemos abordarlo?

Un ejemplo práctico

Para ilustrar este proceso vamos a implementar una nueva prestación en el proyecto storage. En este caso, es la capacidad de configurar una oficina con un cierto número de contenedores de cada tamaño.

Para no desviar mucho la atención del objetivo del artículo, lo que haré será implementar un comando CLI, al igual que hicimos en el artículo anterior de la serie sobre crear un adaptador.

Lo que quiero es analizar la problemática de desarrollar una prestación en AH, desde la perspectiva del actor. Podríamos desarrollar la lógica de negocio dentro del hexágono, pero entonces estaría inaccesible al actor. Por tanto, es necesario abordar el desarrollo del puerto y del adaptador.

De hecho, en los artículos anteriores lo hicimos de esta forma aislada por lo que me ha quedado un mal regusto del resultado. Aunque lo justifico pensando que así ponía el foco en entender los elementos de la arquitectura, pese a que la aplicación en sí no estuviese muy bien.

Así que vamos a ello.

Definiendo lo que queremos

En pocas palabras, queremos que se pueda configurar el sistema de almacenaje definiendo el número de contenedores por tamaño, mediante línea de comandos.

Algo así:

storage configure --small=4 --medium=3 --large=3

Como respuesta, deberíamos tener algo así:

Configured storage:

* Small: 4
* Medium: 3
* Large: 3

Algunas especificaciones más:

  • Hay que indicar al menos un tamaño y cantidad.
  • Si no se pasa ninguno se muestra el estado actual. Es decir storage configure muestra la configuración que esté vigente. Si está sin configurar muestra un mensaje del tipo Storage is not configured.
  • Si el sistema ya está configurado nos dará un error porque no se puede cambiar. Quizá en el futuro podamos plantear otras cosas, pero de momento ya nos va bien así.

Por supuesto, quiero empezar con un test. Más exactamente con BDD, definiendo una feature de configuración.

En ese sentido, pienso que el primer escenario podría ser el de obtener la configuración actual, aunque sepamos que está vacía. La razón es que es suficiente para requerir el subcomando configure en el adaptador de CLI e invocar un caso de uso en la aplicación.

Preparación

Como preparación a este artículo, he estado trabajando bastante en el proyecto. Aunque esencialmente se mantiene la misma estructura, he ido reescribiendo o refactorizando partes a medida que he ido aprendiendo sobre el propio proyecto, tanto al revisar código como al experimentar con él. Los cambios de código no invalidan las líneas generales que he ido explicando en los artículos anteriores de la serie, pero seguramente habrá que matizarlo. Estoy pensando que el próximo artículo de la serie sea un resumen de las prácticas, organización de código, etc.

Uno de los puntos clave de estos cambios ha sido reescribir los pasos de los tests BDD. Al principio lo hice ejercitando directamente los comandos y handlers, lo que resultó relativamente útil para empezar a desarrollar. Sin embargo, creo que acabó haciendo confuso el desarrollo y la estructura.

Esta vez, he ido un poco más lejos y he cambiado estos pasos para que las features sean tests end to end. Es decir, el sistema se testea como actor usuario del adaptador de CLI. Es casi como ejecutar el comando dentro de un test. Esto se podría hacer exactamente así, dicho sea de paso, pero el código necesita aún un grado de madurez que no tenemos.

Este es un ejemplo. Como se puede ver, las llamadas a @storage.run se parecen bastante a lo que sería el comando de shell y nos aprovechamos de la simplicidad de configurar el sistema con componentes en memoria. Para producción o tests que ejecuten la línea de comandos, necesitaríamos persistencia indefinida.

# frozen_string_literal: true

require_relative "../../setup/cli_adapter_factory"

Given(/^there is enough capacity$/) do
  enough_capacity_conf = {
    small: 1,
    medium: 1,
    large: 1
  }
  @storage = CliAdapterFactory.for_test(enough_capacity_conf)
end

When("Merry registers a package") do
  @output = capture_stdout { @storage.run(%w[register some-locator small]) }
end

Then("first available container is located") do
  @container_name = @output.split.last
  expect(@container_name).to eq("s-1")
end

Then("he puts the package into it") do
  output = capture_stdout { @storage.run(["store", @container_name]) }
  expect(output).to eq("package stored in container #{@container_name}\n")
end

# There is no enough space for allocating package

Given("no container with enough space") do
  no_container_conf = {}
  @storage = CliAdapterFactory.for_test(no_container_conf)
end

Then("there is no available container") do
  expect(@output).to include("no space available")
end

Then("package stays in queue") do
  expect(@output).to include("Package some-locator is in waiting queue")
end

Given("an empty {string} container") do |capacity|
  one_container_conf = {}
  one_container_conf[capacity.to_sym] = 1
  @storage = CliAdapterFactory.for_test(one_container_conf)
end

When("Merry registers a {string} size package") do |size|
  @output = capture_stdout { @storage.run(["register", "some-locator", size]) }
end

Then("package is allocated in container") do
  @container_name = @output.split.last
  expect(@container_name).to eq("s-1")
end

# Container with packages in it and not enough free space

Given("a {string} package stored") do |size|
  output = capture_stdout { @storage.run(["register", "large-pkg", size]) }
  container_name = output.split.last
  @storage.run(["store", container_name])
end

def capture_stdout
  original = $stdout
  foo = StringIO.new
  $stdout = foo
  yield
  $stdout.string
ensure
  $stdout = original
end

Definiendo la feature

Un escenario mínimo

Empezamos con este escenario mínimo. Cuando el sistema no está configurado, nos dice que no lo está. Será suficiente para montar el flujo de ejecución básico a través del adaptador y el hexágono, sin forzarnos a introducir casi nada de lógica.

Feature: Configuring the system
  We can configure the system with an arbitrary number of containers with different sizes

  Rules:
    - Containers can have capacity for 4, 6 or 8 volumes, so:
    * Small = 4 vol
    * Medium = 6 vol
    * Large = 8 vol

  Scenario: Empty system
    Given the system is not configured
    When Merry sends no configuration
    Then System shows status
      """
      Configured storage:

      System is not configured
      """

Que se traduce en los siguientes pasos:

require_relative "../../setup/cli_adapter_factory"

Given(/^the system is not configured$/) do
  @storage = CliAdapterFactory.for_test({})
end

When(/^Merry sends no configuration$/) do
  @output = capture_stdout { @storage.run(%w[configure]) }
end

Then(/^System shows status$/) do |text|
  expect(@output).to include(text)
end

Hacer pasar este escenario me obliga a introducir todos los elementos necesarios para tener un walking skeleton de la feature.

Vamos a ver todos los elementos. En primer lugar, CliAdapter que va a recibir todos los argumentos pasados al script desde la terminal. CliAdapter usa ActionFactory para que esta construya la Action que se debe ejecutar.

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

Las *Actions en este proyecto no son más que la extracción a objetos del código que se debe ejecutar al identificar un subcomando. Es importante señalar que no son un concepto de la arquitectura hexagonal ni de ninguna otra, sino una forma de organizar el código de este adapter específico.

Aquí podemos ver cómo ActionFactory obtiene el subcomando y sus parámetros, devolviendo la Action adecuada y ya configurada con todo lo necesario.

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)
    when "store"
      container = args[1]
      StorePackageAction.new(@command_bus, container)
    when "configure"
      conf = {}
      ConfigureAction.new(@command_bus, conf)
    else
      NoAction.new
    end
  end
end

ConfigureAction es la traducción del subcomando configure de nuestro script de consola y es la que necesitamos desarrollar ahora.

Como se puede ver ejecuta el comando Configure, pasándoselo al CommandBus y espera recibir una excepción en caso de que Containers no esté configurado todavía. El happy path, en este caso, consiste en no hacer nada. Pero esto es suficiente para lograr lo que queremos, que es invocar ConfigureHandler.

class ConfigureAction
  def initialize(command_bus, conf)
    @command_bus = command_bus
    @conf = conf
  end

  def execute
    @command_bus.execute(Configure.new(@conf))
  rescue ContainersNotYetConfigured
    puts "Configured storage:\n\nSystem is not configured"
  else
    puts "Should do something about configuration success"
  end
end

ConfigureHandler no hace gran cosa en este momento. Se limita a arrojar una excepción, la cual es capturada en ConfigureAction, que pone el mensaje deseado en la consola.

class ConfigureHandler
  def initialize(containers)
    @containers = containers
  end

  def handle(configure)
    raise ContainersNotYetConfigured.new
  end
end

Con esto, tenemos todos los elementos básicos para confeccionar la feature completa y ya sabemos que invocando en la terminal storage configure ... se pondrán en uso. Por supuesto, el siguiente paso sería implementar la capacidad de configurar el almacenamiento.

CommandBus?

Puede que a estas alturas te estés preguntado cómo es nuestro CommandBus. Lo cierto es que no es nada sofisticado… más bien es bastante burdo. Y QueryBus es similar.

class CommandBus
  def initialize(queue, containers)
    @queue = queue
    @containers = containers
  end

  def execute(command)
    if command.instance_of?(RegisterPackage)
      RegisterPackageHandler.new(@queue).handle(command)
    end
    if command.instance_of?(StorePackage)
      StorePackageHandler.new(@queue, @containers).handle(command)
    end
    if command.instance_of?(Configure)
      ConfigureHandler.new(@containers).handle(command)
    end
  end
end

Como se puede ver es la mínima expresión de un CommandBus. Simplemente, identifica el comando que recibe, instancia el handler adecuado y ejecuta su método handle, pasándole el comando.

No es más que una solución simple para tener una prueba de concepto que sea funcional. Por supuesto, en un futuro se puede reemplazar por una solución más profesional.

Configurando algo

Tenemos dos posibles opciones para seguir ahora. Una es seguir por la lína de storage configure para que, partiendo de un sistema configurado, nos lo muestre en pantalla. La otra opción es introducir una configuración, aunque solo sea parcial. Lo cierto es que, en producción, necesitamos tener la posibilidad de configurar el sistema, así que vamos primero con esto, ya que entregamos más valor.

Lo que querríamos conseguir es algo así:

storage configure --small=2 --medium=3 --large=1

Esto supone que nuestro CliAdapter tiene que ser capaz de obtener esas opciones y pasárselos al comando Configure. Como hemos visto más arriba, eso se hace en ActionFactory.

Pero procesar tres opciones puede ser un poco complicado. Por esa razón vamos a empezar solo con una opción. Generalizarlo a más opciones debería ser fácil.

storage configure --small=2

En forma de escenario, lo voy a poner así:

  Scenario: Empty system
    Given the system is not configured
    When Merry configures 2 "small" containers
    Then System shows status
      """
      Configured storage:

      * Small: 2
      * Medium: 0
      * Large: 0
      """

Traducido a pasos, quedaría algo como esto:

Given(/^the system is not configured$/) do
  @storage = CliAdapterFactory.for_test({})
end

When(/^Merry sends no configuration$/) do
  @output = capture_stdout { @storage.run(%w[configure]) }
end

Then(/^System shows status$/) do |text|
  expect(@output).to include(text)
end

When(/^Merry configures (\d+) "([^"]*)" containers$/) do |qty, size|
  @output = capture_stdout { @storage.run(['configure', "--#{size}=#{qty}"]) }
end

Las opciones se reciben como strings de la forma “–opcion=valor”, con lo que tendría que ser fácil discriminarlas en el array de argumentos y parsearlas, por ejemplo, con una expresión regular. Incluso, podríamos buscar específicamente por las opciones con un nombre específico como sería el caso de small, medium o large.

En este caso, comenzaré introduciendo un objeto OptionParser al que pasar el mensaje by_name_or_default. Si la opción no está, puede poner un valor por defecto. De momento, no hace gran cosa.

class OptionParser
  def initialize(args)
    @args = args
  end
  def by_name_or_default(name, default)
    raise NotImplementedError
  end
end

En principio, queremos poder usarlo de esta forma:

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

  def for_subcommand(args)
    parser = OptionParser.new(args)
    sub_command = args[0]
    case sub_command
    when "register"
      locator = args[1]
      size = args[2]
      RegisterPackageAction.new(@command_bus, @query_bus, locator, size)
    when "store"
      container = args[1]
      StorePackageAction.new(@command_bus, container)
    when "configure"
      conf = {}
      small = parser.by_name_or_default("small", 0).to_i
      conf[:small] = small unless small == 0
      ConfigureAction.new(@command_bus, conf)
    else
      NoAction.new
    end
  end
end

Al tirar la excepción NotImplementedError el propio test nos va a decir que debemos implementar ese método, cosa que podemos hacer mediante TDD. No voy a ir paso por paso, pero este sería el resultado.

La especificación:

RSpec.describe "OptionParser" do
  before do
    # Do nothing
  end

  after do
    # Do nothing
  end

  context "When options are not present" do
    it "should return default" do
      parse(
        example: "subcommand",
        option_name: "option",
        default: 0,
        expected: 0
      )
    end
  end

  context "When options are passed" do
    it "should return value of desired option" do
      parse(
        example: "subcommand --option=3",
        option_name: "option",
        default: 0,
        expected: "3"
      )
    end
    it "should return default if not found" do
      parse(
        example: "subcommand --another=3",
        option_name: "option",
        default: 0,
        expected: 0
      )
    end
    it "should return default if value not specified" do
      parse(
        example: "subcommand --option=",
        option_name: "option",
        default: 0,
        expected: 0
      )
    end
  end
end

def parse(default:, example:, expected:, option_name:)
  parser = OptionParser.new(example.split(" "))
  expect(parser.by_name_or_default(option_name, default)).to eq(expected)
end

Y aquí el parser de opciones. Lo que hace es recorrer todos los argumentos y si cumplen el patrón “–name=valor” lo devuelve. Si no encuentra ningún valor, devuelve lo que hayamos indicado por defecto. Este es uno de esos ejemplos en los que Ruby puede resultar tan expresivo y natural:

class OptionParser
  def initialize(args)
    @args = args
  end

  def by_name_or_default(name, default)
    @args.each do |part|
      part.match(/--#{name}=(.*)/) do |matches|
        return matches[1] unless matches[1] == ""
      end
    end
    default
  end
end

Con esto, podemos ejecutar el escenario que ahora podrá pasar a invocar la ConfigureAction y, en consecuencia, el handler adecuado dentro del hexágono. Ahora tenemos que modificar el código para que el escenario anterior siga pasando, pero nos diga que debemos implementar algo. Creo que esto podría servir:

class ConfigureHandler
  def initialize(containers)
    @containers = containers
  end

  def handle(configure)
    if configure.conf == {} and !@containers.configured?
      raise ContainersNotYetConfigured.new
    end

    raise NotImplementedError.new
  end
end

Y añadimos de momento este método en Containers para saber si ya está configurado o no.

class InMemoryContainers
  def initialize
    @containers = []
  end

  def configured?
    @containers != []
  end
  
  # Code removed for clarity
   
end

En fin, que gracias a esto ya nos pide que implementemos algo en el handler al lanzar el error. En este punto, también nos movemos a un ciclo de TDD clásica. Por el momento haremos que reconfigurar los contenedores equivalga a reemplazar la configuración existente si está vacía. En caso de que ya esté configurado, no se hace ningún cambio.

RSpec.describe "ConfigureHandler" do
  context "Containers not configured" do
    it "should reconfigure with passed configuration" do
      containers = InMemoryContainers.configure({})
      handler = ConfigureHandler.new(containers)
      handler.handle(Configure.new({small: 3}))
      expect(containers.total_space).to eq(Capacity.new(12))
    end
  end
end
class ConfigureHandler
  def initialize(containers)
    @containers = containers
  end

  def handle(configure)
    if configure.conf == {} and !@containers.configured?
      raise ContainersNotYetConfigured.new
    end
    @containers.reconfigure(configure.conf)
  end
end

Esto, a su vez, nos pedirá implementar reconfigure en containers. Dado que el test del handler espera ese comportamiento, nos podría servir para el desarrollo de ese método de forma rápida. Es más riguroso, en cambio, especificar este comportamiento en containers_spec, que nos servirá para que cualquier implementación de containers deba cumplir.

Algo así:

shared_examples "a Containers" do
  it { is_expected.to respond_to(:available) }
  it { is_expected.to respond_to(:reconfigure) }

  before do
    @containers = described_class.new
  end

  # Code removed for clarity

  describe ".reconfigure" do
    it "should have capacity of containers configured" do
      @containers.reconfigure({small: 1})
      expect(@containers.total_space).to eq(Capacity.new(4))
    end
    it "should have capacity of containers combined" do
      @containers.reconfigure({small: 2, medium: 2})
      expect(@containers.total_space).to eq(Capacity.new(20))
    end
    it "should not change if already configured" do
      @containers = described_class.configure({small: 2})
      @containers.reconfigure({small: 4})
      expect(@containers.total_space).to eq(Capacity.new(8))
    end
  end
end

Como nota al margen: en este punto del proyecto no estoy muy contento con varias cosas. Así que es posible que se vean incongruencias entre el código mostrado en el artículo y el que se puede consultar en el repo del proyecto. Por supuesto, este siempre tiene la versión buena.

Una cosa que no tuve en cuenta al principio es que no se hace nada para obtener la configuración, por lo que ConfigureAction no puede mostrar nada reflejando que se ha realizado la configuración con éxito. Aquí nos haría falta una query, por lo que ConfigureAction podría ser algo así:

class ConfigureAction
  def initialize(command_bus, query_bus, conf)
    @command_bus = command_bus
    @query_bus = query_bus
    @conf = conf
  end

  def execute
    @command_bus.execute(Configure.new(@conf))
  rescue ContainersNotYetConfigured
    puts "Configured storage:\n\nSystem is not configured"
  else
    response = @query_bus.execute(GetConfiguration.new)
    puts <<-EOT
    Configured storage:

    * Small:  #{response.small}
    * Medium: #{response.medium}
    * Large:  #{response.large}
    EOT
  end
end

A grandes rasgos, introduciremos los objetos necesarios e iremos conectando todo hasta obtener el correspondiente Walking Sekeleton que nos lleve a ejecutar el handler. De nuevo, hacemos explícito que no está implementado para que los propios tests nos pidan hacerlo.

class GetConfigurationHandler
  def initialize(containers)
    @containers = containers
  end

  def handle(query)
    raise NotImplementedError
  end
end

Una vez que conseguimos que al ejecutar el test se muestre el error NotImplementedError pasamos a desarrollar esta pieza usando TDD.

En el ejemplo anterior utilicé el fake InMemoryContainers. Como tal fake, tiene que implementar el comportamiento del rol Containers por lo que introdujimos el método reconfigure y lo implementamos.

Otra aproximación para hacer lo mismo es usar dobles, de tal modo que no implementamos nada en Containers, sino que simulamos su comportamiento con el doble. De este modo, aprendemos como lo queremos usar. Voy a intentar mostrar el ejemplo.

Por ejemplo, podemos empezar con un test en el que suponemos que Containers no está configurado todavía. Mi idea es que el handler devuelva como respuesta una estructura de datos con todos los campos a 0.

GetConfigurationResponse = Struct.new(:small, :medium, :large)

Para ello, necesito simular que Containers devolverá, a su vez, una estructura similar con los datos de configuración. Lo que todavía no sé es cómo voy a implementar eso, por lo que hacer un doble de ese comportamiento me permite definir como lo voy a invocar y qué respuesta espero. Esto queda recogido en el siguiente test:

RSpec.describe "GetConfigurationHandler" do
  
  context "when empty containers" do
    it "should show all sizes 0" do
      containers = double("containers")
      allow(containers).to receive(:configuration) {
        r = Struct.new(:small, :medium, :large)
        r.new(0, 0, 0)
      }

      handler = GetConfigurationHandler.new(containers)
      response = handler.handle(GetConfiguration.new)
      expected = GetConfigurationResponse.new(
        small: 0,
        medium: 0,
        large: 0
      )
      expect(response).to eq(expected)
    end
  end
end

La implementación del handler es sencilla, porque la chicha está en otra parte.

class GetConfigurationHandler
  def initialize(containers)
    @containers = containers
  end

  def handle(query)
    conf = @containers.configuration
    GetConfigurationResponse.new(
      small: conf.small,
      medium: conf.medium,
      large: conf.large
    )
  end
end

Con esto resuelvo este paso. Podría probar con otros valores de configuración, pero realmente, el handler lo único que hace es pedirle la configuración al objeto Containers y prepara la respuesta con esos datos. No hay muchas vueltas que darle.

Ahora bien, si ejecutamos la feature, que sería el test end to end, veremos que falla porque el método configuration no está definido todavía en InMemoryContainers, que es la implementación que estamos usando. De nuevo, es una llamada a desarrollarlo en un ciclo de TDD clásico.

shared_examples "a Containers" do
  it { is_expected.to respond_to(:available) }
  it { is_expected.to respond_to(:reconfigure) }
  it { is_expected.to respond_to(:configured?) }
  it { is_expected.to respond_to(:configuration) }

  before do
    @containers = described_class.new
  end

  # Code removed for clarity

  describe ".configuration" do
    it "should show 0 containers of each type if not configured" do
      conf = @containers.configuration
      expect(conf.small).to eq(0)
      expect(conf.medium).to eq(0)
      expect(conf.large).to eq(0)
    end

    it "should show current configuration" do
      @containers.reconfigure({small: 2, medium: 4, large: 5})
      conf = @containers.configuration
      expect(conf.small).to eq(2)
      expect(conf.medium).to eq(4)
      expect(conf.large).to eq(5)
    end
  end
end

Esto lo resuelvo con el siguiente método que es muy poco OOP, pero que ahora mismo resuelve lo que necesito:

  def configuration
    conf = Struct.new(:small, :medium, :large)
    c = conf.new(0, 0, 0)
    @containers.each do |container|
      c.small = c.small + 1 if container.is_a? SmallContainer
      c.medium = c.medium + 1 if container.is_a? MediumContainer
      c.large = c.large + 1 if container.is_a? LargeContainer
    end
    c
  end

Se me ocurren dos formas de hacer esto mismo, con mayor limpieza.

  • Guardar la configuración original en una propiedad de Containers. Cuando se recibe la configuración se generan los contenedores necesarios, pero para saber los que tenemos debemos contarlos. El problema de guardar la configuración a mayores es que en algún momento podría desincronizarse.
  • Pasar la struct a cada container, de tal forma que cada uno la incremente como corresponda. Esta es la forma más OOP, ya que no tenemos que preguntar a cada container de qué tipo es.

Algo así:

  def configuration
    conf = Struct.new(:small, :medium, :large)
    c = conf.new(0, 0, 0)
    @containers.each do |container|
      c = container.count(c)
    end
    c
  end

Al ejecutar esto me encuentro con que la feature falla. Pero al fijarme, lo que ocurre es que hay pequeñas diferencias en espacios y retornos. Como no es algo significativo corrijo los tests para que pasen y listo.

Básicamente, este cambio, con el que quitamos caracteres de retorno que se añaden al output.

Then(/^System shows status$/) do |text|
  expect(@output.strip).to eq(text)
end

Una vez que tenemos todo lo anterior, añadir soporte para las otras opciones de tamaño debería ser sencillo:

  Scenario: Adding all containers sizes
    Given the system is not configured
    When Merry configures 2 "small" containers
    When Merry configures 4 "medium" containers
    When Merry configures 3 "large" containers
    Then System shows status
      """
      Configured storage:

      * Small:  2
      * Medium: 4
      * Large:  3
      """

Realmente, tengo que hacer más cambios en los tests para poder configurar varios tamaños:

Given(/^the system is not configured$/) do
  @storage = CliAdapterFactory.for_test({})
  @arguments = ["configure"]
end

When(/^Merry sends no configuration$/) do
  @output = capture_stdout { @storage.run(%w[configure]) }
end

Then(/^System shows status$/) do |text|
  @output = capture_stdout { @storage.run(@arguments) }
  expect(@output.strip).to eq(text)
end

When(/^Merry configures (\d+) "([^"]*)" containers$/) do |qty, size|
  @arguments.append "--#{size}=#{qty}"
end

Que en el propio código de producción, en el que solo tengo que tocar la ActionFactory para que prepare las opciones necesarias:

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

  def for_subcommand(args)
    parser = OptionParser.new(args)
    sub_command = args[0]
    case sub_command
      
      # Removed code for clarity
    
    when "configure"
      small = parser.by_name_or_default("small", 0).to_i
      medium = parser.by_name_or_default("medium", 0).to_i
      large = parser.by_name_or_default("large", 0).to_i
      conf = {}
      conf[:small] = small unless small == 0
      conf[:medium] = medium unless medium == 0
      conf[:large] = large unless large == 0
      ConfigureAction.new(@command_bus, @query_bus, conf)
    else
      NoAction.new
    end
  end
end

Aumentando una feature y pagando la deuda técnica

Llegadas a este punto parece claro que hay un escenario no contemplado. Hemos dicho que, por el momento, no vamos a querer que se pueda cambiar la configuración de contenedores una vez establecida una. Quizá en el futuro sea una propiedad interesante del sistema, pero ahora mismo no nos interesa. Por tanto, necesitamos especificar eso y definir un comportamiento.

  Scenario: Configured system
    Given the system is already configured with
      | size   | qty |
      | small  | 2   |
      | medium | 4   |
      | large  | 3   |
    When Merry configures 5 "small" containers
    And Merry configures 2 "medium" containers
    And Merry configures 1 "large" containers
    Then System shows status
      """
      Configured storage:

      System is already configured

      * Small:  2
      * Medium: 4
      * Large:  3
      """

Estos son los pasos definidos, junto con un par de funciones de ayuda.

Given(/^the system is not configured$/) do
  @storage = CliAdapterFactory.for_test({})
  @arguments = ["configure"]
end

Given(/^the system is already configured with$/) do |table|
  configuration = configuration_from(table)

  @storage = CliAdapterFactory.for_test(configuration)
  @arguments = ["configure"]
end

When(/^Merry sends no configuration$/) do
  @output = capture_stdout { @storage.run(%w[configure]) }
end

Then(/^System shows status$/) do |text|
  @output = capture_stdout { @storage.run(@arguments) }
  expect(@output.strip).to eq(text)
end

When(/^Merry configures (\d+) "([^"]*)" containers$/) do |qty, size|
  @arguments.append "--#{size}=#{qty}"
end

def capture_stdout
  original = $stdout
  foo = StringIO.new
  $stdout = foo
  yield
  $stdout.string
ensure
  $stdout = original
end

def configuration_from(table)
  definitions = table.raw.drop(1)

  configuration = {}
  definitions.each do |size, qty|
    qty.to_i.times do
      configuration[size.to_sym] = qty.to_i
    end
  end
  configuration
end

Actualmente, InMemoryContainers ya controla que no se modifique la configuración actual si existe. Aun así, el escenario no pasa completamente porque no se inserta el mensaje que necesitamos:

RSpec::Expectations::ExpectationNotMetError: 
expected: "Configured storage:\n\nSystem is already configured\n\n* Small:  2\n* Medium: 4\n* Large:  3"
     got: "Configured storage:\n\n* Small:  2\n* Medium: 4\n* Large:  3"

El problema es que no tenemos forma de saber si ha cambiado o no la configuración. Esto puede ser debido a que lo gestionamos mal en su momento. Si nos fijamos en el método reconfigure se debería ver claramente el problema.

  def reconfigure(conf)
    if configured?
      return
    end
    @containers = []
    conf.each do |size, qty|
      qty.times do |index|
        name = "#{size[0]}-#{index + 1}"
        add(Container.of_capacity(size.to_sym, name))
      end
    end
  end

Se podría decir que reconfigure falla silenciosamente si intentamos configurar un sistema que ya está configurado y no debería.

Además, es que aquí hay varios errores más. El primero es el nombre, ya que reconfigure nos indica la posibilidad de volver a configurar, cosa que no es cierta. El nombre se lo hemos puesto para evitar el conflicto con configure, que es un método factoría y no estoy del todo seguro que tenga uso aparte de los tests. Tendríamos que corregir esto.

La feature en sí es necesaria, aunque solo se usaría una vez en el caso más habitual. Quizá install sea un nombre mejor, ya que indicaría que se trata de una primera vez y no se espera que algo esté instalado, por lo que tendría más sentido el siguiente paso.

La forma más sencilla de notificar el problema es mediante una excepción. Es anómalo intentar instalar algo que ya está instalado. Al lanzar la excepción es posible capturarla y reaccionar de acuerdo a eso, que es lo que necesitamos poder hacer en la ConfigureAction.

Así que lo primero que voy a hacer es cambiar el nombre del método reconfigure a install. Una vez tengo esto, modifico la especificación de Containers para que falle con excepción si intento instalar un sistema ya instalado.

  describe ".install" do
    it "should have capacity of containers configured" do
      @containers.install({small: 1})
      expect(@containers.total_space).to eq(Capacity.new(4))
    end
    it "should have capacity of containers combined" do
      @containers.install({small: 2, medium: 2})
      expect(@containers.total_space).to eq(Capacity.new(20))
    end
    it "should fail if already configured" do
      @containers = described_class.configure({small: 2})

      expect {
        @containers.install({small: 4})
      }.to raise_error(AlreadyInstalled)
    end
  end

E implementado así:

  def install(conf)
    if configured?
      raise AlreadyInstalled.new
    end
    @containers = []
    conf.each do |size, qty|
      qty.times do |index|
        name = "#{size[0]}-#{index + 1}"
        add(Container.of_capacity(size.to_sym, name))
      end
    end
  end

Nada del otro jueves. Una vez resuelto esto y pasando la especificación de Containers, podemos volver a la feature. Al ejecutarla vemos que se ha lanzado la excepción AlreadyInstalled, haciendo fallar el escenario. Pero ahora ya sabemos dónde intervenir.


class ConfigureAction
  def initialize(command_bus, query_bus, conf)
    @command_bus = command_bus
    @query_bus = query_bus
    @conf = conf
  end

  def execute
    @command_bus.execute(Configure.new(@conf))
  rescue ContainersNotYetConfigured
    puts <<-EOT
    Configured storage:
    
    System is not configured
    EOT
  rescue AlreadyInstalled
    response = @query_bus.execute(GetConfiguration.new)
    puts <<~EOT
      Configured storage:

      System is already configured
      
      * Small:  #{response.small}
      * Medium: #{response.medium}
      * Large:  #{response.large}
    EOT
  else
    response = @query_bus.execute(GetConfiguration.new)
    puts <<~EOT
      Configured storage:
      
      * Small:  #{response.small}
      * Medium: #{response.medium}
      * Large:  #{response.large}
    EOT
  end
end

Todo este proceso nos permite ejemplificar el problema de la deuda técnica cuando estamos desarrollando un producto. Empezamos lanzando funcionalidad basándonos en el conocimiento que tenemos. Gracias a eso logramos aprender cosas sobre nuestro producto y toca volver a reflejar ese conocimiento en el código. Si el código y el conocimiento no se reconcilian regularmente, llegamos a un punto en el que resulta difícil introducir nuevas features o mejorar las que tenemos.

Ahora podría plantearse si sería necesario trasladar este nuevo conocimiento y terminología a todos los demás elementos del código, así como revisar el papel del método configure.

¿Como haríamos esto en un equipo?

Partimos de la premisa de que un equipo ágil trabaja orientado a la entrega de valor. Muchos equipos no hacen esto, sino que trabajan orientados a tareas, las cuales pueden contribuir a la entrega de valor, o no. Según esto, todo el equipo contribuye a la feature que se haya decidido desarrollar en este momento. Esto incluye el paso de historia de usuario a las decisiones sobre como se va a implementar y el proceso de desarrollo en sí.

Por tanto, la discusión y decisiones de diseño sobre la feature las haríamos en equipo, así como la creación de las features y escenarios Gherkin. También los acuerdos sobre las conversaciones y los puertos que haya que definir. Sería ideal convertir la historia de usuario, que no debería ser más que la expresión de una necesidad, en una o varias especificaciones por ejemplos.

Teniendo una herramienta como Cucumber (o cualquiera de sus ports a otros lenguajes), la verdad es que tiene mucho sentido expresar las especificaciones como features en Gherkin, lo que hace mucho más fácil para no desarrolladoras participar, generalmente explicando cuáles son los comportamientos esperados, el valor que pueden aportar, los casos que realmente son valiosos, etc.

Por otro lado, una duda típica es la división por especialidad. Si el adapter es, por ejemplo, una interfaz web: ¿dónde se divide el trabajo para la gente de frontend y la de backend?

Creo que en un equipo realmente ágil la pregunta no es adecuada. Sencillamente, cada persona del equipo podrá aportar en mayor o menos grado en cada cosa que sea necesario hacer. Es decir, las tareas específicas no son compartimentos estancos y un equipo verdaderamente ágil no gestiona carga de trabajo, sino entrega de valor.

Así que el equipo trabajaría al completo hasta el punto en que sea más adecuado repartirse por especialización. Por ejemplo, la aplicación web y el backend. La idea es que lleguemos a un momento en el que ya tengamos muy claros los contratos de los puertos, el tipo de funcionalidad que esperamos de la otra parte, etc. Pero la idea de fondo es que todas tengamos muy claro cómo funciona la feature y hagamos esta división cuando el proceso de discusión ya esté muy avanzado. Con todo, pienso que el trabajo conjunto de frontend y backend garantiza que se pueda obtener mucho feedback necesario en el momento, permitiendo llegar a acuerdos que faciliten el trabajo de la otra especialidad.

En algunos casos, las historias de usuario pueden ser lo bastante pequeñas como para que no sea necesario que todo el equipo esté centrado en una sola. Por supuesto, siempre hay cosas que hacer: empezar a trabajar en la siguiente prioridad, o tal vez resolver un bug que acaba de ser notificado, o incluso gestionar deuda técnica, entendida como refactorizar el código existente para que refleje mejor el conocimiento que vamos adquiriendo.

November 8, 2023

Etiquetas: good-practices   design-patterns   hexagonal  

Temas

good-practices

refactoring

php

testing

tdd

design-patterns

python

blogtober19

design-principles

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

hexagonal

tips

ddd

books

bbdd

software-design

soft-skills

pulpoCon

oop

javascript

api

sql

ethics

agile

typescript

swift

java