Cómo implementar una Arquitectura Hexagonal con Symfony Flex

2022-04-12 - Categorías: General / PHP / Symfony
Symfony Hexagonal

Este es un codekata en PHP con Symfony Flex para montar una estructura usando Arquitectura Hexagonal, en unos minutos, para un proyecto nuevo.

Symfony tiene su propia estructura por defecto, nos invita a seguir una serie de directorios en donde se guardan los controladores, repositorios, entidades, etc.. Esto esta muy bien, puede parecer que nos cierra a dicha estructura, pero nada más lejos de la realidad. Igual que en cualquier otro proyecto en puro PHP, con Symfony se puede estructurar todo como queramos.

Además, a partir de la versión 4 de Symfony se empezó a introducir la tecnología Flex, con la que todo está orientado a microservicios. Y a partir de la versión 5, Symfony Flex es el punto de partida, con lo que sólo nos instalamos los componentes que necesitamos, ya sean de Symfony o de la comunidad PHP.

Al grano, iniciando un proyecto nuevo

Partimos de que ya tenemos todas las herramientas instaladas en el sistema. Así que vamos a un terminal y lanzamos el comando de Symfony:

symfony new symfony-hexagonal

..si todo ha ido bien, entonces ya tenemos iniciado el proyecto.

Instalando unos requerimientos de Composer

Entonces, es muy recomendable instalar el maker de Symfony para generar código. Con el maker ahorrarás mucho tiempo en tediosas tareas de generación de código, estructurado y limpio, que siempre es igual. Para este post, el ORM de Doctrine lo vamos a ver ya que facilita mucho el trabajar con la base de datos sin picar nada de código SQL.

cd symfony-hexagonal
composer require --dev symfony/maker-bundle
composer require symfony/orm-pack

Generando algo de código fuente, unos controladores y una entidad

De nuevo vamos al terminal, y podemos lanzar algo como lo siguiente:

php bin/console make:controller ApiController
php bin/console make:controller DefaultController
php bin/console make:entity Task

..por no complicar el post, podemos dejar la entidad Task sólo con un title de tipo string, cuando el generador de código nos va preguntando por los campos que queremos. Lo siguiente entonces será configurar la BD, y aplicar los cambios para poder seguir. Configuramos entonces el .env o .env.local mejor, descomentando para configurar SQLite que no necesita instalar nada más que PHP y sus extensiones, y comentamos las demás BDs:

DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

Lo siguiente es lanzar desde terminal esto, con lo que creamos la BD y su estructura:

php bin/console doctrine:database:create
php bin/console doctrine:schema:create

Ya hasta aquí, tendremos una estructura genérica Symfony, con una base de datos, si todo ha ido bien se tiene que ver algo tal que así:

Ahora vamos con la Arquitectura Hexagonal

Hay mucha bibliografía sobre Arquitectura Hexagonal por Internet. Así que, parafraseando un poco, tenemos hasta ahora un software estructurado por capas que no es hexagonal.

Tenemos de momento sólo estas capas: controladores, entidades y repositorios. Pero en la Arquitectura Hexagonal, tenemos que tener otra estructura con las siguientes capas:

  • Dominio:
    • Esta es la capa más interna. Aquí va todo el código genérico con los objetos que manejan conceptualmente todo. Por ejemplo, para este post tenemos una aplicación orientada a manejar tareas, podríamos poner aquí los conceptos de las tareas, prioridades, personas, asignaciones, etc.. todo lo relacionado con lo especial a nivel conceptual. Es decir, aquí va todo lo relacionado con el modelo del dominio.
  • Aplicación:
    • Aquí van todos los casos de uso que hacen que se conecten las capas de infraestructura con el dominio. Por ejemplo, unos casos de uso para una aplicación de gestión de tareas podrían ser AñadirTarea(X), AñadirPersona(Y), ObtenerTodasLasTareasPendientesDeAsignacion(), ObtenerLasTareasAsignadasAUnaPersona(Y), AsignarTareaAPersona(X, Y), etc.. Es decir, aquí van todos los casos de uso que dan externamente funcionalidades a la aplicación.
  • Infraestructura:
    • Aquí finalmente tenemos los puntos de entrada y salida para toda la aplicación. Por ejemplo en este caso para el post, el contacto con el exterior será por HTTP y un BD SQLite: una API (ApiController.php) para obtener las tareas bajo ruta /api, otro endpoint (DefaultController.php) para generar tareas aleatoriamente bajo ruta /default, y un adaptador a Base de Datos (TaskRepository.php) para la persistencia.

