Hexagonal Architecture (Ports and Adapters)
Understand Hexagonal Architecture's domain core, ports, and inbound/outbound adapters, and how they let the same business logic be driven by HTTP, a CLI, or a test, while swapping databases or external services freely.
Hexagonal Architecture (also called Ports and Adapters, coined by Alistair Cockburn) puts the domain core at the center of the system and surrounds it with ports — interfaces the core defines — and adapters — concrete implementations that plug into those ports from the outside. The hexagon shape itself is not literal (it is not six-sided for any technical reason); it is simply drawn with enough sides to show multiple adapters plugging into the same core simultaneously, which is exactly the point: an HTTP adapter and a CLI adapter can drive the same core through the same port, side by side.
The domain core and ports
The domain core holds all business logic and knows nothing about HTTP, databases, or any specific technology — it is pure application/business rules. A port is an interface the core defines to describe a capability it needs (an outbound port, like SaveOrderPort) or a capability it offers to the outside world (an inbound port, like PlaceOrderPort). Ports belong to the core; they are the core's own vocabulary for talking to the outside.
Because the core only depends on ports (interfaces) it owns, it can be unit tested with fake implementations and has zero knowledge of which specific database, message queue, or web framework is eventually plugged in.
Inbound adapters (driving side)
Inbound adapters sit on the 'driving' side: they take input from the outside world (an HTTP request, a CLI command, a scheduled job, a message from a queue) and translate it into a call on an inbound port the core exposes. An HTTP controller is a classic inbound adapter — it parses the request and calls $placeOrderPort->handle($command), but the controller itself contains no business logic.
Multiple inbound adapters can drive the exact same core logic: a REST controller, a GraphQL resolver, and a CLI command can all call the same PlaceOrderPort, guaranteeing that the business rule behaves identically no matter how the request arrived.
Outbound adapters (driven side)
Outbound adapters sit on the 'driven' side: the core calls out through an outbound port it defines (SaveOrderPort, SendEmailPort), and an adapter implements that port with real technology — a MySQL repository, an SMTP mailer, a Stripe client. The core calls the port interface only; it never imports the concrete adapter class.
This is what makes swapping technology cheap: replacing a MySQL-backed adapter with a PostgreSQL-backed one, or a real payment gateway with a sandbox/mock one for testing, means writing a new class that implements the same port — the domain core does not change at all.
• Domain core — business logic, defines ports, zero framework/technology knowledge • Port — an interface owned by the core: inbound (offered to the world) or outbound (needed from the world) • Inbound adapter — HTTP controller, CLI command, queue consumer; drives the core through an inbound port • Outbound adapter — database repository, email client, payment gateway; implements an outbound port the core calls
Ports as interfaces owned by the core
Both ports are interfaces declared alongside the core, not inside any adapter. OrderService implements the inbound port and depends on the outbound port — never on a concrete adapter class. Any adapter (HTTP, CLI) can call handle(); any adapter (MySQL, in-memory) can implement SaveOrderPort.
// Inbound port — the capability the core offers to the outside world
interface PlaceOrderPort
{
public function handle(PlaceOrderCommand $command): int; // returns new order id
}
// Outbound port — the capability the core needs from the outside world
interface SaveOrderPort
{
public function save(Order $order): int;
}
// Core implements the inbound port, depends on the outbound port
final class OrderService implements PlaceOrderPort
{
public function __construct(private SaveOrderPort $orders) {}
public function handle(PlaceOrderCommand $command): int
{
$order = new Order($command->productId, $command->quantity);
return $this->orders->save($order);
}
}Code Examples
OrderService is the domain core: it implements the PlaceOrderPort (inbound) it offers and depends on SaveOrderPort (outbound) for persistence. PlaceOrderHttpAdapter is an inbound adapter translating an HTTP-shaped array into a core call; PdoOrderRepository is an outbound adapter implementing the persistence port — a CLI adapter or an in-memory test repository could be swapped in without touching OrderService. Expected result: Array ( [order_id] => 1 [status] => placed) final class OrderService implements PlaceOrderPort — the core only ever exposes and consumes ports, never adapter classes directly.
<?php
// --- Domain core: business rules, defines its own ports ---
final class Order
{
public function __construct(
public readonly string $productId,
public readonly int $quantity
) {
if ($quantity < 1) {
throw new \InvalidArgumentException('Quantity must be at least 1.');
}
}
}
// Inbound port: what the core offers to the outside world
interface PlaceOrderPort
{
public function handle(string $productId, int $quantity): int;
}
// Outbound port: what the core needs from the outside world
interface SaveOrderPort
{
public function save(Order $order): int;
}
// Core service implements the inbound port, depends only on the outbound port
final class OrderService implements PlaceOrderPort
{
public function __construct(private SaveOrderPort $orders) {}
public function handle(string $productId, int $quantity): int
{
$order = new Order($productId, $quantity);
return $this->orders->save($order);
}
}
// --- Inbound adapter (driving side): HTTP controller calling into the core ---
final class PlaceOrderHttpAdapter
{
public function __construct(private PlaceOrderPort $placeOrder) {}
p
…Hexagonal architecture (ports and adapters)
Every arrow crosses through a port (an interface the core owns). Inbound adapters call in through an inbound port; the core calls out through an outbound port that outbound adapters implement.
Inbound adapters Outbound adapters
(driving side) (driven side)
┌────────────┐ ╱▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔╲ ┌────────────┐
│ HTTP │ ╱ ╲ │ MySQL │
│ Controller│──────▶│ Domain Core │──────▶│ Repository│
└────────────┘ │ (business rules) │ └────────────┘
┌────────────┐ │ │ ┌────────────┐
│ CLI │──────▶│ Ports = interfaces │──────▶│ SMTP │
│ Command │ in │ owned by the core │ out │ Mailer │
└────────────┘ port ╲ ╱ port └────────────┘
╲____________________╱Important Rules
The domain core defines all ports (interfaces); adapters implement or call them, never the reverse.
Inbound adapters (HTTP, CLI, queue consumers) translate outside input into a call on an inbound port — they contain no business logic.
Outbound adapters (database repositories, mail clients, payment gateways) implement outbound ports the core depends on.
The core never imports a concrete adapter class (PdoOrderRepository, Symfony\Request) — only the port interfaces it owns.
Ports and adapters for testability and multiple front ends
Hexagonal architecture is popular in PHP systems that need to be driven from more than one place — a web API today, a CLI import job tomorrow, an admin GraphQL layer later — because all three can be inbound adapters calling the exact same core through the same inbound port, guaranteeing identical behavior. It also makes integration testing cheap: swap a real PdoOrderRepository outbound adapter for an InMemoryOrderRepository implementing the same SaveOrderPort, and the entire core can be tested with no real database running.
final class OrderService { public function handle(array $httpRequest) { $pdo = new PDO(...); $pdo->exec("INSERT INTO orders..."); } } final class OrderService implements PlaceOrderPort { public function __construct(private SaveOrderPort $orders) {} public function handle(string $productId, int $quantity): int { /*...; return $this->orders->save($order); */ } }
The wrong version has the domain core reading a raw HTTP array and opening its own database connection directly — mixing an inbound adapter's job (parsing HTTP) and an outbound adapter's job (talking to MySQL) into the core, so the core can no longer be driven by a CLI or tested without a real database.
In Hexagonal Architecture, what is a 'port'? A. A network port number the application listens on B. An interface owned by the domain core describing a capability it offers or needs C. A concrete class that talks to a database D. A folder where controllers are stored
B. An interface owned by the domain core describing a capability it offers or needs — A port is an interface the domain core defines — either an inbound port (a capability offered to the outside) or an outbound port (a capability the core needs) — and adapters connect to it.
Which of these is an example of an outbound adapter? A. An HTTP controller that receives a request and calls an inbound port B. A CLI command that parses arguments and calls the domain core C. A repository class that implements a persistence port using PDO to talk to MySQL D. The domain core's Order entity
C. A repository class that implements a persistence port using PDO to talk to MySQL — An outbound adapter implements an outbound port the core depends on, providing the real technology (like a PDO-backed repository) the core's port interface abstracts away.
You need to add a CLI command (php artisan orders:place SKU-42 3) that places an order using the exact same business rules as the existing HTTP endpoint. Using the hexagonal example above, describe what new code you would write and what you would reuse unchanged.
Write a new inbound adapter, e.g. PlaceOrderCliAdapter, that parses argv into ($productId, $quantity) and calls $this->placeOrder->handle($productId, $quantity) — the same PlaceOrderPort. OrderService, Order, SaveOrderPort, and PdoOrderRepository are reused completely unchanged; only a new inbound adapter is added.
Which class already implements the inbound port, and does it care who calls it?