Arquitectura hexagonal

Arquitectura de software

Arquitectura de software Reglas autoimpuestas al definir como diseñamos software

¿Que ganamos entonces inponiendonos este tipo de reglas?

  • Buscamos la mantenibilidad: Somos capaces de mantener mejor el código gracias a como formamos la arquitectura
  • Buscamos la cambiabilidad: Somos capaces de reemplazar piezas de nuestra arquitectura sin aparentemente un costo muy grande
  • Buscamos el testing: Somos capaces de testear nuestro código de una forma rápida, sencilla y eficaz.
  • Buscamos la simplicidad: Somos capaces de tener un código simetrico, que sea fácil de entender. Si entiende un caso de uso, serás capaz de entender cualquier otro, nuestro código se vuelve predecible.

Esto también nos aleja de errores que no queremos cometer:

  • Evitar la complejidad accidental: Evitamos la complejidad accidental al no introducir con nuestros desarrollos más complejidad de la que el sistema ya tiene https://codely.tv/wp-content/uploads/2016/05/complejidad-accidental-vs-complejidad-esencial.jpg

Active Record vs Data Mapper

Active Record

Se basa en que las propias entidades de nuestro dominio contengan la infraestructura necesaria a nivel de persistencia para poder ser persistidas, actualizadas, y recuperadas. La capa de dominio es la que se comunica con la base de datos.

¿Como se refleja esto en el código?

Las entidades extienden de una clase base que se encarga de la persitencia a base de datos.

class Product < ApplicationRecord
end

Entonces nos encontramos con que los métodos de interacción con la base de datos están en la misma entidad:

# Crea una nueva instancia de User y a la vez la persiste en BD:
user = User.create(name: "David", occupation: "Code Artist")

# Guarda una instancia ya creada a través del constructor new:
user.save

# Busca un usuario en base de datos en base a un criterio de selección:
david = User.find_by(name: 'David')

# Actualiza el estado de una instancia en base de datos:
david.update(name: 'Pepe')

Data mapper

Doctrine es el ORM más extendido en el ecosistema PHP, como lo podría ser Hibernate en Java. Y en este caso nos encontramos con que a diferencia del patrón ActiveRecord, implementa el patrón DataMapper.

El patrón DataMapper lo que permite es justamente que nuestras entidades no conozcan nada relativo a cómo éstas son persistidas en la base de datos.

¿Como ocurre esto?

Por un lado tenemos nuestra entidad:

// Ejemplo simplificado de: <https://github.com/CodelyTV/cqrs-ddd-php-example/blob/master/src/Context/Video/Module/Video/Domain/Video.php>

final class Video
{
    private $id;
    private $title;

    public function __construct(VideoId $id, VideoTitle $title)
    {
        $this->id       = $id;
        $this->title    = $title;
    }

    public function id(): VideoId
    {
        return $this->id;
    }

    public function title(): VideoTitle
    {
        return $this->title;
    }
}

Por otro lado tendremos el fichero de definición el PHP, YAML, o XML que establece el mapeo entre cada una de las entidades y atributos de nuestro sistema, y las tablas y columnas en las que son persistidas:

# Ejemplo simplificado de: <https://github.com/CodelyTV/cqrs-ddd-php-example/blob/master/src/Context/Video/Module/Video/Infrastructure/Persistence/Video.orm.yml>

CodelyTv\\Context\\Video\\Module\\Video\\Domain\\Video:
  type:  entity
  table: video

  id:
    id:
      type: video_id
      column: id
      length: 36

  fields:
    courseId:
      type: course_id
      column: course_id

  embedded:
    title:
      class: CodelyTv\\Context\\Video\\Module\\Video\\Domain\\VideoTitle
      columnPrefix: false

Y finalmente definir la implementación necesaria para interaccionar con la base de datos:

// Ejemplo simplificado de: <https://github.com/CodelyTV/cqrs-ddd-php-example/blob/master/src/Context/Video/Module/Video/Infrastructure/Persistence/VideoRepositoryMySql.php>
// Y: <https://github.com/CodelyTV/cqrs-ddd-php-example/blob/master/src/Infrastructure/Doctrine/Repository.php>

namespace CodelyTv\\Context\\Video\\Module\\Video\\Infrastructure\\Persistence; // ℹ️ Destacar namespace de capa de Infraestructura

final class VideoRepositoryMySql implements VideoRepository
{
    private $entityManager;