Lo bueno de esta arquitectura es que las capas más internas (dominio y aplicación), no saben nada del exterior, son independientes. Así de esta forma el programa será mucho más mantenible, robusto, modificable, etc..

Por ejemplo, si cambiamos la forma de almacenamiento de SQL a NoSQL, sólo habría que cambiar la capa de infraestructura, no se verían afectadas las otras capas de dominio y aplicación. Igualmente si ahora queremos una web, una app, mensajes en chats o email.. solo tocaríamos infraestructura.

Igualmente si modificamos la capa de dominio o aplicación, los comportamientos internos, no harían falta cambios en las capa externa de infraestructura.

Al grano entonces, moviendo todo para hacerlo hexagonal

Hay muchas opciones, el único requisito es tiene que quedar claro lo que es de cada capa. Una de las opciones es hacer una carpeta por cada capa. Por ejemplo podríamos hacer así:

Siguiendo a modo de code-kata, vamos a editar un poco los ficheros para hacerlo funcionar. Entonces habrá que decirle a Doctrine que las entidades van a estar en el directorio src/Domain/Entity editando el fichero config/packages/doctrine.yaml con estas dos líneas:

doctrine:
    orm:
        mappings:
            App:
                dir: '%kernel.project_dir%/src/Domain/Entity'
                prefix: 'App\Domain\Entity'

En el fichero de configuración de las anotaciones, hay que decirle donde van a estar los controladores de entrada, para la capa de infraestructura, en el config/routes/annotations.yaml:

controllers:
    resource: ../../src/Infrastructure/Http/

Para el autowiring, hay que excluir las entidades de dominio de la configuración en el fichero config/services.yaml:

services:
    App\:
        exclude:
            - '../src/Domain/Entity/'

Añadiendo algo de funcionalidad

Para terminar con el post, vamos a ir añadiendo un par de acciones y viendo algo del código. Vamos a tener un puerto de entrada HTTP que son los controladores, y de salida un adaptador a base de datos, en forma de repositorio original ORM de Doctrine sin complicarnos más.

De los controladores, uno para crear tareas random, el default con punto de entrada /default:

<?php

namespace App\Infrastructure\Http;

use App\Application\AddTaskUseCase;
use App\Infrastructure\Repository\TaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class DefaultController extends AbstractController
{
    /**
     * @Route("/default", name="app_default")
     */
    public function index(AddTaskUseCase $addTaskUseCase, EntityManagerInterface $entityManager): JsonResponse
    {
        return $this->json([
            'message' => 'Hi! Welcome to your new controller! '.$addTaskUseCase->execute($entityManager),
            'path' => 'src/Infrastructure/Http/DefaultController.php',
        ]);
    }
}

Como va a crear tareas aleatorias, le vamos a pasar el manejador de entidades, que en este caso va a ser el ORM de Doctrine que tenemos engancha a SQLite. ¡Ojo! primera utilidad, ahora tenemos el caso de uso de la capa de Aplicación y Dominio aislados, con lo que en cualquier momento podemos crear otro manejador de entidades para los tests, o cambiarlo sin más problema porque es de la capa de Infraestructura. Ni el caso de uso ni dominio necesarian modificaciones.

Ahora el controlador que muestra las tareas almacenadas, el de punto de entrada /api:

<?php

namespace App\Infrastructure\Http;

use App\Application\GetTasksUseCase;
use App\Infrastructure\Repository\TaskRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class ApiController extends AbstractController
{
    /**
     * @Route("/api", name="app_api")
     */
    public function index(GetTasksUseCase $getTasksUseCase, TaskRepository $taskRepository): JsonResponse
    {
        return $this->json([
            'allThetasks' => $getTasksUseCase->execute($taskRepository),
        ]);
    }
}

