Arquitectura Hexagonal en PHP: Implementación práctica con Symfony
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
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
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:
- El dominio no depende de nada externo
- La comunicación con el dominio se hace a través de interfaces (puertos)
- Los detalles técnicos (adaptadores) implementan esas interfaces
Recursos adicionales
- Libro: "Hexagonal Architecture Explained" by Juan Manuel Garrido de Paz
- Documentación oficial de Symfony
- DDD en PHP - Carlos Buenosvinos
- Repositorio de ejemplo en GitHub
¿Tienes dudas sobre arquitectura hexagonal? ¿Quieres compartir tu experiencia? Déjame un comentario o contáctame en LinkedIn.