    public function __construct(EntityManager $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function save(Video $video): void
    {
        $this->entityManager()->persist($entity);
        $this->entityManager()->flush($entity);
    }

    public function search(VideoId $id): ?Video
    {
        return $this->repository(Video::class)->find($id);
    }
}

¿Por qué elegir Data Mapper?

Justamente el por qué optar por DataMapper se basa en el pilar de la Arquitectura Hexagonal: Partimos de la premisa de que los detalles de infraestructura deben ser altamente tolerantes al cambio, con lo cuál, si usamos Data Mapper, nuestro dominio no estará acoplado a la infraestructura.

Para lograr ese desacoplamiento, establecemos la interface a nivel de contrato de Dominio (lo que denominaríamos como puerto en términos de Ports & Adapters):

Ejemplo de: <https://github.com/CodelyTV/cqrs-ddd-php-example/blob/master/src/Context/Video/Module/Video/Domain/VideoRepository.php>

namespace CodelyTv\\Context\\Video\\Module\\Video\\Domain; // ℹ️ Destacar namespace de capa de Dominio

interface VideoRepository
{
    public function save(Video $video): void;

    public function search(VideoId $id): ?Video;
}

Y declaramos la citada implementación en la capa de infraestructura (lo que denominaríamos como adaptador en términos de Ports & Adapters).

Ejemplo de: https://github.com/CodelyTV/cqrs-ddd-php-example/blob/master/src/Context/Video/Module/Video/Domain/VideoRepository.php
 namespace CodelyTv\Context\Video\Module\Video\Domain; // ℹ️ Destacar namespace de capa de Dominio
 interface VideoRepository
 {
     public function save(Video $video): void;
 <code>public function search(VideoId $id): ?Video;
 }

Y declaramos la citada implementación en la capa de infraestructura (lo que denominaríamos como adaptador en términos de Ports & Adapters).

Si aplicamos este desacoplamiento seremos capaces de persistir en la base de datos respetando el Principio de inversión de dependencias (DIP)

DIP Los módulos de alto nivel no deberían depender de los módulos de bajo nivel. Ambos deberían depender de abstracciones.

// Ejemplo simplificado de: <https://github.com/CodelyTV/cqrs-ddd-php-example/blob/master/src/Context/Video/Module/Video/Application/Find/VideoFinder.php>

namespace CodelyTv\\Context\\Video\\Module\\Video\\Application\\Find; // ℹ️ Destacar namespace de capa de Aplicación (estamos en el caso de uso de buscar un vídeo)

// ℹ️ Destacar que no tenemos ningún import/use de la capa de Infraestructura.
// Aplicación sólo conoce Dominio gracias al haber aplicado el DIP en los puertos y adaptadores:
use CodelyTv\\Context\\Video\\Module\\Video\\Domain\\Video;
use CodelyTv\\Context\\Video\\Module\\Video\\Domain\\VideoId;
use CodelyTv\\Context\\Video\\Module\\Video\\Domain\\VideoNotFound;
use CodelyTv\\Context\\Video\\Module\\Video\\Domain\\VideoRepository;

final class VideoFinder
{
    private $repository;

    public function __construct(VideoRepository $repository) // ℹ️ Nos acoplamos al contrato de dominio, no a la implementación concreta.
    {
        $this->repository = $repository;
    }