¡Ojo! aquí de nuevo vamos a independizar la capa de aplicación de infraestructura, pasándole el repositorio de tareas. Así de nuevo volvemos a aislar las capas internas, de forma que podremos cambiar en cualquier momento el repositorio de tareas para que se encuentren en cualquier otro lado que no sea SQLite, y las capas de Aplicación y Dominio no necesitarían ningún cambio.

Ahora el caso de uso de añadir tarea:

<?php

namespace App\Application;

use App\Domain\Entity\Task;
use Doctrine\ORM\EntityManagerInterface;

class AddTaskUseCase
{
    public function execute(EntityManagerInterface $entityManager)
    {
        $newTask = new Task();
        $newTask->setTitle('A new random task '.rand());

        $entityManager->persist($newTask);
        $entityManager->flush();

        // Otras posibles acciones relacionadas con añadir tareas..

        return 'Adding a random task..';
    }
}

..aquí es donde añadiríamos toda la lógica de relacionada con crear tareas, tocando el dominio y desembocando en las acciones que fueran necesarias.

Otro caso de uso para obtener la información de las tareas, con toda la posible funcionalidad relacionada:

<?php

namespace App\Application;

use App\Domain\Entity\Task;
use App\Infrastructure\Repository\TaskRepository;

class GetTasksUseCase
{
    public function execute(TaskRepository $taskRepository)
    {
        $titles = [];
        $tasks = $taskRepository->findAll();
        foreach ($tasks  as $task) {
            $titles[] = $task->getTitle();
        }

        // Otras posibles acciones relacionadas con obtener las tareas..

        return $titles;
    }
}

La entidad casi quedará por defecto, junto con su repositorio:

<?php

namespace App\Domain\Entity;

use App\Infrastructure\Repository\TaskRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=TaskRepository::class)
 */
class Task
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $title;

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

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): self
    {
        $this->title = $title;

        return $this;
    }
}
<?php

namespace App\Infrastructure\Repository;

use App\Domain\Entity\Task;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\ORMException;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @method Task|null find($id, $lockMode = null, $lockVersion = null)
 * @method Task|null findOneBy(array $criteria, array $orderBy = null)
 * @method Task[]    findAll()
 * @method Task[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class TaskRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Task::class);
    }

    /**
     * @throws ORMException
     * @throws OptimisticLockException
     */
    public function add(Task $entity, bool $flush = true): void
    {
        $this->_em->persist($entity);
        if ($flush) {
            $this->_em->flush();
        }
    }

    /**
     * @throws ORMException
     * @throws OptimisticLockException
     */
    public function remove(Task $entity, bool $flush = true): void
    {
        $this->_em->remove($entity);
        if ($flush) {
            $this->_em->flush();
        }
    }

    // /**
    //  * @return Task[] Returns an array of Task objects
    //  */
    /*
    public function findByExampleField($value)
    {
        return $this->createQueryBuilder('t')
            ->andWhere('t.exampleField = :val')
            ->setParameter('val', $value)
            ->orderBy('t.id', 'ASC')
            ->setMaxResults(10)
            ->getQuery()
            ->getResult()
        ;
    }
    */

    /*
    public function findOneBySomeField($value): ?Task
    {
        return $this->createQueryBuilder('t')
            ->andWhere('t.exampleField = :val')
            ->setParameter('val', $value)
            ->getQuery()
            ->getOneOrNullResult()
        ;
    }
    */
}

..sólo hace falta cambiar los namespaces y sus paths reales a los ficheros.

Refactorizando, independizando las capas internas de dominio y aplicación de infraestructura

Gracias a los comentarios de Franco, lector aquí en jnjsite.com, nos damos cuenta de que tenemos un error en la arquitectura implementada, porque el código fuente del caso de uso AddTaskUseCase (capa de aplicación), tiene funcionalidades dependientes de usar Doctrine. Es decir, le estamos pasando el entityManager de Doctrine desde a la capa de aplicación, con lo que la capa de aplicación queda dependiente de usar Doctrine, y esto no es correcto.

