Intentando llegar a buen puerto con Arquitectura Hexagonal y BDD

por Fran Iglesias

Algunas correcciones de rumbo antes de proseguir con el desarrollo de la aplicación de gestión de un punto de recogida de paquetes. Quizá no hay mucho de arquitectura hexagonal en este artículo, pero espero que sea útil igualmente.

Puedes leer aquí el artículo anterior

Los tests deben fallar, al menos al principio

Un aspecto que me molesta un poco en el estado actual del proyecto es que el escenario de aceptación no falla. Un test que no falla no proporciona información y lo que queremos es que nos diga qué es lo que tenemos que hacer a continuación.

El motivo de que ahora no falle el test es haber construido el código usando un enfoque TDD clásico, introduciendo la implementación mínima necesaria para que el test pueda pasar. El problema, en parte, es que lenguajes como Ruby nos permiten una implementación vacía de un método. La otra parte es haber introducido implementaciones fake para ir saliendo adelante.

En el caso de los comandos, que como sabemos no devuelven nada sino que provocan un efecto en el sistema, los tests van a pasar si no chequeamos que se haya producido el efecto esperado, lo que puede incluir código que no hace nada. Por desgracia, en este momento inicial de desarrollo puede que aún no sepamos ni que efecto queremos que se produzca, ni como verificarlo.

En las queries, basta con introducir la implementación fake que devuelva lo que pide el test.

Para que el código pueda evolucionar desde esta situación, siempre desde una metodología clásica, la mejor opción que tenemos es introducir tests que desafíen la implementación actual, obligándonos a introducir código más flexible. Lo que ocurre es que hacerlo en el ciclo de aceptación puede ser bastante costoso. Al menos en nuestro ejemplo, en el que el escenario es intencionadamente genérico.

En TDD outside-in suelo apoyarme en la idea de lo que podríamos llamar implementaciones pendientes. Es decir, declarar de forma explícita que una implementación no está completa. Esto no solo hace que el test de aceptación falle, sino que también nos dice dónde deberíamos seguir trabajando.

En el estado actual del código podríamos conseguir esto de dos maneras:

  • eliminando (o no poniendo desde el principio) método vacíos, de modo que el escenario no se pueda ejecutar hasta que los añadimos de nuevo y los implementamos. Diría que esta forma va mejor en lenguajes interpretados, ya que el error se va a producir cuando el test intente ejecutar ese método y no evita que la parte ya implementada pueda correr igualmente.
  • haciendo que los métodos no implementados generen una excepción o error que indique precisamente que están pendientes de tener código. Esta forma aplica a los lenguajes compilados, ya que permite compilar la aplicación y correr la parte ejecutable del test, hasta fallar en el punto preciso por el que deberíamos continuar.

Vamos a verlo aplicado a nuestro ejemplo.

El paso del escenario que queremos desarrollar ahora es este:

Then("first available container is located") do
  available_container = AvailableContainer.new
  available_container_handler = AvailableContainerHandler.new
  response = available_container_handler.handle(available_container)
  @container = response.container
end

Queremos que la ejecución del test falle indicándonos que tenemos que implementar AvailableContainerHandler.handle, que ahora mismo tiene esta implementación, con la cual pasamos el escenario:

class AvailableContainerHandler
  def handle(available_container)
    AvailableContainerResponse.new
  end
end

Con la primera técnica, simplemente quitamos el código. El intérprete de Ruby indicará el fallo, dejándonos claro que tendríamos que bajar al nivel unitario para seguir el desarrollo.

class AvailableContainerHandler

end

Esto es lo que ocurre al ejecutar el escenario, que nos dice cual es exactamente el problema y, en consecuencia, cuál es el siguiente paso en que deberíamos concentrarnos.