    public function __invoke(VideoId $id): Video
    {
        return $this->repository->search($id);
    }
}

Gracias a esto seremos capaces de reemplazar la infraestructura, la implementación del repositorio por otra distinta y no tendremos que cambiar nada en la capa de aplicación ni de dominio.

Sevicios de infraestructura

No acoplar la estructura de un contrato con su implementación

Un error muy común sería modelar el dominio de tal forma que aunque no esté acoplado a la infraestructura, si esté pensando para tener la estructura para alguna implementación específica. Por ejemplo, en nuestro aplicación tenemos un servicio de notificaciones, y desde un principio tenemos claro que en la infraestructura inicial estará slack como ese servicio, el error sería modelar el dominio para que cumpla los requisitos que pide tal implementación.

💉 Inyectar las dependencias de los adaptores/implementaciones por constructor

Para poder solventar esto, debemos modelar el dominio pensando en que sea lo más abstracto posible, sin influirle con nada, y en el caso de necesitar parámetros específicos para la implementación, lo haríamos mediante su constructor. El contructor nunca lo definiremos en la interface, irá en sus implementaciones, y entonces será la infraestructura la que se encarge de conocer el dominio y de como acoplarse a el.

🧪 Usamos implementaciones fake de servicios para nuestros test

Usaremos implementaciones fake para poder testear sin tener que ejecutar el código real que tenemos en la infraestructura. Estaríamos evitando también tener que falsear el comportamiento de un servicio existente, algo que se puede complicar al tratar de mockear las dependencias y funcionamiento de esa implementación. Por lo que nosotros, si tuvieramos un servicio de notificaciones usaríamos algo así:

final class FakeNotifier implements Notifier
{
		public function notify(NotificationText $text, NotificationType $action)
		{
				//I do nothing
		}
}

La estructura de directorios en arquitectura hexagonal

Las capas superiores conocen las capas inferiores y no al revés:

Nuestro dominio no conoce detalles de implementación de la capa de infraestructura, solo define el contrato para que sea la infraestructura quien implemente dicho funcionamiento.

La aplicación conoce el dominio a modo de poder presentar la información para que otros la consuman, en este caso, sera la capa de infraestructura quien use la aplicación para realizar las operaciones pertinentes.

La infraestructura son detalles de implementación, como puede ser una base de datos, y debería poder cambiarse un tipo de infraestructura por otra sin afectar al funcionamiento base de la aplicación y el dominio.

Módulos o sub-dominios

Para tener una arquitectura más limpia, recurrimos a estructurar los directorios de una forma distinta a lo que fuera aplicar simplemente application-domain-infrastructure utilizando módulos o sub-dominios, por ejemplo:

Before

.
├── application
│   ├── user
│   │   └── search
│   └── video
│       ├── create
│       └── search
├── domain
│   ├── user
│   └── video
├── entry_point
│   └── controller
│       ├── status
│       ├── user
│       └── video
└── infrastructure
    ├── shared
    │   ├── config
    │   ├── dependency_injection
    │   └── persistence
    ├── user
    │   ├── dependency_injection
    │   ├── marshaller
    │   └── repository
    └── video
        ├── dependency_injection
        ├── marshaller
        └── repository

After

.
├── entry_point
│   ├── EntryPointDependencyContainer.scala
│   ├── Routes.scala
│   ├── ScalaHttpApi.scala
│   └── controller
│       ├── status
│       ├── user
│       └── video
└── module
    ├── shared
    │   └── infrastructure
    ├── user
    │   ├── application
    │   ├── domain
    │   └── infrastructure
    └── video
        ├── application
        ├── domain
        └── infrastructure

Los beneficios de tener la estructura de directorios organizada de tal forma serían:

Cohesión y facilidad de encontrar lo que buscas:

  • La aplicación refleja conceptos de dominio antes que conceptos relacionados con la arquitectura de software
  • Los conceptos relacionados con un mismo módulo están más cerca unos de otros

Escalabilidad del código/mantenibilidad

  • Para mantener la aplicación a lo largo del tiempo buscamos el aislamiento entre los distinto módulos. En el ejemplo anterior vemos que en el before para eliminar todo lo referente a video es mucho más complicado que realizarlo en el after, ya que es en el after donde tenemos más aislado y localizado dicho módulo.

Application service vs Domain service

Servicios de aplicación

Como podemos ver en la imagen, los servicios de aplicación son el punto de entrada de nuestra aplicación. Desde el controlador ya sea de tipo API o línea de comandos, se llama al servición de aplicación para que este se encarge de realizar las operaciones pertinentes, como pueda ser:

  • Solicita operaciones al sistema de persistencia. Dichas operaciones comienzan y finalizan en los servicios de aplicación.
  • Publicar eventos de dominio

Son el inicio de un caso de uso de nuestro aplicación.

Un Servicio de aplicación instancia un Servicio de dominio para evitar la duplicidad de código.

Servicios de dominio

Son el resultado de agrupar lógica de negocio que podremos reutilizar desde los servicios de aplicación. Imaginemos que tenemos dos casos de uso en nuestra aplicación:

  • Obtener una playlist en base a su identificador
  • Modificar el nombre de una playlist

¿Que comparten ambos casos?

  • Necesitan ir al repositorio de playlists a buscar la playlist dado un identificador
  • Lanzar una excepción de dominio tipo PlaylistNotFound en el caso de que no encuentre la playlist
  • Retornar la playlist en caso de encontrarla

Entonces extraeremos a un Servicio de dominio dicho compartimiento común que tendrían nuestros dos casos de uso (dos servicios de aplicación).

Modelado de dominio y registro de eventos

Value object

Un value object es el resultado de encapsular nuestrá semántica de dominio en un objeto. Por ejemplo:

//Before
String dni = "xxxxxxxxx"
//After
Dni dni = new Dni("xxxxxxxxx");

¿Y que nos aporta modelar de esa manera? Supongamos que estamos tratando números de DNI, lo lógico sería que no aceptemos cualquier tipo de número que nos pasen, debería tener una validaciones con el fin de no tener datos incoherentes. Al hacer que nuestro manejo de DNI no sea con un string primitivo, si no que sea un objeto de dominio, estamos abriendo la posibilidad de que cuando construyamos dicho objeto hagamos ciertas validaciones que permitan o no su creación, por lo que si tuviéramos un DNI erróneo devolveríamos un error.

class Dni{
    String dni;