Vamos al grano.. el caso de uso refactorizado quedaría así:

<?php

namespace App\Application;

use App\Domain\Entity\Task;
use App\Infrastructure\Repository\TaskRepository;

class AddTaskUseCase
{
    public function execute(TaskRepository $taskRepository)
    {
        $newTask = new Task();
        $newTask->setTitle('A new random task '.rand());

        $taskRepository->add($newTask, true);

        // Otras posibles acciones relacionadas con añadir tareas..

        return 'Adding a random task..';
    }
}

Y el controlador así:

<?php

namespace App\Infrastructure\Http;

use App\Application\AddTaskUseCase;
use App\Infrastructure\Repository\TaskRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class DefaultController extends AbstractController
{
    /**
     * @Route("/default", name="app_default")
     */
    public function index(AddTaskUseCase $addTaskUseCase, TaskRepository $taskRepository): JsonResponse
    {
        return $this->json([
            'message' => 'Hi! Welcome to your new controller! '.$addTaskUseCase->execute($taskRepository),
            'path' => 'src/Infrastructure/Http/DefaultController.php',
        ]);
    }
}

Recapitulando y terminando

Si has llegado hasta aquí, para terminar, sólo me queda tratar de resumirte el objetivo de la Arquitectura Hexagonal. Se trata de aislar la Capa de Infraestructura, el cómo se realizan los inputs y outputs en el sistema, para facilitar todo el mantenimiento y construcción de las capas internas de Aplicación y Dominio. Esta estructura hará más mantenible el programa facilitando muchas cosas como el preparar tests.

Otro punto importante es que las capas internas no deben saber nada de cómo se implementan las capas externas. Por lo tanto, los casos de uso deben recibir todos los parámetros que van a necesitar. Es decir, los casos de uso recibirán la información de entrada que pueden recibir por ejemplo mediante HTTP, así como las interfaces de salida en donde deberán de hacer los outputs como por ejemplo las interfaces de persistencia en BDs, servicios de entrega de emails, etc..

Esta forma de pasarle los inputs y outputs a los casos de uso aislará las capas internas, haciendo que a los casos de uso no les importe si las entradas vienen por HTTP, email, ficheros en un FTP, etc.. o si las salidas se tienen que guardar en una base de datos Postgres/Maríadb, o los mensajes de email ahora cambian para ser entregados en AWS, MailChimp, Mailrelay, etc..

Espero haberme podido explicar bien.

