Para mi intento de explicar como crear una aplicación basada en arquitectura hexagonal, he pensado en reutilizar una prueba técnica que realicé hace algunos años. Eso sí, reconvertida para no desvelar ni la prueba original, ni la empresa.
Y, por si te lo estabas preguntando, fallé miserablemente por no interpretar correctamente las instrucciones, lo que provocó que la prueba no pasase los tests, pese a que los resultados eran correctos. Así que no avancé en el proceso.
Quizá algún día me anime a escribir mi opinión sobre las pruebas técnicas. De momento, te diré que ahora mismo me molesta bastante tener que hacerlas si son del tipo “hazla en tu casa”, más que nada porque escribo y publico muchísimo código casi todos los días, como podrás imaginar viendo este blog y subproductos asociados.
Preferiría más bien tener una sesión de programación de entre una hora y hora y media, con un par de personas del equipo, que me propongan un pequeño ejercicio, y que viendo como lo planteo y comienzo a resolverlo, se hagan una idea de si podrían trabajar conmigo… y yo con ellas.
Pero estoy divagando…
El problema
El proyecto que queremos desarrollar es un pequeño servicio en forma de API REST para organizar mercancía en un punto de recogida de paquetes mientras no es entregada a sus destinatarios finales. La idea es que las usuarias puedan indicar un punto de recogida independiente al vendedor para que envíe allí la mercancía y recogerla exhibiendo el localizador del transportista.
Básicamente, queremos que nos ayude a colocar los paquetes, que vienen en cajas con tamaños estándar1, en contenedores optimizando el espacio. Así, los paquetes pueden ser de tres tamaños: 1 volumen, 2 volúmenes y 3 volúmenes.
Los contenedores pueden albergar cualquier combinación de ellos hasta completarse. Tenemos contenedores de 4, 6 y 8 volúmenes. Tenemos un número limitado de ellos, por lo que es posible que en un momento dado no tengamos espacio para nuevos paquetes.
Si un paquete no se puede almacenar, debe pasar a una cola en la que esperará hasta que uno de los contenedores quede libre. Sin embargo, un paquete puede saltar la cola si el paquete anterior no ha podido ser guardado. Por ejemplo, si tenemos un paquete de 3 volúmenes y luego otro de 1 volumen y queda un espacio libre, se podría asignar primero el paquete más pequeño.
La propuesta original
La solución que nos piden es una API REST que nos cumpla el siguiente contrato:
GET /health
Para chequear que el sistema está funcionando y puede aceptar peticiones.
Respuestas:
- 200 OK Si está listo para aceptar peticiones.
PUT /containers
Nos permite definir los contenedores disponibles en el almacén. Al llamar a este endpoint se reinicia el sistema y todos los paquetes que pudiera haber anteriormente pasan a la cola.
Body obligatorio La lista de contenedores.
Content Type application/json
Ejemplo:
[
{
"id": 1,
"volumes": 4
},
{
"id": 2,
"volumes": 6
}
]
Respuestas:
- 200 OK Si la lista se ha registrado correctamente y estamos listos para operar.
- 400 Bad Request Cualquier error que impida procesar la lista correctamente.
POST /package
Cuando entra un paquete en el sistema.
Body obligatorio Los datos del paquete que se necesita guardar
Content Type application/json
Sample:
{
"id": 1,
"volumes": 2
}
Respuestas:
- 200 OK o 202 Accepted Cuando el paquete se ha registrado y ha sido asignado a un contenedor o puesto en la cola.
- 400 Bad Request Cualquier error que impida procesar el paquete correctamente.
POST /pickup
Cuando la destinataria recoge su paquete debe retirarse de su contenedor.
Body obligatorio Un formulario con el identificador del paquete, por ejemplo: id=3
Content Type application/x-www-form-urlencoded
Respuestas:
- 200 OK o 204 No Content Cuando el paquete se ha retirado correctamente.
- 404 Not Found Cuando el paquete no se encuentra.
- 400 Bad Request Cualquier error que impida procesar el paquete correctamente.
POST /locate
Normalmente, antes de entregar un paquete necesitaremos saber en qué contenedor está, así que necesitamos un endpoint que nos proporcione esta información. Por tanto, queremos un formulario que dado el`localizador del paquete nos devuelva o bien el contenedor en el que se encuentra, o bien si está en la cola, esperando asignación.
Body obligatorio Un formulario con el identificador del paquete, por ejemplo: id=3
Content Type application/x-www-form-urlencoded
Accept application/json
Respuestas:
- 200 OK Con un payload indicando el contenedor.
- 204 No Content Si está en la cola de espera.
- 404 Not Found Si el paquete no está en el sistema.
- 400 Bad Request Cualquier error que impida procesar el paquete correctamente.
Pero bueno, también hay que pensar en cosas como de qué forma la aplicación va a ser capaz de saber cuando debe intentar colocar los paquetes que estén en la cola de espera, etc.
La aplicación
En su día escribí esta prueba técnica en Ruby y tengo ganas de volver a hacerlo, porque me apetece mucho trabajar en este lenguaje, aunque no domino su ecosistema y así puedo practicar. Por otro lado, no usaré Rails. Ya sé que todo el mundo Ruby trabaja con Rails, pero ciertamente no es el objetivo del ejercicio. Lo que busco es escribir una aplicación que siga el patrón de arquitectura hexagonal2 y sirva como ejemplo de los artículos que hemos publicado en el blog sobre el tema.
Como corresponde a un proyecto basado en Arquitectura Hexagonal, esas decisiones se tomarán cuando sea necesario tomarlas.
La aplicación en sí, lo que a veces identificamos como “el hexágono”, se encargará de representar el modelo de negocio: un sistema que nos ayude a organizar la mercancía de un punto de recogida de paquetería, de forma completamente independiente de cualquier tecnología concreta. En cualquier caso, la única dependencia técnica sería el lenguaje de programación.
Como establece el patrón, el sistema tiene que exponer puertos para que el mundo exterior pueda usarlo: los puertos primarios o drivers. Pero antes, necesitamos hablar de los actores.
Identificando los actores
¿Quiénes van a necesitar usar el sistema? A grandes rasgos, la respuesta es: los empleados del punto de entrega. Pero podemos matizar un poco.
Yo diría que podemos identificar los siguientes roles:
- La persona responsable de recibir los paquetes de los transportistas, que necesitará dar de alta el paquete en el sistema, que le asignará un espacio en un contenedor o lo pondrá en la cola de espera.
- La persona responsable de entregar los paquetes a las destinatarias, que necesitará localizar el paquete en su contenedor y darlo de baja del sistema una vez lo haya entregado.
- La persona responsable de gestionar el almacenamiento, que necesitará mantener al día la disponibilidad de los contenedores y estar al tanto de los paquetes en espera, cuando el sistema indique que se ha liberado espacio y los puede asignar a un contenedor.
Así que parece que podemos identificar tres actores o roles3, en tanto que posibles usuarias del sistema con distintas intenciones y necesidades. Eso es independientemente de que en la vida real podrían ser una misma persona.
Un cuarto grupo de actores son los tests, que deberían poder ejecutar toda la funcionalidad de la aplicación.
Identificando puertos primarios
El servicio que vamos a crear expone una API Rest. Esto, por definición, en Arquitectura Hexagonal, es un adaptador y está fuera de la aplicación o hexágono. Al referirse a una tecnología concreta (API Rest sobre HTTP), nos está diciendo que existen uno o varios puertos con los que el mundo exterior puede iniciar conversaciones con la aplicación, para los que la API constituye un adaptador.
Por tanto, ¿qué puertos primarios podría exponer la aplicación? Pensemos un poco sobre el escenario. La aplicación intenta resolver el problema de gestión del espacio de un punto de entrega de paquetería.
Gestión de contenedores
Por un lado, podríamos decir que el punto de entrega está configurado por un cierto número de contenedores, limitada seguramente por el espacio disponible, la cantidad de paquetes y su rotación. Podríamos pensar en un puerto “para gestionar los contenedores”, aunque solo nos pidan realizar una operación, de momento.
Pero es fácil imaginar otras operaciones, como añadir más contenedores, reemplazarlos, quitar otros, etc.
Todas estas operaciones son perfectamente separables de la gestión, almacenamiento y entrega de los paquetes. Responden a necesidades diferentes y podrían estar realizadas por personas distintas o conectadas a sistemas diferentes.
Almacenamiento de paquetes
Examinando los requisitos, también es fácil identificar un puerto “para almacenar los paquetes entrantes”. La gestión de los paquetes entrantes es una actividad separada de la gestión de los contenedores y, en bastantes sentidos, de la recogida de paquetes por parte de sus destinatarias.
Es obvio que todas las operaciones del punto de entrega están relacionadas, pero podemos ver que implican distintos intereses.
Los transportistas llegan con los paquetes destinados a este punto de entrega y su tarea es asegurarse de que sean recepcionados. En el punto de entrega los paquetes pueden estar asignados a un contenedor o en la cola de espera en tanto no hay espacio disponible. Pero en ambos casos estarán localizables.
Por otro lado, la recepción de paquetes y la recogida pueden ser realizados por personas diferentes, en espacios diferentes, utilizando adaptadores diferentes.
Recogida de paquetes
El último puerto que podemos considerar es uno “para recoger paquetes”. La acción de recoger un paquete se inicia porque la persona destinataria lo reclama. Hay dos operaciones importantes: localizar el paquete y entregarlo.
En conclusión, parece claro que podemos definir tres puertos:
- para gestionar los contenedores
- para recepcionar/almacenar paquetes
- para recoger paquetes
Qué hay de los puertos secundarios
Los puertos secundarios, o driven ports, son aquellos en los que la aplicación es la iniciadora de conversaciones con tecnologías del mundo real. No siempre pueden estar claros desde el principio del diseño. Podemos esperar que la aplicación tenga algunas necesidades que impliquen la necesidad de alguna tecnología concreta, pero a veces no lo podemos descubrir hasta que lleguemos a un cierto punto de desarrollo. O, al menos, hasta que no decidamos qué tecnología concreta va a ser la adecuada.
De todas maneras, podemos anticipar algunas necesidades, como la de persistir información sobre contenedores y paquetes. Dicho en otras palabras: ¿qué conversaciones podría nuestra aplicación querer iniciar para cumplir sus finalidades?
Sabemos que para resolver nuestro problema, nuestra aplicación necesitará:
- conocer de qué contenedores dispone y de su capacidad
- conocer cuál es su disponibilidad, si tienen espacio libre y cuánto
- conocer dónde se ha guardado un paquete
- quitar los paquetes entregados del contenedor en donde estaban
Posiblemente, necesitaremos definir dos puertos:
- para guardar y obtener la información de los contenedores
- para guardar y obtener la información de los paquetes
Este es un primer planteamiento, pero yo diría que no es muy recomendable establecerlo como objetivo. Es posible que, a medida que avancemos en el desarrollo de la aplicación, podamos definir mejor estas necesidades.
Diría que lo más importante a recalcar en este punto es:
- Los puertos secundarios o driven no tienen por qué corresponderse con los puertos primarios. Incluso admitiendo que un puerto primario define una conversación y que poder completar esa conversación acabará requiriendo uno o más puertos secundarios, la pregunta que hay que hacerse es ¿qué necesita la aplicación?
- Es prematuro tomar decisiones muy firmes sobre los puertos secundarios y las tecnologías necesarias para implementarlas. Es menos costoso esperar a que el desarrollo de la aplicación nos vaya definiendo lo que se necesita y empezar con tecnologías más simples, baratas y fáciles de cambiar.
El bootstrap de la aplicación
Todas las aplicaciones necesitarán un lugar de setup y configuración. La función de este módulo, por llamarlo de alguna manera, es asegurar que la aplicación se monta correctamente y que todos los componentes necesarios son configurados conforme a las necesidades del entorno de ejecución.
El setup de una aplicación es un lugar donde se mezclan varios asuntos. Por un lado, necesitamos montar la aplicación con sus dependencias. Por otro, tenemos que tener en cuenta el entorno en que se ejecuta para poder escoger las instancias de los adaptadores que sean más adecuadas. Hay que cargar variables de entorno que nos proporcionen los datos de configuración y otras muchas tareas.
¿Por dónde empezar a trabajar?
He aquí una pregunta interesante, y probablemente una de las más buscadas. Voy a intentar contestarla a mi manera.
Cuando realicé la prueba técnica de la que extraje la idea para la aplicación no tenía un conocimiento muy elaborado de lo que era la Arquitectura Hexagonal. Mi aproximación en ese momento fue comenzar desarrollando la API mediante tests end to end usando un estilo TDD outside in. En términos del patrón, estaba intentando crear la aplicación a través de un adaptador, por decirlo así.
El problema de hacerlo así es que estoy mezclando cosas: la representación del modelo del dominio y la implementación de un adaptador, o incluso puede que varios a la vez.
Hoy por hoy, creo que la aproximación tiene que ser distinta.
En primer lugar, la aplicación, que es la representación del modelo mental que tenemos del dominio, se puede desarrollar con independencia de cualquier otra cuestión. Para ello, podemos usar los tests como actores, adoptando un enfoque TDD.
A medida que defino los puertos, puedo empezar el desarrollo independiente de los adaptadores. De hecho, la misión de los adaptadores no es otra que traducir las intenciones de los actores de tal manera que usen los puertos proporcionados por la aplicación.
En nuestro ejemplo, cada endpoint del API Rest se encarga de obtener la información necesaria de la Request y, si consigue todo lo necesario, accionar el puerto adecuado. Si procede, esperará por la respuesta y la traducirá a códigos de estado HTTP y, si es necesario, devolverá un payload. Si la petición no puede procesarse se informará mostrando un código de estado adecuado.
En consecuencia, una vez que tenga claros como son los puertos y su protocolo, puedo empezar a desarrollar adaptadores usando fakes de los mismos puertos. En un equipo de desarrollo, eso nos permitiría distribuir trabajo
En resumidas cuentas:
- Lo primero que quiero desarrollar es la aplicación, para lo cual usaré tests que me ayudarán a definir cómo serán los driver ports o puertos primarios, y cuál será el comportamiento de la aplicación.
- Al implementar la aplicación debería poder ir descubriendo los driven ports, o puertos secundarios, que la aplicación va a necesitar.
- En este punto, seguramente necesitaré implementar algunos de ellos aunque solo sea para el entorno de testing y de desarrollo.
- El siguiente paso será desarrollar los adaptadores de los puertos primarios. Por lo general, esto se nos presenta como requisito, lo cual empuja a muchos equipos a empezar mezclando conceptos. Es en este punto donde pueden entrar decisiones al respecto de los frameworks y es donde las cosas se suelen liar. Para desarrollar en Arquitectura Hexagonal, los frameworks no deberían hacer acto de presencia hasta que empezamos a implementar adaptadores de producción. De este modo, no introducirán condicionantes en el diseño de la aplicación en sí.
- A estas alturas, debería tener suficiente información para tomar decisiones sobre las tecnologías que usaré en producción para implementar los puertos secundarios. Por ejemplo, si necesito persistencia: ¿me viene mejor una base de datos documental o relacional? ¿Cómo debería diseñar las tablas o colecciones? ¿Sería buena idea usar Redis para la cola de espera? Estas decisiones deberían ocurrir en una fase relativamente tardía del desarrollo.
Hay que tener en cuenta, que esto es lo que me planteo como desarrollador individual. Pero en un equipo de desarrollo pienso que estos pasos pueden ejecutarse de una forma diferente.
- Al principio debería darse la discusión que he planteado más arriba: ¿quiénes son los actores? ¿Qué conversaciones podrían querer iniciar con nuestro sistema? ¿QUé puertos primarios darían soporte a esas conversaciones? ¿Qué puertos secundarios podría necesitar nuestra aplicación para participar en la conversación?
- Esto debería dar lugar a una definición inicial de los puertos. Puede que no definitiva, pero quizá suficiente para empezar a trabajar en alguna de las conversaciones.
- Para decidir por qué conversación empezar, probablemente necesitemos plantear una metodología de rebanado vertical y escoger un slice de aplicación como primer objetivo para empezar a levantar el sistema.
- Con esto, podríamos tener suficiente como para empezar a trabajar en especialidades. Por ejemplo, parte del equipo trabajando en la aplicación y otra parte del equipo desarrollando las primeras versiones de los adaptadores implicados en este slice.
- Todo esto debería ocurrir con feedback continuo entre los participantes. No se trata de distribuir la carga de trabajo, sino de establecer un entorno que nos permita aprovechar la capacidad del equipo para avanzar en paralelo en los distintos componentes.
Primeros pasos: preparando mi entorno de trabajo (Ruby edition)
Esta parte es bastante específica de Ruby y no está relacionada directamente con Arquitectura Hexagonal. De momento, voy a preparar mi entorno de trabajo. Como he mencionado, pretendo usar Ruby (3.1.0 por más señas) como lenguaje de programación y bundler
para la gestión de dependencias.
He pensado que también quiero afrontar el proyecto usando BDD para crear los tests de aceptación.
En cualquier caso, si trabajas con otro lenguaje, este sería el momento para preparar el entorno e instalar el port de Cucumber que corresponda a tu lenguaje, si también te interesa empezar con BDD.
Así que inicio un proyecto que voy a llamar storage
. IntelliJ IDEA me permite crear un proyecto para Ruby e iniciarlo como repositorio de git. En todo caso, el clásico:
mkdir storage
cd storage
git init
El primer archivo que voy a añadir es Gemfile
. En este archivo se definen las dependencias del proyecto (como el composer.json
de PHP, go.mod
en go, build.graddle
de Java, etc., etc.). De momento, todo es muy básico.
# Gemfile
source "https://rubygems.org"
ruby '3.1.0'
group :test do
gem 'cucumber'
gem 'rspec'
end
A continuación ejecuto cucumber --init
para preparar el proyecto. Esto creará una estructura de carpetas específica para el desarrollo de las especificaciones.
cucumber --init
En mi caso, me pidió instalar una serie de gems
para poder trabajar, ya que acabo de limpiar mi sistema de anteriores instalaciones del lenguaje.
gem pristine debase --version 0.2.5.beta2
gem pristine debug --version 1.4.0
gem pristine executable-hooks --version 1.6.1
gem pristine ffi --version 1.15.5
gem pristine gem-wrappers --version 1.4.0
gem pristine nio4r --version 2.5.9
gem pristine puma --version 6.3.0
gem pristine rbs --version 2.0.0
gem pristine ruby-debug-ide --version 0.7.3
Con esto deberíamos tener suficiente para empezar a trabajar.
Primeros pasos: decidiendo por dónde empezar
De entre todas las prestaciones que se nos piden para este proyecto, tenemos que decidir cómo partirlas y por cuál empezar. Es posible utilizar diversos criterios y estrategias para tomar esta decisión.
En la definición de la API el primer punto que se nos propone es ofrecer un endpoint /health
que nos permita chequear si el sistema está levantado y aceptando peticiones. Es algo útil, pero no tiene valor de negocio por sí mismo. De hecho, ni siquiera forma parte de la aplicación, sino que es un requisito que tiene más que ver con el adaptador API Rest.
El primer elemento con algún valor de negocio es PUT /containers
, que nos permite definir el setup de un almacén de punto de entrega. Sin embargo, tiene menos valor de negocio que lo que define el punto de entrega: que las destinatarias puedan recoger sus paquetes.
Por otro lado, para poder recoger los paquetes, antes tenemos que poder recibirlos y tenerlos organizados. Y para esto necesitamos preparar el almacén con un cierto número de contenedores. Pero para ello necesitaríamos poder comunicarle a la aplicación el setup de contenedores.
Parece la pescadilla que se muerde la cola, pero tenemos opciones para evitar esa especie de ciclo infinito. Por ejemplo, estableciendo primero algunos valores fijos de este setup, hasta que estemos en condiciones de abordar esa parte del desarrollo.
Mi opinión es que la prestación que aportaría más valor ahora mismo es la de que se puedan recibir paquetes y almacenarlos correctamente. Incluso aunque el sistema no permita localizar los paquetes, podría ser lo bastante útil en oficinas pequeñas con poco tráfico y sería posible evaluar su funcionamiento, llevando un registro manual de las asignaciones efectuadas por el sistema, en tanto la aplicación no está completa.
Estas oficinas pequeñas pueden determinarse analizando los datos que la empresa tenga disponibles. Incluso sería posible averiguar una configuración de contenedores frecuente, la cual podríamos establecer como hard-coded en el sistema, evitándonos la urgencia de implementarla desde el principio, retrasando prestaciones de más valor.
¿Y de qué serviría esto? Pues de mucho. Podrías tener un grupo de oficinas en los que desplegar cuanto antes una versión limitada de la aplicación, obteniendo feedback realista.
Por tanto, vamos a empezar con la capacidad de que se puedan entregar paquetes en la oficina y almacenarse en los contenedores.
La conversación de este puerto para registrar paquetes sería algo así:
Transportista: Hola, vengo a entregar este paquete
Recepcionista: De acuerdo, paquete registrado
Sistema: El paquete se debe colocar en el contenedor 'whatever'
Recepcionista: Allá voy a guardarlo.
Escribiremos, pues, nuestro primer escenario. Es una definición un poco tentativa, puesto que estamos empezando y aún no tenemos nada claras algunas cosas.
# features/register_packages.feature
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
- Containers can have capacity for 4, 6 or 8 volumes
- A Package that cannot be allocated goes to a waiting queue
Scenario: There is space for allocating package
Given Merry brings a package
When package is registered
Then it is assigned to a container
En este escenario no estoy haciendo ninguna mención a identificadores o tamaños. ¿Lo necesitaría en este momento? Por un lado, el escenario dice que dispongo del espacio necesario para guardar el paquete, por lo que no me importa su tamaño. El localizador tampoco es crucial en este momento, al menos, no a los efectos de este test.
En lo que se refiere a haber sido asignado a un contenedor, la cosa es un poquito más ambigua. Nos basta con que el sistema nos digo el primero disponible, pero tampoco nos importa saber exactamente cuál.
Lo más destacable es de qué manera este escenario nos puede ayudar a identificar elementos de la arquitectura hexagonal.
Recuerda que los tests son actores, con la peculiaridad de que no necesitan adaptadores, pues pueden hablar directamente con la aplicación. En cierto modo, los tests son actores simulando actores del mundo físico, ya sean seres humanos o sistemas que interactúan con el nuestro.
Por tanto, el test, en este caso el escenario definido en el documento Gherkin, representa la conversación del actor-test con la aplicación. Para que se pueda dar una conversación, tiene que existir un puerto que el actor use para iniciarla.
En este ejemplo, creo que se puede ver claramente que el actor-test necesita un puerto que le permita:
- Solicitar la admisión/registro de un paquete
- Saber a qué contenedor ha sido asignado
La primera acción es claramente un comando, que podría expresarse en código como RegisterPackage
, mientras que la segunda es una consulta, o query, que podría llamarse WhatContainerIsAvailable
.
El escenario en Gherkin es totalmente agnóstico de la forma de implementación4 lo que nos permitirá usarlo tanto para construir la aplicación/hexágono, como para usarlo como test de aceptación ejecutándolo contra cualquier adaptador que podamos crear, como sería el caso de la API Rest que nos han pedido desarrollar.
El siguiente paso será crear las Step Definitions y podemos usar las ideas anteriores para ello. Esta es mi primera tentativa, en la que podremos observar varios problemas. Sin embargo, el punto más positivo es que estamos definiendo exactamente los protocolos de este puerto.
# features/step_definitions/steps.rb
# frozen_string_literal: true
Given('Merry brings a package') do
@register_package = RegisterPackage.new()
end
When('package is registered') do
register_package_handler = RegisterPackageHandler.new
register_package_handler.handle(@register_package)
end
Then('it is assigned to a container') do
what_container_is_assigned = WhatContainerIsAssigned.new
what_container_is_assigned_handler = WhatContainerIsAssignedHandler.new
response = what_container_is_assigned_handler.handle(what_container_is_assigned)
expect(response.container).to be_truthy
end
BDD nos permite realizar una aproximación al desarrollo bastante heurística e interactiva. Esto es: podemos empezar con un código que sea un esbozo de la implementación y luego refactorizar progresivamente hasta una solución mejor diseñada a medida que introducimos nuevos tests que desafían la implementación existente.
De hecho, estas definiciones de pasos no están completas. Así, por ejemplo, WhatContainerIsAssigned
no recibe ningún parámetro que le diga a qué paquete se le ha asignado. Sin embargo, en este punto podrían ser suficiente para empezar a trabajar, dejando que sean los nuevos ejemplos los que nos ayuden a refinar esta implementación.
¿Y esto por qué? Pues para evitar un desarrollo especulativo. En lugar de intentar anticipar todos los posibles elementos del protocolo que estamos desarrollando, puede ser mejor idea descubrir poco a poco sus limitaciones, introduciendo lo que sea necesario para superarlas.
Mi decisión de usar BDD en este proyecto no tenía mucho que ver con el objetivo del artículo. Quería aprovechar la situación para repasar la metodología. Pero al hacerlo, descubrí un gran potencial como ayuda al diseño de la aplicación hexagonal. BDD ayuda a describir y desarrollar las conversaciones que necesitan mantener los actores con la aplicación, lo que nos lleva directamente a la definición de los puertos.
Cómo organizar el código
BDD tiene un proceso prácticamente idéntico a TDD outside-in (o London School). Los tests de aceptación son escenarios escritos en Gherkin, pero el proceso de desarrollo puede enfocarse igual, con el doble ciclo aceptación/unitario.
Ejecutamos el escenario y observaremos que van fallando cosas porque no existen las clases que necesitamos. Nuestro objetivo es conseguir que el test se pueda ejecutar, fallando por la razón adecuada, que es no cumplir lo que el escenario dice.
Cada mensaje de error que nos pida implementar algo nos señala el momento para empezar a crear las clases usando TDD a nivel unitario.
Otra aproximación posible es usar los escenarios con una metodología TDD clásica (Chicago School). En ese caso, comenzaríamos con fake implementation: escribir el código mínimo suficiente para que el escenario pase devolviendo las respuestas necesarias de forma incondicional. Cada nuevo escenario debería cuestionar esa implementación anterior para forzarnos a introducir cambios en el código, incidiendo especialmente en la fase de refactor.
Independientemente del proceso que sigamos, es el momento de tomar decisiones acerca de dónde vamos a poner las cosas. En Ruby la convención es que el código de producción va en la carpeta lib
, que se ubica en el primer nivel del proyecto. En otros lenguajes, la convención dicta src
. Hemos visto que nos interesa separar físicamente el código de la aplicación o hexágono y el de los adaptadores, por lo que pienso que puede tener sentido una estructura similar a esta:
.
├── Gemfile
├── Gemfile.lock
├── cucumber.yml
├── features
│ ├── register_packages.feature
│ ├── step_definitions
│ │ └── steps.rb
│ └── support
│ └── env.rb
└── lib
├── adapter
└── app
└── for_registering_packages
Tengo algunas dudas en la cabeza, pero no me quiero adelantar a ellas. De momento, esto es lo que necesitaría para empezar a ubicar el código.
Los puertos se hacen explícitos usando un directorio dentro de app
. Esto puede variar dependiendo de algunas convenciones. Hay lenguajes que recomiendan, o incluso fuerzan, un archivo por clase. En otros, es posible poner tantas clases como nos parezca adecuado en un mismo archivo, que se convierte en un módulo como es el caso de Python, de forma que podrías tener en un solo lugar todos los elementos que componen un puerto, o mejor aún: todos los elementos que componen una conversación en un puerto.
La guía de estilo de Ruby aconseja un archivo por clase o módulo. En este caso, mi preferencia es guardar todos los archivos estrechamente relacionados bajo una carpeta con el nombre del concepto principal.
├── Gemfile
├── Gemfile.lock
├── cucumber.yml
├── features
│ ├── register_packages.feature
│ ├── step_definitions
│ │ └── steps.rb
│ └── support
│ └── env.rb
├── lib
│ ├── adapter
│ └── app
│ └── for_registering_packages
│ └── register_package
│ ├── register_package.rb
│ └── register_package_handler.rb
└── storage.iml
La longitud de los paths podría considerarse como un inconveniente, pero esto nos ayuda a mantener una estructura en la que cada concepto es representado por una carpeta que contiene unos pocos archivos muy cohesivos.
Después de añadir un poco más de código, consigo que el escenario se ejecute por completo, aunque falla. Pero es exactamente donde queríamos estar.
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
- Containers can have capacity for 4, 6 or 8 volumes
- A Package that cannot be allocated goes to a waiting queue
Scenario: There is space for allocating package # features/register_packages.feature:9
Given Merry brings a package # features/step_definitions/steps.rb:8
When package is registered # features/step_definitions/steps.rb:12
Then it is assigned to a container # features/step_definitions/steps.rb:17
expected: truthy value
got: nil (RSpec::Expectations::ExpectationNotMetError)
./features/step_definitions/steps.rb:21:in `"it is assigned to a container"'
features/register_packages.feature:12:in `it is assigned to a container'
Failing Scenarios:
cucumber features/register_packages.feature:9 # Scenario: There is space for allocating package
1 scenario (1 failed)
3 steps (1 failed, 2 passed)
0m0.050s
La estructura de archivos que tengo hasta este momento habla por sí misma. Dentro de app
, podemos ver un puerto for_registering_packages
, que tiene dos conversaciones. No nos hace falta ni mirar el contenido de los archivos para hacernos una idea de lo que pasa.
├── Gemfile
├── Gemfile.lock
├── cucumber.yml
├── features
│ ├── register_packages.feature
│ ├── step_definitions
│ │ └── steps.rb
│ └── support
│ └── env.rb
├── lib
│ ├── adapter
│ └── app
│ └── for_registering_packages
│ ├── register_package
│ │ ├── register_package.rb
│ │ └── register_package_handler.rb
│ └── what_container_is_available
│ ├── what_container_is_available.rb
│ ├── what_container_is_available_handler.rb
│ └── what_container_is_available_response.rb
└── storage.iml
Para hacer pasar el escenario, solo necesito que WhatContainerIsAvailableResponse.container
devuelva algún valor truthy
, o sea, que sea true
o equivalente, como una cadena de texto no vacía. Gracias al Duck Typing de Ruby no me tengo que preocupar mucho ahora por lo que retorno.
# frozen_string_literal: true
class WhatContainerIsAvailableResponse
def container
true
end
end
Esta implementación fake hace pasar el escenario:
Using the default profile...
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
- Containers can have capacity for 4, 6 or 8 volumes
- A Package that cannot be allocated goes to a waiting queue
Scenario: There is space for allocating package # features/register_packages.feature:9
Given Merry brings a package # features/step_definitions/steps.rb:8
When package is registered # features/step_definitions/steps.rb:12
Then it is assigned to a container # features/step_definitions/steps.rb:17
1 scenario (1 passed)
3 steps (3 passed)
0m0.021s
RuboCop lleva un buen rato avisándome de que algunos nombres de clases son muy largos, una costumbre que arrastro de otros lenguajes. Voy a intentar reducirlos sin perder semántica:
.
├── Gemfile
├── Gemfile.lock
├── cucumber.yml
├── features
│ ├── register_packages.feature
│ ├── step_definitions
│ │ └── steps.rb
│ └── support
│ └── env.rb
├── lib
│ ├── adapter
│ └── app
│ └── for_registering_packages
│ ├── available_container
│ │ ├── available_container.rb
│ │ ├── available_container_handler.rb
│ │ └── available_container_response.rb
│ └── register_package
│ ├── register_package.rb
│ └── register_package_handler.rb
└── storage.iml
En este punto recupero esta conversación imaginaria:
Transportista: Hola, vengo a entregar este paquete
Recepcionista: De acuerdo, paquete registrado
Sistema: El paquete se debe colocar en el contenedor 'whatever'
Recepcionista: Allá voy a guardarlo.
Mi impresión es que hace falta una elemento más en la conversación: la acción de poner el paquete en el contenedor, que no está recogida de forma explícita ni en el escenario, ni en las definiciones. Así que extendamos un poco el escenario:
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
- Containers can have capacity for 4, 6 or 8 volumes
- A Package that cannot be allocated goes to a waiting queue
Scenario: There is space for allocating package
Given Merry brings a package
When package is registered
Then it is assigned to a container
And it is stored in the container
¿O mejor 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
- Containers can have capacity for 4, 6 or 8 volumes
- A Package that cannot be allocated goes to a waiting queue
Scenario: There is space for allocating package
Given Merry brings a package
When package is registered
Then it is stored in the first available container
La primera versión es más explícita, pero menos natural. La segunda, es más natural, sin dejar de sugerir la idea de que se comprueba cuál es el contenedor disponible5.
Esta sería la nueva implementación de los pasos, no pongo los require
para centrarnos en el código:
Given("Merry brings a package") do
@register_package = RegisterPackage.new
end
When("package is registered") do
register_package_handler = RegisterPackageHandler.new
register_package_handler.handle(@register_package)
end
Then("it is stored in the first available container") do
available_container = AvailableContainer.new
available_container_handler = AvailableContainerHandler.new
response = available_container_handler.handle(available_container)
store_package = StorePackage.new(response.container)
store_package_handler = StorePackageHandler.new
store_package_handler.handle(store_package)
end
Por supuesto, surgen muchas cuestiones. ¿Cómo sabe StorePackage
el paquete que tiene que guardar? ¿Por qué tenemos que consultar el container disponible? ¿No podría consultarlo StorePackage
?
Algunas podrían tener respuesta. Por ejemplo, necesitamos obtener el contenedor disponible para que la persona de recepción sepa donde poner físicamente el paquete, ya que no es un sistema automático. Quizá lo que ocurre es que el escenario no está bien escrito:
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
- Containers can have capacity for 4, 6 or 8 volumes
- 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
Creo que este escenario refleja mejor todo lo que necesitamos. De este modo reescribimos las definiciones, que ahora tienen mejor pinta. Aunque tiene un problema importante, como veremos después:
Given("Merry registers a package") do
register_package = RegisterPackage.new
register_package_handler = RegisterPackageHandler.new
register_package_handler.handle(register_package)
end
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
Then("he puts the package into it") do
store_package = StorePackage.new(@container)
store_package_handler = StorePackageHandler.new
store_package_handler.handle(store_package)
end
Y finalmente introducimos los nuevos objetos que necesitamos para definir el puerto. Esta es la estructura de archivos que queda:
.
├── Gemfile
├── Gemfile.lock
├── cucumber.yml
├── features
│ ├── register_packages.feature
│ ├── step_definitions
│ │ └── steps.rb
│ └── support
│ └── env.rb
├── lib
│ ├── adapter
│ └── app
│ └── for_registering_packages
│ ├── available_container
│ │ ├── available_container.rb
│ │ ├── available_container_handler.rb
│ │ └── available_container_response.rb
│ ├── register_package
│ │ ├── register_package.rb
│ │ └── register_package_handler.rb
│ └── store_package
│ ├── store_package.rb
│ └── store_package_handler.rb
└── storage.iml
Volvamos a las definiciones de los pasos. Hemos dicho que tienen un problema que, por otra parte, es bastante evidente: no se verifica el resultado de las acciones, por lo que el test no tiene oportunidad de fallar.
Given("Merry registers a package") do
register_package = RegisterPackage.new
register_package_handler = RegisterPackageHandler.new
register_package_handler.handle(register_package)
end
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
Then("he puts the package into it") do
store_package = StorePackage.new(@container)
store_package_handler = StorePackageHandler.new
store_package_handler.handle(store_package)
end
Esto es, necesitamos verificar que el efecto que perseguimos se produce: si el paquete está colocado en el contenedor. Lo más obvio es preguntarle al propio contenedor si tiene el paquete en cuestión. Pero, ¿cómo lo identificamos?
Necesitaremos introducir una forma de identificar el paquete, la cual ya conocemos: su localizador.
De momento, no es necesario reflejar esto en el escenario, pero sí en las definiciones de los pasos. Lo cual, a su vez, significa que seguimos trabajando en el diseño del puerto for_registering_packages
.
En principio, vamos a incluir estos cambios:
Given("Merry registers a package") do
@locator = "some-locator"
register_package = RegisterPackage.new @locator
register_package_handler = RegisterPackageHandler.new
register_package_handler.handle(register_package)
end
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
Then("he puts the package into it") do
store_package = StorePackage.new(@container)
store_package_handler = StorePackageHandler.new
store_package_handler.handle(store_package)
expect(@container.contains?(@locator)).to be_truthy
end
El más llamativo sería que necesitamos un objeto Container
, que aún no hemos definido. Pero, ¿dónde lo colocamos? Obviamente, Container
es un concepto importante del dominio de la aplicación y no es exclusivo de un puerto.
Dado que el patrón de Arquitectura Hexagonal no prescribe ninguna organización de código que debamos seguir, tenemos bastante libertad. Mi preferencia personal sería tener una carpeta domain
6 bajo la que organizar todos estos conceptos a medida que vayan apareciendo en beneficio de la claridad, pues sospecho que acabarán surgiendo varios conceptos y la raíz de la carpeta app
acabará quedando muy poblada. Pero como digo, es una preferencia personal.
Esta es la estructura que tenemos ahora:
.
├── Gemfile
├── Gemfile.lock
├── cucumber.yml
├── features
│ ├── register_packages.feature
│ ├── step_definitions
│ │ └── steps.rb
│ └── support
│ └── env.rb
├── lib
│ ├── adapter
│ └── app
│ ├── domain
│ │ └── container.rb
│ └── for_registering_packages
│ ├── available_container
│ │ ├── available_container.rb
│ │ ├── available_container_handler.rb
│ │ └── available_container_response.rb
│ ├── register_package
│ │ ├── register_package.rb
│ │ └── register_package_handler.rb
│ └── store_package
│ ├── store_package.rb
│ └── store_package_handler.rb
└── storage.iml
A decir verdad, no hay prácticamente ningún código significativo en lo que llevamos de aplicación y hemos empleado mucho tiempo en desarrollar esta estructura. Pero creo que nos podemos hacer una idea de cómo hemos partido de un problema y hemos comenzado a montar la arquitectura de la solución.
Los puertos secundarios, ¿para cuándo?
Hasta ahora, hemos estado hablando de cómo definir los puertos primarios (driver ports) a partir de las conversaciones que los actores querrían iniciar con nuestra aplicación.
Pero también hemos mencionado que los puertos secundarios comenzarían a definirse cuando empecemos a implementar la aplicación, respondiendo a preguntas del tipo:
- ¿Cómo va a persistir la información del paquete hasta que le podemos asignar un contenedor?
- ¿Dónde se va a obtener la lista de contenedores de la oficina?
- ¿Cómo podremos saber en dónde se ha guardado el paquete?
- ¿Cómo vamos a implementar la cola de espera?
La respuesta a estas preguntas implica frecuentemente una tecnología del mundo real y, por tanto, debe estar fuera de la aplicación/hexágono. Y siempre que algo está fuera de la aplicación, debemos definir un puerto para poder interactuar. Como ya sabemos, estamos hablando de los puertos secundarios o driven ports.
Resulta que tenemos una herramienta de diseño muy adecuada para esto: TDD outside-in, que ya he mencionado hace un ratito.
En TDD outside-in, comenzamos con un test de aceptación que ejercita la aplicación desde fuera y que usamos para ir desarrollando cada paso del ciclo de vida de la petición. En nuestro caso, el test conversa con uno de los puertos driver.
Pongamos por caso, la conversación RegisterPackage
.
En la aplicación hexagonal, el primer elemento que maneja la petición es el puerto. En nuestro caso está definido con un patrón Command/Handler (o Query/Handler) materializado en la pareja RegisterPackage
y RegisterPackageHandler
, que ahora mismo no estaría implementado. En otro contexto estaríamos hablando de un caso de uso.
El primer paso sería comenzar a desarrollar este caso de uso usando TDD clásica mientras diseñamos cómo debería funcionar. Para eso, introducimos dobles de tests, ya que no sabemos todavía cómo se van a implementar. Esto nos permitirá concentrarnos en definir sus interfaces, simulando su comportamiento.
Por ejemplo, para implementar RegisterPackageHandler
parece claro que tendríamos que:
- Obtener una nueva instancia de
Package
con sus datos (Package
tendría que iniciar su vida como pendiente de clasificación) - Guardar la instancia de algún modo para poder usarla más tarde3
Guardar la instancia nos remite seguramente al concepto de repositorio. Aunque un repositorio es una colección en memoria, también sabemos que las memorias no son eternas. Esto nos lleva a darnos cuenta de que necesitaremos alguna tecnología de persistencia que nos garantice que estos datos se mantendrán en el tiempo.
Por otro lado, también tenemos en mente que, según el protocolo que hemos visto, el registro de un paquete es una primera etapa hasta que es clasificado en nuestro sistema. De hecho, quizá sería mejor que un paquete registrado se guardase en un espacio provisional que tenga el comportamiento de una cola.
En cualquier caso, todo esto nos está hablando de un puerto secundario. La aplicación necesita hablar con una tecnología que posibilite persistir cierta información durante el tiempo necesario. Por lo general, los puertos secundarios se definen como interfaces que deben ser implementadas por los adaptadores. La aplicación estable cómo se tiene que realizar la conversación.
Por eso, usar dobles de test en este punto es bastante útil. Nos permite imaginar como queremos que sea esa interfaz o puerto, sin necesidad de disponer de una implementación concreta.
Para empezar a definirlo usaremos un test unitario. Aquí usaré Rspec, pero cualquier framework de test nos vale.
En principio, creo que voy a empezar disponiendo las specs de modo similar a los archivos del proyecto.
Esta es mi primera aproximación. En este test espero tener una “cola de paquetes” (PackageQueue
) en la que poner el paquete recién creado. Este paquete no estará ubicado todavía. Obviamente, el test no pasará.
RSpec.describe "RegisterPackageHandler" do
before do
# Do nothing
end
after do
# Do nothing
end
context "registering a package" do
it "should register package" do
package_queue = double("PackageQueue")
expect(package_queue).to receive(:put) do |package|
expect(package.allocated?).to be_falsey
end
command = RegisterPackage.new("some-locator")
handler = RegisterPackageHandler.new(package_queue)
handler.handle(command)
end
end
end
Pienso que podría implementarlo así, para lo que necesitaré introducir una clase Package
con un método factoría register
, por lenguaje de dominio. RegisterPackage
tendrá que darnos el valor del localizador.
class RegisterPackageHandler
def initialize(package_queue)
@package_queue = package_queue
end
def handle(register_package)
package = Package.register(register_package.locator)
@package_queue.put(package)
end
end
Por su parte, para hacer pasar el test necesitamos introducir la clase Package
, con algunos métodos:
class Package
def initialize(locator)
@locator = locator
end
def self.register(locator)
Package.new(locator)
end
def allocated?
false
end
end
¿Y qué hay de PackageQueue
? No necesitamos implementarlo para hacer pasar el test, nos basta con verificar que la interacción que necesitamos ocurre: RegisterPackageHandler
, envía el mensaje put
con un Package
que cumple la condición adecuada.
PackageQueue
7 está contribuyendo a la definición del puerto secundario for_enqueueing_packages. ¿Debería llamarse ForEnqueueingPackages
?
Sin embargo, necesitaremos alguna implementación concreta para que el escenario inicial pueda pasar, ya que necesitamos una instancia de un PackageQueue
para poder construir RegisterPackageHandler
en las step definitions. Generalmente, tendremos un adaptador específico para tests de todos los puertos secundarios, que tenga un comportamiento adecuado, pero que no sea costoso en términos de rendimiento ni configuración.
En lenguajes como Java o PHP será necesario declarar una interfaz e introducir implementaciones de la misma.
En Ruby no tenemos interfaces explícitas, pero podemos recurrir a varias opciones: duck typing, herencia, o usar un módulo para definir la interfaz. Estos tres mecanismos funcionan bien, pero presentan distintos inconvenientes. Sin embargo, existe un método mucho más completo y que se basa en tests.
He aquí un ejemplo muy básico. Este test define el comportamiento de cualquier objeto que pretenda actuar como un PackageQueue
:
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
end
Para escribir un test de un adaptador que implemente PackageQueue
nos basta con hacer esto:
RSpec.describe InMemoryPackageQueue do
context "honors interface" do
it_behaves_like "a PackageQueue"
end
end
Y he aquí un ejemplo de una implementación muy simple, pero que debería ser suficiente para nuestro objetivo inmediato:
class InMemoryPackageQueue
def initialize
@packages = []
end
def put(package)
@packages.unshift(package)
end
def include?(package)
@packages.include?(package)
end
end
La evolución de la estructura del código puede verse aquí. Fíjate que InMemoryPackageQueue
es un adaptador que implementa el puerto for_enqueueing_packages
, y no está dentro de app
.
También es de destacar la manera en que la estructura del código nos está contando la historia de la aplicación.
.
├── Gemfile
├── Gemfile.lock
├── coverage
├── cucumber.yml
├── features
│ ├── register_packages.feature
│ ├── step_definitions
│ │ └── steps.rb
│ └── support
│ └── env.rb
├── lib
│ ├── adapter
│ │ └── for_enqueueing_packages
│ │ └── memory
│ │ └── in_memory_package_queue.rb
│ └── app
│ ├── domain
│ │ ├── container.rb
│ │ └── package.rb
│ ├── for_enqueueing_packages
│ └── for_registering_packages
│ ├── available_container
│ │ ├── available_container.rb
│ │ ├── available_container_handler.rb
│ │ └── available_container_response.rb
│ ├── register_package
│ │ ├── register_package.rb
│ │ └── register_package_handler.rb
│ └── store_package
│ ├── store_package.rb
│ └── store_package_handler.rb
├── spec
│ ├── adapter
│ │ └── for_enqueueing_packages
│ │ └── memory
│ │ └── in_memory_package_queue_spec.rb
│ ├── app
│ │ ├── for_enqueueing_packages
│ │ │ └── package_queue_spec.rb
│ │ └── for_registering_packages
│ │ └── register_package
│ │ └── register_package_handler_spec.rb
│ ├── domain
│ └── spec_helper.rb
└── storage.iml
Ahora podemos usar InMemoryPackageQueue
en las steps defintions para poder ejecutar el escenario:
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
available_container = AvailableContainer.new
available_container_handler = AvailableContainerHandler.new
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
store_package_handler.handle(store_package)
expect(@container.contains?(@locator)).to be_truthy
end
Actualización (2023-06-11)
Un detalle que me había dejado incómodo es que la especificación de PackageQueue
no estaba en el código de producción, sino en los tests. Esto hacía que la definición del puerto quedase extrañamente vacía.
En consecuencia, la he movido a su lugar, como se puede ver aquí. De este modo, la definición del puerto ocurre dentro de la aplicación y se implementa en los adaptadores.
Conclusiones tras los primeros pasos
Tengo la sensación de haber tocado muchos temas en este artículo, no sé si de una forma suficientemente ordenada. Pero crear una aplicación no es algo trivial.
Los puntos que más me gustaría destacar son:
- Empezamos identificando a los actores interesados en usar nuestra aplicación.
- Describimos las conversaciones que previsiblemente querrían tener con nuestro sistema.
- Eso nos ayuda a descubrir los puertos primarios o drivers.
- Behavior Driven Development puede ser una herramienta muy útil para ese descubrimiento.
- Este proceso de definición puede dar muchas vueltas, avanzar y retroceder, hasta encontrar con una definición satisfactoria.
- Arquitectura Hexagonal no prescribe una organización de código, pero es evidente que tenemos que separar la aplicación en sí de los adaptadores. Dentro de la aplicación, tiene sentido hacer explícitos los puertos y organizar el código en torno a ellos.
- Los puertos secundarios o driven, empiezan a descubrirse en cuanto empezamos a implementar la funcionalidad de la aplicación.
- La metodología TDD outside-in tiene potencial para ayudarnos a descubrir y describir estos puertos.
Intentaré continuar con esta serie de artículos describiendo paso a paso cómo voy desarrollando esta aplicación. Aquí tienes el repositorio con el ejemplo que estoy escribiendo. Puedes seguir su historia a través de sus commits.
Continúa en el siguiente artículo.
-
Por simplificar el problema ↩
-
Y no tiene sentido escribir una aplicación hexagonal empezando por un framework con fuertes opiniones ↩
-
Con el uso del término rol quiero significar que una misma persona puede ejecutar los distintos roles. ↩
-
O debería serlo. ↩
-
Me gustaría recalcar que estos escenarios que estoy proponiendo no tienen que representar la mejor solución posible. Lo importante es que nos ayudan a entender el problema y descubrir las conversaciones que necesitamos describir en forma de puertos. De hecho, es posible que tengamos que revisar las decisiones tomadas hasta ahora. ↩
-
Podrías llamar a esta carpeta
business
,core
,domain_model
, inclusohexagon
, si me apuras. El nombredomain
simplemente significa aquello de lo que se ocupa la aplicación y se utiliza convencionalmente en todo tipo de modelos de arquitectura de software con ese significado, ya sea DDD, Clean Architecture, Onion Architecture, etc. ↩ -
Debo mencionar que en este punto, me pregunto cuál sería el mejor nombre para esta clase. ↩