Architecture

Arquitectura Hexagonal en PHP: Implementación práctica con Symfony

Aprende a implementar arquitectura hexagonal en proyectos PHP reales usando Symfony, Domain-Driven Design y principios SOLID para crear aplicaciones escalables y mantenibles.

FC

Fernando Caravaca

FullStack Developer

15 de diciembre de 2024
12 min de lectura
Diagrama de arquitectura hexagonal

Arquitectura Hexagonal en PHP: Implementación práctica con Symfony

Arquitectura Hexagonal Diagram Diagrama conceptual de la arquitectura hexagonal mostrando el núcleo del dominio rodeado por puertos y adaptadores

Introducción

La arquitectura hexagonal, también conocida como "Ports and Adapters" o "Clean Architecture", es un patrón arquitectónico que nos permite crear aplicaciones desacopladas, testeables y mantenibles. En este artículo, exploraremos cómo implementar este patrón en proyectos PHP usando Symfony.

¿Por qué importa? En proyectos reales, los requisitos cambian constantemente: necesitamos cambiar de base de datos, integrar nuevas APIs, o migrar frameworks. La arquitectura hexagonal nos protege de estos cambios manteniendo nuestro código de negocio independiente de los detalles de infraestructura.

¿Qué es la Arquitectura Hexagonal?

La arquitectura hexagonal propuesta por Alistair Cockburn en 2005 divide nuestra aplicación en tres capas principales:

1. Dominio (Core/Núcleo)

El corazón de nuestra aplicación. Contiene:

  • Entidades: Objetos de negocio con identidad
  • Value Objects: Objetos inmutables sin identidad
  • Domain Services: Lógica de negocio compleja
  • Domain Events: Eventos que ocurren en el dominio

Regla de oro: El dominio NO debe depender de nada externo (frameworks, bases de datos, HTTP).

2. Puertos (Interfaces)

Son los contratos que definen cómo interactuar con el dominio:

  • Puertos de Entrada (Driving Ports): Casos de uso que la aplicación expone (interfaces de servicios de aplicación)
  • Puertos de Salida (Driven Ports): Interfaces que el dominio necesita (repositorios, servicios externos)

3. Adaptadores

Son las implementaciones concretas de los puertos:

  • Adaptadores de Entrada: Controllers HTTP, CLI commands, eventos de mensajería
  • Adaptadores de Salida: Repositorios Doctrine, clientes HTTP, servicios de email

Arquitectura en capas Flujo de datos a través de las capas de la arquitectura hexagonal

Implementación práctica: Sistema de Gestión de Pedidos

Vamos a construir un sistema de pedidos paso a paso aplicando arquitectura hexagonal con Symfony.

Estructura de directorios

src/
├── Domain/
│   ├── Model/
│   │   ├── Order.php
│   │   ├── OrderId.php
│   │   ├── Money.php
│   │   └── OrderStatus.php
│   ├── Repository/
│   │   └── OrderRepositoryInterface.php
│   ├── Service/
│   │   └── OrderPriceCalculator.php
│   └── Event/
│       └── OrderWasPlaced.php
├── Application/
│   ├── UseCase/
│   │   ├── PlaceOrder/
│   │   │   ├── PlaceOrderCommand.php
│   │   │   └── PlaceOrderHandler.php
│   │   └── FindOrder/
│   │       ├── FindOrderQuery.php
│   │       └── FindOrderHandler.php
│   └── Service/
│       └── OrderService.php
└── Infrastructure/
    ├── Persistence/
    │   └── Doctrine/
    │       └── DoctrineOrderRepository.php
    ├── Http/
    │   └── Controller/
    │       └── OrderController.php
    └── Messaging/
        └── RabbitMQEventPublisher.php

Paso 1: Definir las Entidades del Dominio

<?php
// src/Domain/Model/Order.php

namespace App\Domain\Model;

use App\Domain\Event\OrderWasPlaced;
use DateTimeImmutable;

final class Order
{
    private OrderId $id;
    private array $items = [];
    private OrderStatus $status;
    private Money $totalAmount;
    private DateTimeImmutable $createdAt;
    private array $domainEvents = [];

    private function __construct(
        OrderId $id,
        DateTimeImmutable $createdAt
    ) {
        $this->id = $id;
        $this->status = OrderStatus::pending();
        $this->createdAt = $createdAt;
        $this->totalAmount = Money::zero();
    }

    public static function place(
        OrderId $id,
        array $items,
        OrderPriceCalculator $calculator
    ): self {
        $order = new self($id, new DateTimeImmutable());

        foreach ($items as $item) {
            $order->addItem($item);
        }

        $order->totalAmount = $calculator->calculate($order);
        $order->recordThat(new OrderWasPlaced($id));

        return $order;
    }

    public function confirm(): void
    {
        if (!$this->status->isPending()) {
            throw new \DomainException('Only pending orders can be confirmed');
        }

        $this->status = OrderStatus::confirmed();
    }

    private function addItem(OrderItem $item): void
    {
        $this->items[] = $item;
    }

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

    public function totalAmount(): Money
    {
        return $this->totalAmount;
    }

    public function popEvents(): array
    {
        $events = $this->domainEvents;
        $this->domainEvents = [];
        return $events;
    }

    private function recordThat(object $event): void
    {
        $this->domainEvents[] = $event;
    }
}

Paso 2: Value Objects

<?php
// src/Domain/Model/OrderId.php

namespace App\Domain\Model;

use Ramsey\Uuid\Uuid;

final class OrderId
{
    private string $value;

    private function __construct(string $value)
    {
        if (!Uuid::isValid($value)) {
            throw new \InvalidArgumentException('Invalid OrderId format');
        }
        $this->value = $value;
    }

    public static function generate(): self
    {
        return new self(Uuid::uuid4()->toString());
    }

    public static function fromString(string $value): self
    {
        return new self($value);
    }

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

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    public function __toString(): string
    {
        return $this->value;
    }
}
<?php
// src/Domain/Model/Money.php

namespace App\Domain\Model;

final class Money
{
    private int $amount; // Stored in cents
    private string $currency;

    private function __construct(int $amount, string $currency)
    {
        if ($amount < 0) {
            throw new \InvalidArgumentException('Amount cannot be negative');
        }
        $this->amount = $amount;
        $this->currency = strtoupper($currency);
    }

    public static function fromFloat(float $amount, string $currency): self
    {
        return new self((int) ($amount * 100), $currency);
    }

    public static function zero(): self
    {
        return new self(0, 'EUR');
    }

    public function add(self $other): self
    {
        $this->assertSameCurrency($other);
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function toFloat(): float
    {
        return $this->amount / 100;
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new \InvalidArgumentException('Cannot operate with different currencies');
        }
    }
}

Paso 3: Puerto de Salida (Repositorio)

<?php
// src/Domain/Repository/OrderRepositoryInterface.php

namespace App\Domain\Repository;

use App\Domain\Model\Order;
use App\Domain\Model\OrderId;

interface OrderRepositoryInterface
{
    public function save(Order $order): void;

    public function findById(OrderId $id): ?Order;

    public function nextIdentity(): OrderId;
}

Paso 4: Caso de Uso (Puerto de Entrada)

<?php
// src/Application/UseCase/PlaceOrder/PlaceOrderCommand.php

namespace App\Application\UseCase\PlaceOrder;

final class PlaceOrderCommand
{
    public function __construct(
        public readonly array $items,
        public readonly ?string $customerId = null
    ) {}
}
<?php
// src/Application/UseCase/PlaceOrder/PlaceOrderHandler.php

namespace App\Application\UseCase\PlaceOrder;

use App\Domain\Model\Order;
use App\Domain\Repository\OrderRepositoryInterface;
use App\Domain\Service\OrderPriceCalculator;

final class PlaceOrderHandler
{
    public function __construct(
        private OrderRepositoryInterface $orderRepository,
        private OrderPriceCalculator $priceCalculator
    ) {}

    public function handle(PlaceOrderCommand $command): string
    {
        $orderId = $this->orderRepository->nextIdentity();

        $order = Order::place(
            $orderId,
            $this->mapItems($command->items),
            $this->priceCalculator
        );

        $this->orderRepository->save($order);

        // Publish domain events
        foreach ($order->popEvents() as $event) {
            // Event dispatcher here
        }

        return $orderId->value();
    }

    private function mapItems(array $items): array
    {
        // Map raw data to OrderItem value objects
        return array_map(fn($item) => OrderItem::fromArray($item), $items);
    }
}

Paso 5: Adaptador de Persistencia (Doctrine)

<?php
// src/Infrastructure/Persistence/Doctrine/DoctrineOrderRepository.php

namespace App\Infrastructure\Persistence\Doctrine;

use App\Domain\Model\Order;
use App\Domain\Model\OrderId;
use App\Domain\Repository\OrderRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;

final class DoctrineOrderRepository implements OrderRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager
    ) {}

    public function save(Order $order): void
    {
        $this->entityManager->persist($order);
        $this->entityManager->flush();
    }

    public function findById(OrderId $id): ?Order
    {
        return $this->entityManager
            ->getRepository(Order::class)
            ->find($id->value());
    }

    public function nextIdentity(): OrderId
    {
        return OrderId::generate();
    }
}

Paso 6: Adaptador de Entrada (HTTP Controller)

<?php
// src/Infrastructure/Http/Controller/OrderController.php

namespace App\Infrastructure\Http\Controller;

use App\Application\UseCase\PlaceOrder\PlaceOrderCommand;
use App\Application\UseCase\PlaceOrder\PlaceOrderHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

final class OrderController
{
    public function __construct(
        private PlaceOrderHandler $placeOrderHandler
    ) {}

    #[Route('/api/orders', methods: ['POST'])]
    public function placeOrder(Request $request): JsonResponse
    {
        $data = json_decode($request->getContent(), true);

        $command = new PlaceOrderCommand(
            items: $data['items'] ?? [],
            customerId: $data['customer_id'] ?? null
        );

        $orderId = $this->placeOrderHandler->handle($command);

        return new JsonResponse([
            'order_id' => $orderId,
            'status' => 'created'
        ], 201);
    }
}

Configuración de Servicios en Symfony

# config/services.yaml

services:
    _defaults:
        autowire: true
        autoconfigure: true

    # Domain Services
    App\Domain\Service\:
        resource: '../src/Domain/Service/*'

    # Application Use Cases
    App\Application\UseCase\:
        resource: '../src/Application/UseCase/*'

    # Infrastructure
    App\Infrastructure\:
        resource: '../src/Infrastructure/*'
        exclude: '../src/Infrastructure/{DependencyInjection,Entity,Tests}'

    # Repository binding
    App\Domain\Repository\OrderRepositoryInterface:
        class: App\Infrastructure\Persistence\Doctrine\DoctrineOrderRepository

Testing Architecture Pirámide de testing en arquitectura hexagonal: tests unitarios del dominio, tests de integración de casos de uso, y tests end-to-end

Ventajas de la Arquitectura Hexagonal

1. Testabilidad

Podemos testear el dominio sin necesidad de base de datos o framework:

<?php
// tests/Unit/Domain/OrderTest.php

use App\Domain\Model\Order;
use App\Domain\Model\OrderId;
use PHPUnit\Framework\TestCase;

final class OrderTest extends TestCase
{
    public function test_order_can_be_placed(): void
    {
        $orderId = OrderId::generate();
        $items = [
            OrderItem::create('product-1', 2, Money::fromFloat(10.00, 'EUR'))
        ];

        $order = Order::place($orderId, $items, new OrderPriceCalculator());

        $this->assertEquals($orderId, $order->id());
        $this->assertEquals(20.00, $order->totalAmount()->toFloat());
    }
}

2. Independencia del Framework

El dominio no conoce Symfony. Podemos migrarnos a Laravel mañana cambiando solo adaptadores.

3. Facilidad de Cambio

¿Necesitas cambiar de MySQL a MongoDB? Solo cambia el adaptador de persistencia:

<?php
final class MongoDBOrderRepository implements OrderRepositoryInterface
{
    // Misma interfaz, implementación diferente
}

4. Reglas de Negocio Centralizadas

Toda la lógica de negocio está en el dominio, fácil de encontrar y mantener.

Patrones Complementarios

CQRS (Command Query Responsibility Segregation)

Separar operaciones de lectura y escritura:

<?php
// Write Model (Command)
interface OrderCommandRepository {
    public function save(Order $order): void;
}

// Read Model (Query)
interface OrderQueryRepository {
    public function findById(string $id): ?OrderDTO;
    public function findAll(): array;
}

Event Sourcing

En lugar de guardar el estado, guardamos eventos:

<?php
final class Order extends AggregateRoot
{
    protected function apply(OrderWasPlaced $event): void
    {
        $this->id = $event->orderId;
        $this->status = OrderStatus::pending();
    }

    protected function apply(OrderWasConfirmed $event): void
    {
        $this->status = OrderStatus::confirmed();
    }
}

Errores Comunes

❌ No respetar las dependencias

<?php
// ❌ MAL: El dominio NO debe depender de infraestructura
namespace App\Domain;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Order { }
<?php
// ✅ BIEN: El dominio es independiente
namespace App\Domain;

class Order {
    // Sin anotaciones de Doctrine
}

❌ Lógica de negocio en controladores

<?php
// ❌ MAL
class OrderController {
    public function create() {
        $total = 0;
        foreach ($items as $item) {
            $total += $item['price'] * $item['quantity'];
        }
        // Lógica de negocio en el controlador
    }
}
<?php
// ✅ BIEN
class OrderController {
    public function create() {
        $this->placeOrderHandler->handle($command);
        // El controlador solo coordina
    }
}

Conclusión

La arquitectura hexagonal nos permite construir aplicaciones profesionales y mantenibles en PHP. Con Symfony como framework, obtenemos lo mejor de ambos mundos: la potencia del framework y la claridad arquitectónica del patrón hexagonal.

Recuerda las tres reglas de oro:

  1. El dominio no depende de nada externo
  2. La comunicación con el dominio se hace a través de interfaces (puertos)
  3. Los detalles técnicos (adaptadores) implementan esas interfaces

Recursos adicionales


¿Tienes dudas sobre arquitectura hexagonal? ¿Quieres compartir tu experiencia? Déjame un comentario o contáctame en LinkedIn.

#PHP#Symfony#Hexagonal Architecture#DDD#SOLID

¿Te gustó este artículo?

Comparte tus pensamientos en LinkedIn o contáctame si quieres discutir estos temas.

Fernando Caravaca - FullStack Developer