8 respuestas a “Cómo implementar una Arquitectura Hexagonal con Symfony Flex”

  1. me ha gustado mucho espero lo puedas ampliar para ver como relacionar 2 componentes diferentes 2 BC llamados en ddd… me interesa mucho el metodo shared kernel

    • Jnj dice:

      Muchas gracias por dejar un comentario, me alegro de que te haya gustado.
      Apunto en backlog esos temas que indicas.
      Saludos Kungfoo.

  2. miguel dice:

    Mil gracias por este claro ejemplo de como iniciar una aplicación hexagonal con symfony, estuve en una empresa donde se trabajaba con hexagonal sobre Laravel pero en 3 meses no logre ver como se conectaban las capas entre si, si que vi mucho de la lógica de negocio pero el meollito del asunto se quedo fuera y lo considero básico para poder entenderlo todo… a partir de aquí ya tiraré adelante con mi proyecto aunque si sigues avanzando en el contenido estaré encantado de seguirte, muchas gracias y un saludo !!

    • Jnj dice:

      De nada! Muchas gracias por tu comentario Miguel!

      Voy a tratar de ampliar este tema, editando este post, quizá también con nuevos posts. El tema de la Arquitectura Hexagonal es muy necesario conocerlo a fecha de hoy, hay mucha bibliografía por Internet, aunque quizá a veces contradictoria. Pero si nos empapamos más con el tema son los mismos principios de la programación de siempre, aplicados y extendidos. Es decir, el objetivo es aislar la lógica propia del programa lo más adentro posible, de los puntos de entrada y salida del programa, así será todo más mantenible y de más calidad.

      Al principio en programas pequeños, es hacer a extremos demasiada sobre-arquitectura. Pero de forma natural los programas pueden ir evolucionando hacia arquitecturas parecidas a la Hexagonal, o directamente implementar una Hexagonal. Un resumen de cómo se conectan las capas entre sí podría ser el siguiente:

      * Todos los casos de uso, tienen que recibir toda la información de solicitud desde la capa de Infraestructura, y grabar los resultados usando de nuevo la capa de Infraestructura, por lo tanto hay que pasar tanto datos de petición como inputs, como todo lo necesario para los outputs (adaptadores/repositorios de BD, servicio de mail, chats, etc..).
      * Tenemos entonces un montón de casos de uso que son las acciones conceptuales en la capa intermedia llamada de Aplicación. Por ejemplo: registrar un usuario, obtener las últimas ventas, obtener inventario de productos disponibles, registrar una venta, etc..
      * Y finalmente todos los objetos conceptuales como: Venta, Usuario, CatalogoDeProductos, EspecificacionDeProducto, Producto, etc.. son propios de la capa interna de dominio.

      La única conexión posible que puede haber para ser correcta la Arquitectura Hexagonal es:

      * Capa de Infraestructura sólo ve y utiliza la de Aplicación.
      * Capa de Aplicación sólo ve y utiliza la de Dominio.
      * La capa de Dominio, ni ve ni utiliza las de Aplicación ni Infraestructura, sólo realiza acciones con objetos del dominio.

      Si no es así, es que algo mal estamos haciendo y tenemos que refactorizar. No se si me explico bien ^_-
      Saludos!

  3. Franco dice:

    Me gustó mucho la explicación sobre la arquitectura y como se implementaría en Symfony. De hecho, estuve siguiendo el ejemplo pero con Symfony 6 y funcionó tal cual lo describiste.
    Sin embargo, como aún estoy aprendiendo sobre esta arquitectura me queda la duda: ¿Cómo harías para cambiar Symfony por otro framework?

    • Jnj dice:

      Buenos días Franco.

      Muchas gracias por tu comentario. La pregunta es muy interesante.

      Para cambiar a otro framework, la teoría indica que sólo habría que tocar la capa de infraestructura. Por ejemplo en este caso:
      * HTTP: El enrutador de Symfony habría que adaptarlo al nuevo, si es que lo hay. Si no, habría que construir un nuevo sistema de enrutamiento.
      * BD: El acceso a BD, en Symfony se usa el ORM Doctrine, habría que cambiarlo por el que use el otro framework. Si no lo hay construir uno sería mucho trabajo.
      * Emails, chats, etc: Así sucesivamente con cada punto de entrada y salida al programa.

      Recapitulando, en Hexagonal, lo que se persigue es hacer que las capas de casos de uso (aplicación) y dominio sean totalmente independientes del exterior. Esto implica que no habría que tocar dominio, ni aplicación, si queremos cambiar el framework. También esto facilita la modificación del framework, su actualización, añadirle más herramientas, etc.. Todo esto es la teoría, habría que estudiar cada programa, porque si se nos han colado cosas de infraestructura en aplicación y dominio, tendríamos que refactorizar antes de cambiar de framework.

      Saludos.

      • Franco Ferrari dice:

        En ese caso que planteas, creo que debió usarse algún tipo de interfaz o «capa intermedia», por ejemplo, cuando en ‘DefaultController’ llamas a ‘Doctrine\ORM\EntityManagerInterface’. En esa capa dejaste «amarrado» el código a Doctrine, siendo que a futuro quizá deba cambiarlo por Eloquent (imaginando que de SF pasamos a Laravel).

        ¿Estoy en lo correcto o me estoy mal entendiendo algo?

        • Jnj dice:

          Buenas tardes Franco.

          Pues tienes razón, podemos entonces refactorizar el código fuente para que todo el acceso a BD esté dentro del repositorio. Muy bien visto! Muchas gracias por comentarlo!

          Edito el post en breve para hacer la refactorización con la mejora que indicas..

          Saludos.

Deja una respuesta

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

 

© 2024 JnjSite.com - MIT license

Sitio hecho con WordPress, diseño y programación del tema por Jnj.