Then first available container is located     # features/step_definitions/steps.rb:19
  undefined method `handle' for #<AvailableContainerHandler:0x00000001113ddaa8>

    response = available_container_handler.handle(available_container)
                                          ^^^^^^^ (NoMethodError)
  ./features/step_definitions/steps.rb:22:in `"first available container is located"'
  features/register_packages.feature:11:in `first available container is located'

Ahora voy a demostrar la otra técnica. Ruby incluye un tipo de error que encaja perfectamente con lo que necesitamos.

class AvailableContainerHandler
  def handle(available_container)
    raise NotImplementedError "Please, implement AvailableContainerHandler.handle"
  end
end

Y esto es lo que ocurre cuando ejecutamos el test. Como podemos ver, es prácticamente lo mismo.

Then first available container is located     # features/step_definitions/steps.rb:19
  undefined method `NotImplementedError' for #<AvailableContainerHandler:0x000000010d425960>

      raise NotImplementedError "Please, implement AvailableContainerHandler.handle"
            ^^^^^^^^^^^^^^^^^^^ (NoMethodError)
  ./lib/app/for_registering_packages/available_container/available_container_handler.rb:7:in `handle'
  ./features/step_definitions/steps.rb:22:in `"first available container is located"'
  features/register_packages.feature:11:in `first available container is located'

Para el caso de Ruby me parece que la primera técnica da un mensaje de error mucho más claro. Pero, como he mencionado hace un momento, en otros lenguajes, la técnica de lanzar un error o excepción es más recomendable, ya que permite compilar el proyecto.

Para qué nos sirve que los tests fallen

Los test que fallan nos proporcionan información. Existe el dicho de “no confíes en un test que no falle” y es precisamente porque si bien un test que falla tras un cambio del código nos dice exactamente qué es lo que no funciona, un test que no falla después de introducir un cambio no nos dice nada.

Por esa razón, ahora hemos decidido que tenemos que hacer que los tests que antes pasaban dejen de hacerlo, para poder introducir el código necesario que los pueda hacer pasar. Además, el test concreto que falla nos dice exactamente el lugar donde necesitamos intervenir.

Y al tratarse de una metodología outside-in, cuando un test nos dice que algo está sim implementar, es el momento de diseñar ese componente.

El componente en cuestión es AvailableContainerHandler.

Disponibilidad de contenedores

De entrada, la primera cuestión bastante obvia es que este caso de uso debería obtener la colección de contenedores de algún lugar, para poder determinar cuál es el primero disponible para el paquete recibido.

Si lo recuerdas, el escenario asumía que habría disponibilidad de espacio suficiente para cualquier paquete que pudiésemos haber registrado. De hecho, para cumplir el primer escenario podríamos tener un paquete de tamaño 3 y un contenedor con capacidad 4 (el más pequeño).

Scenario: There is space for allocating package
    When Merry registers a package
    Then first available container is located
    And he puts the package into it

En estas condiciones ni siquiera necesitamos saber ni el tamaño del paquete ni el del contenedor. Pero sí podríamos plantear un diseño básico. La regla de negocio nos pide obtener el primer contenedor que disponga de espacio. Por otro lado, el caso de uso no debería hacer otra cosa que actuar de coordinador. Por tanto, la responsabilidad de proporcionar el contenedor adecuado debería ser de algún servicio.

Más o menos tal como expresa este test:

RSpec.describe "AvailableContainerHandler" do
  context "when used for first time" do
    it "should provide available container" do
      containers = double("Containers")
      expected = Container.new
      allow(containers).to receive(:available).and_return(expected)
      query = AvailableContainer
      handler = AvailableContainerHandler.new(containers)
      response = handler.handle(query)
      container = response.container

      expect(container).to eq(expected)
    end
  end
end

Esto nos permite introducir una primera implementación del caso de uso y de su objeto response:

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

  def handle(available_container)
    available = @containers.available
    AvailableContainerResponse.new(available)
  end
end
class AvailableContainerResponse
  attr_reader :container

  def initialize(container)
    @container = container
  end
end

Que hacen pasar el test. Elementos a destacar:

  • Todavía no estamos tomando en cuenta ni el tamaño del paquete ni el del contenedor y, si vamos a eso, ni siquiera si tenemos contenedores. Pero es que tampoco tenemos ningún test que nos fuerce a implementar algo más. Ya llegará el momento.
  • Hemos definido lo que esperamos de un objeto que representa la colección de contenedores disponible. Por ahora, es una abstracción que tendremos que especificar del mismo modo que hicimos en el artículo anterior con PackageQueue. Sabemos que Containers tendrá que exponer un método available que nos devolverá el contenedor que debemos usar.

Lentitud ahora, rapidez después

Puede que te haya llamado la atención que vamos avanzando bastante despacio, en pasos muy pequeños y que no estamos intentando resolver muchas circunstancias del problema que son obvias desde el principio, como lo que acabo de mencionar sobre el tamaño de paquetes y contenedores.

Esto es totalmente intencional. Se dice que la arquitectura de software consiste en la toma de decisiones que son muy caras de deshacer. Por tanto, estoy intentando avanzar de tal modo que pueda corregir mi rumbo con facilidad.

Tiene que ver con el llamado síndrome del coste hundido (sunken cost, aunque yo prefiero traducirlo como coste sumergido). Este fenómeno se da cuando evitamos aplicar cambios necesarios en una estrategia porque hemos invertido mucho esfuerzo en desarrollarla. Este coste sumergido, una inversión de tiempo, esfuerzo y dinero, nos bloquea cuando tenemos que decidir si seguir por esa línea o reconducir nuestro proyecto.

En desarrollo de software hablamos del principio YAGNI1 (no lo vas a necesitar) que básicamente aboga por concentrarnos única y exclusivamente en la tarea más necesaria ahora mismo, sin tratar de anticiparnos a requisitos por venir. Incluso cuando esos requisitos son esperables. Esto nos evita casarnos con decisiones difíciles de revertir.

En resumen, avanzar ahora lentamente, nos permitirá adquirir velocidad de desarrollo en el futuro.

Retomando el desarrollo

Ahora que los tests unitarios de AvailableContainerHandler pasan, volvemos a ejecutar el escenario. El escenario fallará debido a los cambios que hemos aplicado.

ArgumentError: wrong number of arguments (given 0, expected 1)
/Users/frankie/Projects/storage/lib/app/for_registering_packages/available_container/available_container_handler.rb:6:in `initialize'

Necesitamos introducir un objeto Containers para pasarlo a AvailableContainerHandler en su construcción. Algo así, teniendo en cuenta que Containers es una abstracción y que necesitamos una implementación del adaptador que podamos usar en tests:

Then("first available container is located") do
  containers = InMemoryContainers.new
  available_container = AvailableContainer.new
  available_container_handler = AvailableContainerHandler.new(containers)
  response = available_container_handler.handle(available_container)
  @container = response.container
end

Y llegamos a este punto, en el que ejecutar el escenario nos dice que:

NameError: uninitialized constant InMemoryContainers

InMemoryContainers es un adaptador y debería implementar la interfaz Containers que aún no hemos definido formalmente. En otros lenguajes habríamos tenido que declararla para poder crear su doble de test. En Ruby no es necesario, porque incluso podríamos escribir un adapter válido con tal de que pase el duck typing2, pero nos interesa hacerlo explícito. Además, al hacerlo en forma de especificación, nos garantizamos no solo que tiene los métodos necesarios, sino que exhibe el comportamiento necesario.

Así que hagámoslo, primero definiendo lo más básico:

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

  before do
    @containers = described_class.new
  end

  describe ".available" do
    it "returns a container with enough space" do
      container = @containers.available

      expect(container).to be_a(Container)
    end
  end
end

Para hacer pasar esta especificación nos basta con una implementación bastante fake:

class InMemoryContainers
  def available
    Container.new
  end
end

Implementación que es suficiente para superar el segundo paso del escenario.

Scenario: There is space for allocating package # features/register_packages.feature:9
When Merry registers a package                # features/step_definitions/steps.rb:12
Then first available container is located     # features/step_definitions/steps.rb:20
And he puts the package into it               # features/step_definitions/steps.rb:28
  undefined method `handle' for #<StorePackageHandler:0x00000001060175c8>

    store_package_handler.handle(store_package)
                         ^^^^^^^ (NoMethodError)
  ./features/step_definitions/steps.rb:31:in `"he puts the package into it"'
  features/register_packages.feature:12:in `he puts the package into it'

Puesto que es suficiente, avanzamos al siguiente paso y a lo que nos indica el test: tenemos que implementar StorePackageHandler.

Para este último paso vamos a necesitar acceder a la cola de paquetes PackageQueue que usamos en el primer paso del escenario y probablemente también a la colección de contenedores Containers, al menos para asegurarnos de que guardamos el contenedor con el paquete asignado.

Si estás pensando un poco por adelantado, es posible que ya estés considerando problemas como ¿qué ocurre si entra un nuevo paquete y se le asigna el mismo contenedor antes de guardar el anterior? Y cosas por el estilo. Como he dicho, vamos a ir centrándonos en el problema actual. Una vez resuelto, usaremos nuevos escenarios y tests para cuestionar la solución que tengamos hasta ahora y así cubrir todo ese abanico de casos.

Para desarrollar StorePackageHandler tenemos que ir al nivel unitario otra vez. Esta es mi primera versión de su especificación:

RSpec.describe StorePackageHandler do
  before do
    # Do nothing
  end

  after do
    # Do nothing
  end

  context "Storing in container" do
    it "should store package in provided container" do
      package_queue = double("PackageQueue")
      containers = double("Containers")
      package = Package.new("locator")

      allow(package_queue).to receive(:get).and_return(package)
      expect(containers).to receive(:update) do |c|
        expect(c.contains?("locator")).to be_truthy
      end

      handler = StorePackageHandler.new(package_queue, containers)
      command = StorePackage.new(Container.new)

      handler.handle(command)
    end
  end
end

Y aquí la implementación básica:

class StorePackageHandler
  def initialize(package_queue, containers)
    @package_queue = package_queue
    @containers = containers
  end

  def handle(store_package)
    package = @package_queue.get
    container = store_package.container
    container.store(package)
    @containers.update(container)
  end
end

Al re-implementar los pasos del escenario, vemos que nos tenemos algún error, ya que hemos introducido nuevos métodos en algunas interfaces:

Given("Merry registers a package") do
  @memory_package_queue = InMemoryPackageQueue.new
  @locator = "some-locator"
  register_package = RegisterPackage.new @locator
  register_package_handler = RegisterPackageHandler.new @memory_package_queue
  register_package_handler.handle(register_package)
end

Then("first available container is located") do
  @containers = InMemoryContainers.new
  available_container = AvailableContainer.new
  available_container_handler = AvailableContainerHandler.new(@containers)
  response = available_container_handler.handle(available_container)
  @container = response.container
end

Then("he puts the package into it") do
  store_package = StorePackage.new(@container)
  store_package_handler = StorePackageHandler.new(@memory_package_queue, @containers)
  store_package_handler.handle(store_package)

  expect(@container.contains?(@locator)).to be_truthy
end

Por ejemplo:

Scenario: There is space for allocating package # features/register_packages.feature:9
When Merry registers a package                # features/step_definitions/steps.rb:12
Then first available container is located     # features/step_definitions/steps.rb:20
And he puts the package into it               # features/step_definitions/steps.rb:28
  undefined method `get' for #<InMemoryPackageQueue:0x000000010e48cb28 @packages=[#<Package:0x000000010e48c7b8 @locator="some-locator">]>

      package = @package_queue.get
                              ^^^^
  Did you mean?  gem (NoMethodError)

