Haciendo del Desarrollo y la Arquitectura Web, ciencia y pasión.

Arquitectura hexagonal

Hoy vamos a jugar con el tema de la Arquitectura Hexagonal, o como tambien se suele llamar, "puertos y adaptadores", es un enfoque que separa claramente la lógica de negocio de los detalles técnicos externos. Os plantearé sus principales caracteristicas y despues nos meteremos en código, que imagino que colgaré en un repo.

Este patrón de diseño fue desarrollado por Alistair Cockburn en 2005. El objetivo de este patron de diseño es el de buscar el bajo acoplamiento y alta cohesion. Para ello se basa en la separacion del core central donde se han encapsulado el comportamiento de la aplicacion.

hexagon_archictecture

La idea principal es tener un núcleo central con toda la lógica de negocio, conectado al exterior a través de puertos, que son interfaces que definen cómo interactúa el sistema con componentes externos como bases de datos o APIs.
Estos puertos se comunican mediante adaptadores, que traducen entre el formato interno del sistema y el externo. Esto permite cambiar tecnologías o simular comportamientos sin afectar la lógica central. Como resultado, la aplicación es más fácil de mantener, probar y evolucionar, ya que los cambios externos no impactan directamente en el corazón del sistema. Este desacoplamiento se busca con la conexion de piezas que podriamos considerar como plugins. Estos plugins son reemplazables sin tener impacto en la logica de negocio y permitiendo un testeo, al poder sustituir conectores o adaptadores.

Una aplicación bien diseñada debe abordar tres aspectos basicos: el modelo que representa el problema que intenta resolver, la representación de las intenciones de los usuarios o propiamente los casos de usos, y las tecnologías necesarias para implementar y ejecutar el sistema. Estos elementos trabajan en conjunto para garantizar que la aplicación sea funcional, usable y eficiente.

En primer lugar, el modelo es la representación abstracta que pretender resolver un problema que la aplicación necesita capturar y gestionar. Este modelo encapsula conceptos, relaciones y reglas que reflejan el dominio sobre el cual opera la aplicación. Por ejemplo, en una aplicación de gestión de pedidos, el modelo incluiría entidades como "Producto", "Cliente" y "Pedido", junto con las relaciones entre ellas y las reglas de negocio que regulan su comportamiento (por ejemplo, un cliente puede realizar múltiples pedidos, pero cada pedido está asociado a un solo cliente). El modelo debe ser lo suficientemente detallado como para capturar las necesidades del negocio, pero también lo suficientemente flexible como para adaptarse a cambios futuros.

En segundo lugar, la representación de las intenciones de los usuarios es crucial para garantizar que la aplicación cumpla con las expectativas de quienes la utilizan. Esto implica comprender qué acciones desean realizar los usuarios y cómo prefieren interactuar con el sistema. La interfaz de usuario (UI) y la experiencia de usuario (UX) juegan un papel central aquí, proporcionando mecanismos claros y accesibles para que los usuarios expresen sus intenciones. Por ejemplo, en una aplicación bancaria, un usuario podría querer transferir dinero, consultar su saldo o pagar facturas. La aplicación debe ofrecer interfaces intuitivas que permitan realizar estas tareas de manera eficiente y segura. Además, es importante considerar diferentes tipos de usuarios (administradores, clientes, etc.) y ajustar la interacción en función de sus roles y necesidades específicas.

Finalmente, las tecnologías necesarias son los componentes técnicos que sustentan la implementación y operación de la aplicación. Estas incluyen Linea de comandos, bases de datos, servidores, APIs externas y cualquier otra herramienta que sea requerida para materializar el modelo y satisfacer las intenciones de los usuarios.

Otro de los problemas que intenta resolver este patron de diseño, es el modo en que se efectua el testing de una aplicacion, aislando y separando los mocks u objetos de prueba. Al crear adaptadores que son independientes, podemos sustituir bases de datos o APIs externas con mocks o stubs. El testing involucra a menudo tecnologías no disponibñes en el entorno test, así que es preciso buscar una forma de sustituirlos, y esto es lo que nos da precisamente esta arquitectura. Mencionar que el hecho de que sea hexagonal es más metaforico que necesario, quizá deberia llamarse arquitectura poligonal.

Recapitulando: dentro del hexagono encontraremos la funcionalidad de la logica de negocio de la app, y fuera los elementos para interaccionar con el mundo exterior.

Actores y puertos