    private Dni(String dni) {
        this.dni = dni;
    }

    public static Dni createDni(String dni) {
        if (dni==null){
            throw new RuntimeException("Dni cannot be null");
        }
        return new Dni(dni);
    }
}

Unas comprobaciones que evitamos repetir por todo el código.

Testing capa de aplicación y dominio (test unitario)

Los test unitarios son los que usaremos para comprobar que la lógica de negocio de nuestros casos de uso (capa de aplicación) y modelos o servicios de dominio se comportan como esperamos. Características principales:

  • El objetivo de estos tests es el de validar que la implementación de nuestra lógica de negocio es correcta.
  • Son los test más rápidos de ejecutar. En estos tests falsearemos la implementación a usar de todo componente de infraestructura. Es decir, allá donde definamos un puerto en nuestros casos de uso, inyectaremos un doble de test para que no hagan operaciones de entrada/salida pero poder validar la interacción del dominio con estos componentes. Importante falsear la interface de dominio y no el cliente final para evitar incurrir en el anti-patrón de Infrastructure Mocking.
  • El test unitario será independiente del punto de entrada. Desde el momento en el que encapsulamos nuestros casos de uso en servicios de aplicación para poderlos reaprovechar desde múltiples puntos de entrada (controlador API HTTP o CLI), el test unitario invocará directamente al caso de uso para desacoplarse también del controlador.
  • Al ser los más rápidos de ejecutar y estar centrados en la lógica de negocio, es en estos test donde ubicamos las comprobaciones más exhaustivas en cuanto a las distintas ramificaciones de nuestros casos de uso.

Gracias a que nuestro dominio no conoce detalles de implementación de la capa de infraestructura, solo define el contrato para que sea la infraestructura quien implemente dicho funcionamiento, tenemos la posibilidad de crear implementaciones específicas para nuestros test. Supongamos que tenemos un servicio que necesita un repositorio para persistir en base de datos, dado que no estamos acoplados a una implementación específica, en nuestros test podrías definir una nueva implementación que permita testear solo el comportamiento de la capa de aplicación y de dominio, ya que en este caso no necesitaríamos testear infraestructura en este tipo de test unitario.

Testing capa de infraestructura

Un tipo de test donde el objeto de test es alguna implementación de uno de nuestros puertos. Es decir, en el caso del test unitario, habríamos falseado mediante un doble (PlaylistRepositoryFake) de test la interface de dominio PlaylistRepository, mientras que en el test de integración lo que haremos será justamente testear la implementación de PlaylistRepositoryPgsql para validar que se comporta como esperamos.

		@Test
    public void it_should_save_a_playlist()
    {
        repository().save("Hello, I am a playlist");
    }

    @Test
    public function it_should_check_if_exists_a_playlist()
    {
        String playlist = "Hello, I am a playlist";

        repository().save(playlist);
        
        assertThat(repository().search($playlist)).isTrue();
    }

Test de aceptación

Simulan ser un cliente de nuestra aplicación. Entrarán en juego todas las implementaciones reales para comprobar que todo el flujo y la integración con la infraestructura se producen satisfactoriamente. Con lo cuál, las características principales serían:

  • El objetivo de estos tests es el de asegurar que la aplicación funciona correctamente y el flujo completo de las peticiones se puede realizar satisfactoriamente.
  • Son los test más lentos de ejecutar ya que tienen un alcance mayor y sí ejecutan operaciones de entrada/salida como inserts en base de datos ya que usan las implementaciones reales de estos componentes.
  • Aportan mayor valor debido al alcance que tienen (nos asegura que absolutamente todo está ejecutandose como esperamos)
  • En nuestro caso, al implementar una API HTTP, simularemos peticiones HTTP y comprobaremos que las respuestas tienen el código HTTP y el contenido del cuerpo esperados.
  • Al ser los test más lentos de ejecutar, sólo implementaremos una pequeña muestra de las distintas ramificaciones que pueden tomar nuestros casos de uso. Dejando para los test unitarios la responsabilidad de probar cada una de las casuísticas. Así evitaremos incurrir en el anti-patrón de test del cono de helado.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *