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 /, 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:

class name
    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 /:

<?php

namespace App\Infrastructure\Http;

use App\Application\AddTaskUseCase;
use App\Domain\Entity\Task;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class DefaultController extends AbstractController
{
    /**
     * @Route("/", name="app_default")
     */
    public function index(AddTaskUseCase $addTaskUseCase): Response
    {
        $newTask = new Task();
        $newTask->setTitle('A new random task '.rand());
        $addTaskUseCase->execute($newTask);

        return $this->json([
            'message' => 'Hi! Welcome to your new controller! Adding a random task..',
            'path' => 'src/Infrastructure/Http/DefaultController.php',
        ]);
    }
}

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

<?php

namespace App\Infrastructure\Http;

use App\Domain\Entity\Task;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class ApiController extends AbstractController
{
    /**
     * @Route("/api", name="app_api")
     */
    public function index(EntityManagerInterface $entityManager): Response
    {
        $titles = [];
        $tasks = $entityManager->getRepository(Task::class)->findAll();
        foreach ($tasks  as $task) {
            $titles[] = $task->getTitle();
        }

        return $this->json([
            'allThetasks' => $titles,
        ]);
    }
}

Ahora el único caso de uso implementado:

<?php

namespace App\Application;

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

class AddTaskUseCase
{
    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function execute(Task $task)
    {
        $this->entityManager->persist($task);
        $this->entityManager->flush();

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

..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.

Y 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.

2 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.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.

 

© 2022 JnjSite.com - MIT license

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