Esto supone cambiar la especificación:

shared_examples "a PackageQueue" do
  it { is_expected.to respond_to(:put).with(1).argument }

  before do
    @queue = described_class.new
  end

  describe ".put" do
    it "accepts packages" do
      a_package = Package.register("locator")
      @queue.put(a_package)

      expect(@queue).to include(a_package)
    end
  end

  describe ".get" do
    before do
      @a_package = Package.register("locator")
      @queue.put(@a_package)
    end
    it "gives first package" do
      recovered = @queue.get
      expect(recovered).to be(@a_package)
    end
    it "removes package from queue" do
      @queue.get
      expect { @queue.get }.to raise_error(NoMorePackages)
    end
  end
end

La cual nos guiará en la implementación de InMemoryPackageQueue:

class InMemoryPackageQueue
  def initialize
    @packages = []
  end

  def put(package)
    @packages.unshift(package)
  end

  def get
    if @packages.length < 1
      raise NoMorePackages
    end
    @packages.pop
  end

  def include?(package)
    @packages.include?(package)
  end
end

Con esta versión podemos avanzar un poquito más en el escenario. Containers necesita un método update, que es un nombre horroroso, pero que voy a usar mientras no se me ocurre nada mejor.

Scenario: There is space for allocating package # features/register_packages.feature:9
    When Merry registers a package                # features/step_definitions/steps.rb:12
    Then first available container is located     # features/step_definitions/steps.rb:20
    And he puts the package into it               # features/step_definitions/steps.rb:28
      undefined method `update' for #<InMemoryContainers:0x000000010c4546f8>
    
          @containers.update(container)
                     ^^^^^^^ (NoMethodError)

Así que tenemos que hacer el mismo proceso. Añadir una especificación para el método update e implementarla en el adapter que estamos usando.

require "rspec"
require_relative "../domain/container"
require_relative "../domain/package"

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

  before do
    @containers = described_class.new
  end

  describe ".available" do
    it "returns a container with enough space" do
      container = @containers.available

      expect(container).to be_a(Container)
    end
  end

  describe ".update" do
    it "updates container info" do
      container = Container.new
      container.store(Package.new("locator"))
      @containers.update(container)
      recovered = @containers.available
      expect(recovered.contains?("locator")).to be_truthy
    end
  end
end
class InMemoryContainers
  def initialize
    @container = Container.new
  end

  def available
    @container
  end

  def update(container)
    @container = container
  end
end

Y con estos cambios el escenario ya pasa completamente. Aunque todavía es muy limitada y muy simple, ya tenemos la capacidad de aceptar paquetes.

La implementación de InMemoryContainers solo nos vale para aquellos ejemplos en los que el contenedor admita nuevos paquetes, pero tendríamos que hacerla algo más inteligente, devolviendo el contenedor únicamente si puede admitir más paquetes.

Puedes ver el código hasta este punto en el repositorio.

Consideraciones hasta el momento

Una de las ventajas de Arquitectura Hexagonal es precisamente separar la lógica de la aplicación en sí, de la de los adaptadores. Esto facilita el desarrollo del modelo de dominio, ya que nos podemos olvidar de todos los condicionantes que nos imponen las distintas tecnologías específicas de los adaptadores.

Al relatar el desarrollo de esta forma en el artículo, el proceso puede parecer lento. Sin embargo, la verdad es que en tiempo real transcurre bastante rápido. Gracias a que hemos rebanado muy finamente la funcionalidad, la mayor parte de las implementaciones que hemos tenido que hacer hasta ahora son bastante obvias. Si bien es cierto que aún queda trabajo por hacer hasta que el sistema pueda ponerse en producción, incluso considerando solo la parte del registro de paquetes.

El trabajo a continuación consiste en introducir las prestaciones que necesitamos ayudándonos con nuevos escenarios de aceptación y seguir el doble ciclo outside-in hasta implementarlos.

Nuevos escenarios

Para continuar voy a introducir este escenario en el que definimos que si no hay espacio para guardar un paquete, quede en la cola de espera:

  Scenario: There is no enough space for allocating package
    Given no container with enough space
    When Merry registers a package
    Then package stays in queue

Y defino los siguientes pasos:

# There is no enough space for allocating package

Given("no container with enough space") do
  @containers = InMemoryContainers.new
  full_container = FullContainer.new
  @containers.update(full_container)

  available_container = AvailableContainer.new
  available_container_handler = AvailableContainerHandler.new(@containers)
  response = available_container_handler.handle(available_container)
  @container = response.container

  expect(@container).to be_nil
end

Then("package stays in queue") do
  expect(@memory_package_queue.contains?(@locator)).to be_truthy
end

Para resolver el problema tenemos la opción de poder definir el contenedor con el que iniciamos InMemoryContainers, bien sea a través de su constructora o del método update. Pero también tenemos que conseguir que un Container pueda estar lleno, cosa que no es posible todavía. Por el momento, no hemos preparado ningún escenario en el que se haga mención explícita de los tamaños de los paquetes o las capacidades de los contenedores. Pero la verdad es que, de momento, todavía no quiero hacerlo.

Lo que hacemos aquí es aplicar un patrón Null Object y diseñar un tipo de contenedor que siempre está lleno, por lo que siempre fallará al intentar guardar algo en él. Como era de esperar, si ejecutamos el escenario nos pedirá implementar FullContainer.

RSpec.describe Container do
    context "Storing packages" do
      it "fails when is full" do
        container = FullContainer.new
        package = Package.register("new")
        
        expect { container.store(package) }.to raise_error(NoSpaceInContainer)
      end
    end
end

La implementación de FullContainer es sencilla: simplemente extendemos Container y sobreescribimos el método store.

class FullContainer < Container
  def store(package)
    raise NoSpaceInContainer.new
  end
end

Una vez aplicados estos cambios, al ejecutar el escenario, falla porque AvailableContainerHandler no debería devolver ningún contenedor. Esta lógica en último término está a cargo de Containers, implementado por InMemoryContainers, responsable de decirnos qué contenedor está disponible.

En la implementación fake, simplemente nos devuelve el único contenedor que tiene hasta el momento, sin más, pero necesitamos que le pregunte el ese contenedor si tiene espacio para el nuevo paquete. En resumen: Container tiene que ser capaz de decirnos si admite más paquetes o no. Y de momento nos basta con que un contenedor normal nos diga que sí, y el contenedor lleno, nos diga que no.

Vamos a cambiar la especificación de Container para implementar esto.

RSpec.describe "Container" do
  context "Space available" do
    it "should be available if empty" do
      expect(Container.new.available?).to be_truthy
    end

    it "should be not available if full" do
      expect(FullContainer.new.available?).to be_falsey
    end
  end

  context "Storing packages" do
    it "fails when is full" do
      container = FullContainer.new

      package = Package.register("new")
      expect { container.store(package) }.to raise_error(NoSpaceInContainer)
    end
  end
end

De momento, es bastante fácil:

class Container
  def initialize
    @packages = []
  end

  def contains?(locator)
    true
  end

  def available?
    true
  end

  def store(package)
    @packages.append(package)
  end
end
class FullContainer < Container
  def store(package)
    raise NoSpaceInContainer.new
  end

  def available?
    false
  end
end

Ahora tenemos que hacer que InMemoryContainers le pregunte a su único contenedor si tiene espacio disponible3.

Para ello, modificamos también la especificación:

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

  before do
    @containers = described_class.new
  end

  describe ".available" do
    it "should return a container with enough space" do
      container = @containers.available

      expect(container).to be_a(Container)
    end

    it "should return nil if no container with available space" do
      @containers.update(FullContainer.new)
      container = @containers.available

      expect(container).to be_nil
    end
  end

  describe ".update" do
    it "updates container info" do
      container = Container.new
      container.store(Package.new("locator"))
      @containers.update(container)
      recovered = @containers.available
      expect(recovered.contains?("locator")).to be_truthy
    end
  end
end

Y esto debería servir por el momento:

class InMemoryContainers
  def initialize
    @container = Container.new
  end

  def available
    return @container if @container.available?
    nil
  end

  def update(container)
    @container = container
  end
end

Una vez que hemos hecho pasar todas las especificaciones en el nivel unitario, volvemos a ejecutar el escenario. Este nos pide implementar un método contains? en PackageQueue para poder verificar que el paquete sigue ahí. Sin embargo, en vez de introducir un método para el test, quizá podríamos simplemente usar lo que tenemos y extraer el paquete que debería estar ahí. Así que cambiemos la definición del escenario:

Then("package stays in queue") do
  recovered = @memory_package_queue.get
  expect(recovered.locator).to eq(@locator)
end

Esto supone añadir un attr_reader a Package (el equivalente de un getter en Ruby), pero hace su función y el escenario pasa completamente.

Hay que tener en cuenta que cuando la aplicación esté en producción no se ejecuta automáticamente la acción de guardar el paquete: primero se obtiene el contenedor y luego la persona encargada ejecuta la acción física de guardar el paquete y la registra, lo que equivale a ejecutar la acción de guardar. Si no hay contenedor disponible, simplemente lo deja en la cola.

El segundo escenario queda así implementado. Nuestros siguientes pasos nos deberían llevar a considerar los tamaños de paquetes y contenedores, a fin de completar la definición de este puerto.

Este es el último commit hasta el momento.

Refinando el puerto

Cuando comenzamos a diseñar un software no siempre podemos tener todos los detalles desde el primer momento. Sin embargo, en lugar de aplicar un método waterfall y tratar de tomar en consideración todos los requisitos imaginables, podemos permitirnos empezar con una idea más grosera y descubrir los detalles a medida que vamos construyendo. Al contrario que en la ingeniería física, la ingeniería de software nos permite avanzar de una manera más tentativa, comenzando con implementaciones muy sencillas que actúan a modo de prototipos, pero que pueden evolucionar e incorporar cambios. Esta flexibilidad la podemos, y debemos, aprovechar para comenzar los desarrollos incluso sin conocer completamente el dominio, tan solo lo necesario para poder entregar un mínimo de valor que marque la diferencia entre usar o no usar el software.

Usando este enfoque, hemos establecido las líneas maestras y la estructura de nuestra aplicación, definiendo los flujos de la acción de registrar paquetes. Ahora viene el momento de fijarnos en los detalles que, en nuestro ejemplo, consisten en:

  • Tener en consideración el tamaño de los paquetes y los contenedores para determinar la disponibilidad de espacio y decidir el contenedor en el que guardar cada paquete
  • Configurar varios contenedores y decidir cuál de ellos es el adecuado en cada registro

Para empezar, vamos a seguir considerando un solo contenedor, pero esta vez definiendo su capacidad. Veamos si podemos expresarlo en un escenario:

Scenario: Having a container with capacity
    Given container with capacity 4 that is empty
    When Merry registers a package of size 2
    Then package is allocated in container
    And he puts the package into it
    

Si lo examinamos con detenimiento este escenario es exactamente el mismo que el primero, con la diferencia de que hemos hecho explícitos la capacidad del contenedor y el tamaño del paquete. ¿Podría ser más interesante probar otro escenario? Es posible, pero también supondría algo más de complejidad, así que vamos a intentarlo con este.

Pero hay otra cosa que llama la atención: el escenario suena poco natural. Los tamaños expresados con números hacen que sea más difícil entender. Casi podemos apostar a que en la vida real nos referiríamos a los distintos tamaños como “pequeño”, “mediano” y “grande”. Si reescribimos el escenario de esta forma creo que queda más claro lo que expresa4:

Scenario: Having a container with capacity
    Given an empty "small" container
    When Merry registers a "medium" size package
    Then package is allocated in container
    And he puts the package into it

Es conveniente documentar esto y podemos hacerlo en el mismo documento, que quedaría así:

Feature: Registering packages
  Packages are registered and assigned to a container where they will be stored

  Rules:
    - Packages can have sizes of 1, 2 or 3 volumes, so:
      * Small = 1 vol
      * Medium = 2 vols
      * Large = 3 vols
    - Containers can have capacity for 4, 6 or 8 volumes, so:
      * Small = 4 vol
      * Medium = 6 vol
      * Large = 8 vol
    - A Package that cannot be allocated goes to a waiting queue

  Scenario: There is space for allocating package
    When Merry registers a package
    Then first available container is located
    And he puts the package into it

  Scenario: There is no enough space for allocating package
    Given no container with enough space
    When Merry registers a package
    Then package stays in queue

  Scenario: Having a container with capacity
    Given an empty "small" container
    When Merry registers a "small" size package
    Then package is allocated in container
    And he puts the package into it

A mí, el escenario ya me está empezando a sugerir value objects como PackageSize y ContainerCapacity. En ambos casos representan conceptos que son importantes para este dominio y todo apunta a que tendrán comportamiento propio.

Pero no nos adelantemos. Definir los pasos de este escenario nos va a ayudar a perfilar el puerto “for registering packages” al incorporar datos que deben intercambiarse en esa conversación. Vamos a ver una posible implementación:

# Having a container with capacity
#
Given("an empty {string} container") do |capacity|
  @containers = InMemoryContainers.new
  @small_container = Container.of_capacity(capacity)
  @containers.update(@small_container)
end

When("Merry registers a {string} size package") do |size|
  @memory_package_queue = InMemoryPackageQueue.new
  @locator = "some-locator"
  register_package = RegisterPackage.new(@locator, size)
  register_package_handler = RegisterPackageHandler.new @memory_package_queue
  register_package_handler.handle(register_package)
end

Then("package is allocated in container") do
  available_container = AvailableContainer.new
  available_container_handler = AvailableContainerHandler.new(@containers)
  response = available_container_handler.handle(available_container)
  @container = response.container
  expect(@container).to be(@small_container)
end

Para que este escenario se pueda ejecutar necesitamos introducir un método factoría (Container.of_capacity), así como añadir parámetros a RegisterPackage, y también al constructor de Package. Vamos allá. Empiezo definiendo una especificación:

RSpec.describe "Container" do
  context "Container instantiation" do
    it "should prepare container of desired capacity" do
      small = Container.of_capacity("small")
      expect(small.capacity).to eq(Capacity.new(4))
    end
  end
  
  # Removed code
end

Y para implementarla introduzco varias clases nuevas, incluyendo un value object para representar Capacity.

class Container
  def initialize
    @packages = []
  end

  def self.of_capacity(capacity)
    return SmallContainer.new if capacity == "small"
  end

  def capacity
    Capacity.new(4)
  end
  
  def contains?(locator)
    true
  end
  
  def available?
    true
  end

  def store(package)
    @packages.append(package)
  end
end

require_relative "full_container"
require_relative "small_container"

He decidido modelar los tamaños de los contenedores de esta forma. Por ahora solo necesito el pequeño.

class SmallContainer < Container
  def capacity
    Capacity.new(4)
  end
end

Y Capacity que tiene especial interés por la forma en que implementamos propiedad de igualdad por valor.

class Capacity
  attr_reader :capacity
  def initialize(capacity)
    @capacity = capacity
  end

  def ==(other)
    @capacity == other.capacity
  end
end

Una vez que las especificaciones pasan, ejecuto el escenario de nuevo. Lo siguiente que me pide es permitir el parámetro size en RegisterPackage y modificar sus usos.

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

Una vez añadida esta modificación, el escenario pasa. Esto ocurre en parte por lo que decíamos antes: es prácticamente el mismo escenario que el primero, por lo que realmente no cuestiona la implementación existente. Para ello, necesitaríamos introducir un escenario en el que un contenedor lleno, o casi lleno, no permita guardar un paquete grande.

Pero ya que todos los tests están en verde, podríamos refactorizar para prepararnos para este cambio. Package.register podría aceptar el parámetro size, por ejemplo. Aquí está, aunque todavía no lo usamos:

class Package
  attr_reader :locator
  def initialize(locator)
    @locator = locator
  end

  def self.register(locator, size)
    Package.new(locator)
  end

  def allocated?
    false
  end
end

Mi idea es que register actúe igual que Container.of_capacity y me devuelva un derivado de Package en función del tamaño.

class Package
  attr_reader :locator
  def initialize(locator)
    @locator = locator
  end

  def self.register(locator, size)
    return SmallPackage.new(locator) if size == "small"
  end

  def allocated?
    false
  end

  def size
    PackageSize.new("small")
  end
end

require_relative "small_package"
class SmallPackage < Package
  def size
    PackageSize.new("small")
  end
end

Y PackageSize.

class PackageSize
  attr_reader :size
  def initialize(size)
    @size = size
  end

  def ==(other)
    @size == other.size
  end
end

Para hacer avanzar el desarrollo voy a necesitar un nuevo escenario que fuerce cambios necesarios en Container. Particularmente me interesa que Container.available? responda en función del espacio real disponible, para lo que tiene que sumar el espacio ya ocupado por los paquetes que pueda contener. Creo que un buen escenario para esto sería tener un contenedor parcialmente ocupado y tratar de registrar un paquete que no cabe, el cual quedará en la cola.

Este escenario podría servir. Un contenedor pequeño que ya tenga un paquete grande no tiene espacio para otro paquete grande. No queremos probar el caso del paquete pequeño porque sabemos que cabe y ese escenario no cuestiona la implementación actual.

Scenario: Container with packages in it and not enough free space
    Given an empty "small" container
    And a "large" package stored
    When Merry registers a "large" size package
    Then package stays in queue

Añado la siguiente definición:

# Container with packages in it and not enough free space

Given("a {string} package stored") do |size|
  package = Package.register("large-pkg", size)
  @small_container.store(package)
  @containers.update(@small_container)
end

Ejecuto el escenario y falla con este error. Posiblemente, el primer problema es que Package.register no sabe instanciar un paquete de tamaño grande.

NoMethodError: undefined method `locator' for nil:NilClass

  expect(recovered.locator).to eq(@locator)
                  ^^^^^^^^