Vamos a desgranar cada uno de los elementos. Vamos con los actores y con los puertos. como en toda arquitectura los actores son los elementos que interaccionan con la app, pueden ser humanos, otros sistemas, o soluciones tecnologicas. Nos encontraremos dos tipos de actores
Driver actors: Aquellos que piden a la app que haga algo: personas, test, o clientes
Driven actors: Aquellos a los que la app pide que hagan algo como un BBDD u otros sistemas.

Ahora vamos con los puertos. Un puierto se define como la necesidad de comunicacion y de como llevarlo acabo. Tambien tenemos dos tipos de puertos, dependiendo de quien inicie la accion:

Driving o driver ports: son los puertos empleados por los driver actors.
Driven ports: son los puertos que usará la app para comunicarse con los driven actors para completar sus acciones.

Hablando de los puertos, éstos son elementos o constructos del lenguaje de programación. Los driver ports son interfaces de cada caso de uso, que los adaptadores usarán.
Respecto a los driven ports, la filosofia es la misma: los interfaces deben ser implementados por los adaptadores. A su vez los driven adapters reciben y entregan objetos del dominio.

Adaptadores

Los adapters son implementaciones concretas de tecnologías para poder intearactuar con los puertos de la App y tenemos:
Driving adapters: Usados ppora los driving ports para usar la app.
Driven adapters: Usados por los driver ports para que la App pueda usar las soluciones tecnologicas. Estos adaptadores serían por ejemplo los controladores que exponen un API rest que atiende peticiones HTTP y devolver una respuesta o una interfaz web.

Bien, ahora vamos a hacer una pequeña prueba de concepto. He creado un pequeño escenario con un modelo muy limitado para gestion de usuarios. He creado una base de datos con una unica tabla y un registro numero 1. Tiene tres adaptadores, que se conectan a sus puertos, uno por web y otro a traves de la consola, como un comando. Y adicionalmente  otro mas nos caldra para hacer el testing. Como de costumbre colgaré todo el codigo en github:

Primero tenemos la entidad User que tendremos en el core de la app:

<?php

namespace App\Domain;
//entidad de dominio de la arquitectura hexagonal
class User {
    private int $id;
    private string $name;
    private string $email;

    public function __construct(int $id, string $name, string $email) {
        $this->id = $id;
        $this->name = $name;
        $this->email = $email;
    }

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

    public function getName(): string {
        return $this->name;
    }

    public function getEmail(): string {
        return $this->email;
    }
}

Este será el primero de los puertos de que hablamos, efectivamente se trata de un interfaz, nuestro contrato:


<?php
// el puerto de entrada de datos
namespace App\Domain;

interface UserRepository {
    public function findById(int $id): ?User;
    public function save(User $user): void;
}

El segundo puerto que dará servicio al adaptador de la linea de comando


<?php

namespace App\Domain;

interface UserCommandPort
{
    public function getUser(int $id): void;
} 

Este el puerto para las conexiones http


<?php
namespace App\Domain;

interface UserHttpPort
{
    public function getUser(int $id);
} 

Aqui tenemnos el adaptador de la linea de comandos, efectivamente esta extendiendo el interfaz:


<?php
namespace App\Infrastructure\CLI;

use App\Application\UserService;
use App\Domain\UserCommandPort;

class UserCommand implements UserCommandPort {
    private UserService $userService;

    public function __construct(UserService $userService) {
        $this->userService = $userService;
    }

    public function getUser(int $id): void {
        $userDTO = $this->userService->getUser($id);

        if (!$userDTO) {
            echo "User not found.\n";
            return;
        }

        echo "User ID: " . $userDTO->getId() . "\n";
        echo "Name: " . $userDTO->getName() . "\n";
        echo "Email: " . $userDTO->getEmail() . "\n";
    }
}

Aqui tenemos el otro adaptador para las conexiones http:


<?php

namespace App\Infrastructure\Http;

use App\Application\UserService;
use App\Domain\UserHttpPort;

class UserController implements UserHttpPort {
    private UserService $userService;

    public function __construct(UserService $userService) {
        $this->userService = $userService;
    }

    public function getUser(int $id) {
        $user = $this->userService->getUser($id);

        if ($user) {
            return json_encode([
                'id' => $user->getId(),
                'name' => $user->getName(),
                'email' => $user->getEmail()
            ]);
        }

        return json_encode(['error' => 'User not found']);
    }
}

Aqui tenemos el adaptador que implementara el puerto de UserRepository


<?php

namespace App\Infrastructure\Persistence;

