0

Hexagonal Architecture (Ports & Adapters)

Advanced5 min read·eng-05-006
interviewcompare

Concept

Hexagonal Architecture (also called Ports and Adapters) organizes an application so that the core business logic is completely isolated from infrastructure concerns (databases, HTTP, queues, etc.). The idea: your application can be driven from a web request, a CLI command, or a test — and can switch from MySQL to PostgreSQL — without changing the core.

The metaphor: A hexagon (the application core) has ports (interfaces) on its edges. Adapters plug into ports to connect the core to the outside world.

Three concentric layers:

  1. Domain (innermost): Pure business logic. No Laravel, no database, no HTTP. Entities, value objects, domain services. Framework-free PHP.
  2. Application (middle): Orchestrates use cases. Uses domain objects. Depends on port interfaces, not adapters.
  3. Infrastructure (outermost): Adapters that implement ports. Database repositories, HTTP controllers, queue adapters, email senders.

Ports:

  • Driving ports (primary / left side): How the outside world drives the application. E.g., CreateOrderUseCase interface. The controller calls it.
  • Driven ports (secondary / right side): How the application drives the outside world. E.g., OrderRepositoryInterface. The application core uses it; a MySQL adapter implements it.

Dependency rule: Dependencies point inward. Domain depends on nothing. Application depends only on Domain. Infrastructure depends on Application and Domain.

Why it matters for testing: Because the application core depends on interfaces (ports), you can inject in-memory adapters in tests. No database, no HTTP, no slow I/O.

Code Example

php
<?php
// ============================================================
// DOMAIN LAYER — pure PHP, no framework
// ============================================================
final class OrderItem
{
    public function __construct(
        public readonly string $productId,
        public readonly int    $quantity,
        public readonly float  $unitPrice,
    ) {}

    public function total(): float { return $this->quantity * $this->unitPrice; }
}

final class Order
{
    private array $items = [];

    public function __construct(public readonly string $id, public readonly string $customerId) {}

    public function addItem(OrderItem $item): void { $this->items[] = $item; }
    public function total(): float { return array_sum(array_map(fn($i) => $i->total(), $this->items)); }
    public function getItems(): array { return $this->items; }
}

// ============================================================
// APPLICATION LAYER — ports (interfaces)
// ============================================================
// Driven port — the app core depends on this interface, not the DB
interface OrderRepositoryInterface
{
    public function save(Order $order): void;
    public function findById(string $id): ?Order;
}

// Driving port — use case
interface PlaceOrderUseCaseInterface
{
    public function execute(PlaceOrderRequest $request): string; // returns order ID
}

final readonly class PlaceOrderRequest
{
    public function __construct(
        public readonly string $customerId,
        public readonly array  $items, // [{productId, quantity, unitPrice}]
    ) {}
}

// Application service — depends on domain + driven ports
final class PlaceOrderUseCase implements PlaceOrderUseCaseInterface
{
    public function __construct(private readonly OrderRepositoryInterface $orders) {}

    public function execute(PlaceOrderRequest $request): string
    {
        $order = new Order(uniqid('order_'), $request->customerId);
        foreach ($request->items as $item) {
            $order->addItem(new OrderItem($item['productId'], $item['quantity'], $item['unitPrice']));
        }
        $this->orders->save($order);
        return $order->id;
    }
}

// ============================================================
// INFRASTRUCTURE LAYER — adapters (implement the ports)
// ============================================================
// Driven adapter: Eloquent implementation of OrderRepository
class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function save(Order $order): void
    {
        \App\Models\Order::updateOrCreate(['id' => $order->id], [
            'customer_id' => $order->customerId,
            'total'       => $order->total(),
        ]);
    }

    public function findById(string $id): ?Order { /* hydrate from Eloquent */ return null; }
}

// In-memory adapter for testing
class InMemoryOrderRepository implements OrderRepositoryInterface
{
    private array $orders = [];

    public function save(Order $order): void          { $this->orders[$order->id] = $order; }
    public function findById(string $id): ?Order      { return $this->orders[$id] ?? null; }
    public function count(): int                      { return count($this->orders); }
}

// Driving adapter: HTTP controller (primary/left adapter)
class OrderController
{
    public function __construct(private readonly PlaceOrderUseCaseInterface $useCase) {}

    public function store(\Illuminate\Http\Request $request): \Illuminate\Http\JsonResponse
    {
        $orderId = $this->useCase->execute(new PlaceOrderRequest(
            $request->input('customer_id'),
            $request->input('items', []),
        ));
        return response()->json(['order_id' => $orderId], 201);
    }
}

// ============================================================
// TESTING — swap adapter without changing anything else
// ============================================================
$repo  = new InMemoryOrderRepository();
$useCase = new PlaceOrderUseCase($repo);
$orderId = $useCase->execute(new PlaceOrderRequest('cust-1', [
    ['productId' => 'p-1', 'quantity' => 2, 'unitPrice' => 25.0],
]));
assert($repo->count() === 1); // No database needed!