Soluciono esto y el escenario pasa. Sin embargo, me doy cuenta de que el escenario no asegura que no se asigna ningún contenedor, por lo que el resultado no es concluyente. Por otro lado, esto mismo ocurre en otro escenario anterior y creo que no lo tengo bien expresado. Así que cambio este último escenario por:

Scenario: Container with packages in it and not enough free space
    Given an empty "small" container
    And a "large" package stored
    When Merry registers a "large" size package
    Then there is no available container
    Then package stays in queue

Y hago una modificación similar en el segundo escenario:

Scenario: There is no enough space for allocating package
    Given no container with enough space
    When Merry registers a package
    Then there is no available container
    And package stays in queue

La definición del paso es sencilla porque, de hecho, ya la tenía, pero en el lugar equivocado:

Then("there is no available container") do
  available_container = AvailableContainer.new
  available_container_handler = AvailableContainerHandler.new(@containers)
  response = available_container_handler.handle(available_container)
  @container = response.container
  expect(@container).to be_nil
end

Con este cambio, el escenario recién introducido falla, que es justo lo que necesitaba, y los demás escenarios siguen pasando.

El escenario no pasa porque el sistema encuentra un contenedor disponible, algo que ocurre porque la implementación es fake y es justo lo que quería hacer evolucionar. Container.available? tiene que responder teniendo en cuenta los paquetes que ya tenga almacenados. Pero también tiene que tener en cuenta el tamaño del paquete. Básicamente, un contenedor tiene espacio si:

capacidad - espacio usado > tamaño del paquete

Este es un tipo de problema perfecto para desarrollarlo mediante TDD o especificación mediante ejemplos. Durante este desarrollo hemos visto varios casos en que podíamos establecer algunos comportamientos usando solo el test de aceptación (los escenarios). Sin embargo, muchas veces el comportamiento se establece mejor usando tests unitarios, con los que probar más ejemplos de una forma más ágil.

Ahora bien, el problema es que hasta ahora Container.available? no tenía en cuenta el tamaño del paquete entrante. La flexibilidad de Ruby me permitirá que eso sea opcional. En Java, por ejemplo, nos bastaría con sobrecargar el método.

RSpec.describe "Container" do
  context "Container instantiation" do
    it "should prepare container of desired capacity" do
      small = Container.of_capacity("small")
      expect(small.capacity).to eq(Capacity.new(4))
    end
  end

  context "Space available" do
    it "should be available if empty" do
      expect(Container.new.available?).to be_truthy
    end

    it "should be not available if full" do
      expect(FullContainer.new.available?).to be_falsey
    end
    
    it "should be partially available with only one package" do
      container = Container.of_capacity("small")
      container.store(LargePackage.new("locator-large"))
      
      package = SmallPackage.new("locator-small")
      expect(container.available?(package)).to be_truthy
    end
  end

  # Removed code
end

El problema es que nos basta aceptar el parámetro para que el nuevo test pase, por tanto, necesitamos probar otra cosa. Lo mejor es provocar que no haya espacio suficiente y así forzar un cambio en la implementación. Este sería el test que queremos:

it "should not be available for second large package" do
  container = Container.of_capacity("small")
  container.store(LargePackage.new("locator-large"))

  package = LargePackage.new("other_large")
  expect(container.available?(package)).to be_falsey
end

Para hacer pasar este test hago varios cambios, no solo en Container, sino en Capacity o PackageSize. Container queda así, por el momento:

# frozen_string_literal: true

class Container
  def initialize
    @packages = []
  end

  def self.of_capacity(capacity)
    return SmallContainer.new if capacity == "small"
  end

  def contains?(locator)
    true
  end

  def capacity
    Capacity.new(4)
  end

  def available?(package = nil)
    remaining = capacity.subtract(allocated)
    return remaining.not_full? if package.nil?
    remaining.enough_for?(package)
  end

  def store(package)
    @packages.append(package)
  end

  private

  def allocated
    alloc = Capacity.new(0)
    @packages.each do |package|
      alloc = alloc.add_size(package)
    end
    alloc
  end
end

require_relative "full_container"
require_relative "small_container"

Puedes ver los cambios en este commit.

Para continuar y hacer que el escenario pase, tenemos que hacer que el servicio AvailableContainer sepa también que tiene que tener en cuenta el tamaño del paquete. ¿Qué paquete? Pues el que acabamos de registrar. ¡Ups! Podríamos obtenerlo fácilmente mirando la cola, pero ya intuimos los problemas que se nos vienen: ¿qué pasa si mientras tanto se añade otro paquete a la cola de espera? ¿Cómo garantizo que cada paquete es asignado al contenedor elegido? ¿Cómo se reserva el espacio en el contenedor para que no se ofrezca como disponible a otros paquetes?

Pero el desarrollo iterativo nos permite gestionar estas preguntas sin agobiarnos mucho. En los escenarios actuales solo tenemos que gestionar un paquete y no tenemos el peligro de que se mezcle con otro. Eso nos permite validar el concepto general, construir algo que funcione y luego abordar los requerimientos uno a uno. Esas preguntas se tienen que convertir en tests.

De momento, esta es la solución que he encontrado para hacer pasar el escenario que nos quedaba.

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

  def handle(available_container)
    package = @queue.get
    available = @containers.available package
    @queue.put package
    AvailableContainerResponse.new(available)
  end
end

De nuevo, esto conlleva hacer algunos arreglos en diversas partes del código, hasta que todos los escenarios y especificaciones vuelven a pasar. Esto me lleva a pensar que puedo tener un poco de Shotgun Surgery, que es lo que ocurre cuando para hacer un cambio necesitas tocar muchas partes del código. Lo cierto es que sobre todo son tests, algo que probablemente se puede solucionar con algunos refactors y uso del patrón Object Mother, para evitar las consecuencias de que las signaturas todavía sean inestables.

Y hasta aquí hemos llegado por ahora

Conclusiones

En esta entrega no hemos hablado mucho de arquitectura hexagonal, pero hemos avanzado en el diseño de uno de los puertos de la aplicación. Las interfaces son todavía inestables y estamos avanzando en pasos muy pequeños. La parte positiva es que la idea básica está probada y parece viable. En el lado menos positivo, hemos detectado limitaciones, por lo que nos queda bastante por hacer.

Sin embargo, creo que es bueno que lo hecho hasta ahora haya permitido obtener este feedback. Como hemos avanzado relativamente poco, se podría decir que también hemos invertido poco y corregir la dirección del desarrollo es todavía bastante barato.

  1. You ain’t gonna need it: no lo vas a necesitar 

  2. Por si no lo sabías ya, duck typing es una forma de tipado en la que consideramos que un objeto cumple una interfaz, si responde a los mensajes que son aceptables para esa interfaz. El nombre viene del dicho “si camina como un pato, nada como un pato y grazna como un pato, es que es un pato”. 

  3. en un futuro próximo extenderemos esto a varios contenedores. 

  4. Cucumber detecta los números como parámetros de cada paso. Los textos entre comillas también se toman como parámetros. 

June 11, 2023

Etiquetas: good-practices   design-patterns   ruby   bdd   hexagonal  

Temas

good-practices

refactoring

php

testing

tdd

python

design-patterns

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