use App\Domain\UserRepository;
use App\Domain\User;
use PDO;
//Aquí implementamos la persistencia en MySQL. Uno de los adaptadores que implementaran el puerto de UserRepository

class MySQLUserRepository implements UserRepository {
    private PDO $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    public function findById(int $id): ?User {
        $stmt = $this->pdo->prepare("SELECT * FROM users WHERE id = ?");
        $stmt->execute([$id]);
        $data = $stmt->fetch();

        return $data ? new User($data['id'], $data['name'], $data['email']) : null;
    }

    public function save(User $user): void {
        $stmt = $this->pdo->prepare("INSERT INTO users (id, name, email) VALUES (?, ?, ?)");
        $stmt->execute([$user->getId(), $user->getName(), $user->getEmail()]);
    }
}

Aqui tenemos el cliente de consola:


<?php

require 'vendor/autoload.php';

use App\Infrastructure\Persistence\MySQLUserRepository;
use App\Application\UserService;
use App\Infrastructure\CLI\UserCommand;

// Configuración de la base de datos
$pdo = new PDO("mysql:host=localhost;dbname=hexagon", "daniel", "Asdf1234");
$userRepository = new MySQLUserRepository($pdo);
$userService = new UserService($userRepository);
$userCommand = new UserCommand($userService);

// Leer argumento de la terminal
if ($argc < 3 || $argv[1] !== 'getUser') {
    echo "Uso: php cli.php getUser \n";
    exit(1);
}

$id = (int) $argv[2];
$userCommand->getUser($id);

Aqui tenemos el punto de entrada:


<?php

require 'vendor/autoload.php';

use App\Infrastructure\Persistence\MySQLUserRepository;
use App\Application\UserService;
use App\Infrastructure\Http\UserController;

$pdo = new PDO("mysql:host=localhost;dbname=hexagon", "daniel", "Asdf1234");
$userRepository = new MySQLUserRepository($pdo);
$userService = new UserService($userRepository);
$userController = new UserController($userService);

// Simulación de una petición GET
$id = $_GET['id'] ?? 1;
echo $userController->getUser((int)$id);

Aqui tenemos los tests:


<?php

use PHPUnit\Framework\TestCase;
use App\Application\UserService;
use App\Infrastructure\Persistence\InMemoryUserRepository;
use App\Domain\User;

class UserServiceTest extends TestCase {
    private UserService $userService;
    private InMemoryUserRepository $userRepository;

    protected function setUp(): void {
        $this->userRepository = new InMemoryUserRepository();
        $this->userService = new UserService($this->userRepository);
    }

    public function testCreateUser(): void {
        $this->userService->createUser(1, "John Doe", "john@example.com");

        $user = $this->userRepository->findById(1);

        $this->assertNotNull($user);
        $this->assertEquals("John Doe", $user->getName());
        $this->assertEquals("john@example.com", $user->getEmail());
    }

    public function testGetUser(): void {
        $this->userRepository->save(new User(2, "Alice", "alice@example.com"));

        $userDTO = $this->userService->getUser(2);

        $this->assertNotNull($userDTO);
        $this->assertEquals("Alice", $userDTO->getName());
        $this->assertEquals("alice@example.com", $userDTO->getEmail());
    }
}

Aqui vemos un ejemplo de la llamada desde la linea de comando


 daniel@docker-home  ~/proyectos/articulo hexagonal/project  php cli.php getUser 1
User ID: 1
Name: daniel
Email: daniel.gimeno@gmail.com

Aqui podemos la llamada a la ejecucion de los tests:


 daniel@docker-home  ~/proyectos/articulo hexagonal/project  vendor/bin/phpunit tests/         
PHPUnit 10.5.45 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.1.2-1ubuntu2.20

..                                                                  2 / 2 (100%)

Time: 00:00.007, Memory: 6.00 MB

OK (2 tests, 6 assertions)

Y por ultimo el lanzamiento de servidor de la app. Que lo hecho en plan sencillo desde linea de comando con php -S


 ✘ daniel@docker-home  ~/proyectos/articulo hexagonal/project  sudo php -S 0.0.0.0:8080
[sudo] contraseña para daniel: 
[Sun Mar  9 23:26:16 2025] PHP 8.1.2-1ubuntu2.20 Development Server (http://0.0.0.0:8080) started

Y bueno, por último comentar que en Github está todo este codigo. Espero que os ayude y que hayais aprendido algo. Hasta la próxima.