Hexagonal Architecture Explained Simply
Hexagonal architecture sounds complex, but the idea is simple: keep your business rules in the center and push databases, frameworks and APIs to the edges. Here is how I explain it, with real examples.
The first time I heard the words "hexagonal architecture", I imagined something very complicated. A hexagon has six sides, so I thought there were six layers, or six rules, or six special folders. I was wrong. The hexagon is only a picture. The real idea is much simpler.
In this post I want to explain hexagonal architecture in simple words. I use it every day in my work with PHP and Symfony, but the ideas work in any language: Python, Java, Go or TypeScript.
The problem: everything is mixed
Let's start with a common situation. You have a controller. Inside the controller you read the HTTP request, you validate data, you run a SQL query, you calculate a price, you send an email and you return JSON.
It works. The first version is fast to write. But after six months, problems start:
- You want to change the database, and you need to change 40 files.
- You want to write a unit test for the price calculation, but the test needs a database and an email server.
- A new developer joins the team and doesn't know where the business rules live.
- You want to run the same logic from a console command or a queue consumer, and you copy and paste code.
The problem is not the code quality of each line. The problem is that business logic and technical details are mixed together.
The main idea: inside and outside
Hexagonal architecture has one main idea:
Your application has an inside and an outside. The inside contains the business rules. The outside contains the technical details. The inside never depends on the outside.
What is "inside"?
- Your domain entities (for example
Wallet,Order,Invoice). - Your business rules ("a wallet can't have a negative balance").
- Your use cases ("transfer money between two wallets").
What is "outside"?
- The HTTP framework.
- The database (MySQL, PostgreSQL, MongoDB).
- Message queues (RabbitMQ, Azure Service Bus, SQS).
- External APIs (a payment provider, an LLM provider).
- The console, the cron jobs, the UI.
The author of this pattern, Alistair Cockburn, called it "Ports and Adapters". I think this name is better than "hexagonal", because it explains how the inside and the outside talk to each other.
Ports: the doors of your application
A port is an interface that belongs to the inside. It says what the application needs or offers, but not how it is done.
There are two types of ports.
Driving ports (input). They describe what the application can do. Usually they are your use cases or command handlers. Someone from outside "drives" the application through them.
Driven ports (output). They describe what the application needs from the outside world. For example: "I need to save a wallet" or "I need to send a notification".
Here is a driven port in PHP:
namespace App\Wallet\Domain;
interface WalletRepository
{
public function find(WalletId $id): ?Wallet;
public function save(Wallet $wallet): void;
}
Look at this interface carefully. There is no SQL. There is no Doctrine. There is no Symfony. It only uses domain objects. This is very important: the port speaks the language of the business, not the language of the database.
Adapters: the technical implementation
An adapter lives outside. It implements a port using a specific technology.
namespace App\Wallet\Infrastructure\Persistence;
use App\Wallet\Domain\Wallet;
use App\Wallet\Domain\WalletId;
use App\Wallet\Domain\WalletRepository;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineWalletRepository implements WalletRepository
{
public function __construct(private EntityManagerInterface $em) {}
public function find(WalletId $id): ?Wallet
{
return $this->em->find(Wallet::class, $id->value());
}
public function save(Wallet $wallet): void
{
$this->em->persist($wallet);
$this->em->flush();
}
}
You can have many adapters for the same port:
DoctrineWalletRepositoryfor production.InMemoryWalletRepositoryfor unit tests.- Maybe a
RedisWalletRepositoryone day.
The inside doesn't know and doesn't care which one you use.
The same is true for driving adapters. An HTTP controller is an adapter. A console command is an adapter. A queue consumer is an adapter. All of them call the same use case.
A complete example: transfer money
Let's see a small use case. This is the inside of the application.
namespace App\Wallet\Application;
final class TransferMoney
{
public function __construct(
private WalletRepository $wallets,
private TransferNotifier $notifier,
) {}
public function __invoke(TransferMoneyCommand $command): void
{
$from = $this->wallets->find(new WalletId($command->fromId))
?? throw new WalletNotFound($command->fromId);
$to = $this->wallets->find(new WalletId($command->toId))
?? throw new WalletNotFound($command->toId);
$amount = Money::fromCents($command->amountInCents);
$from->withdraw($amount);
$to->deposit($amount);
$this->wallets->save($from);
$this->wallets->save($to);
$this->notifier->transferDone($from, $to, $amount);
}
}
This class has no idea about HTTP, JSON, SQL or email. It only uses ports (WalletRepository, TransferNotifier) and domain objects (Wallet, Money).
Now the controller, which is an adapter:
final class TransferController
{
public function __construct(private TransferMoney $transfer) {}
public function __invoke(Request $request): JsonResponse
{
$data = $request->toArray();
($this->transfer)(new TransferMoneyCommand(
fromId: $data['from'],
toId: $data['to'],
amountInCents: (int) $data['amount'],
));
return new JsonResponse(null, 204);
}
}
The controller is thin. It translates HTTP into a command, and that's all. If tomorrow we need the same operation from a CLI command, we write a new small adapter and reuse TransferMoney. No copy and paste.
The dependency rule
There is one rule you must remember:
Dependencies always point to the inside.
- Infrastructure can depend on Application and Domain.
- Application can depend on Domain.
- Domain depends on nothing (only the language itself).
If you see use Doctrine\... or use Symfony\Component\HttpFoundation\... inside your Domain folder, something is wrong.
A simple folder structure helps a lot:
src/
Wallet/
Domain/ <- entities, value objects, ports
Application/ <- use cases, commands
Infrastructure/ <- controllers, Doctrine, HTTP clients
You can also check the rule automatically with tools like Deptrac (PHP) or import-linter (Python). I recommend this. Rules that are only in people's heads are forgotten. Rules that run in CI are respected.
Why this is so useful for testing
This is my favourite benefit. Because the use case only depends on interfaces, I can test it with fake adapters. The test is fast and doesn't need Docker.
public function test_it_transfers_money(): void
{
$wallets = new InMemoryWalletRepository();
$wallets->save(WalletMother::withBalance('w1', 1000));
$wallets->save(WalletMother::withBalance('w2', 0));
$useCase = new TransferMoney($wallets, new SpyTransferNotifier());
$useCase(new TransferMoneyCommand('w1', 'w2', 300));
$this->assertEquals(700, $wallets->find(new WalletId('w1'))->balance());
$this->assertEquals(300, $wallets->find(new WalletId('w2'))->balance());
}
This test runs in milliseconds. I can have hundreds of tests like this and run all of them in a few seconds. Then I write a few integration tests for the adapters, for example to check that the Doctrine mapping is correct.
Hexagonal architecture and AI features
Today many backends call an LLM. Hexagonal architecture fits very well here.
Imagine a use case that needs to classify a support ticket. Don't call the OpenAI or Anthropic SDK directly inside the use case. Create a port:
interface TicketClassifier
{
public function classify(TicketText $text): TicketCategory;
}
Then write an adapter that calls the model. Now you can:
- Change the provider without touching the business logic.
- Use a fake classifier in your unit tests.
- Add retries, timeouts and caching in the adapter only.
I write more about this in How to Integrate an LLM into Your Backend. The LLM is just another external system. Treat it like a database or a payment API: put it behind a port.
Common mistakes
After some years using this architecture, I have seen (and made) these mistakes:
1. Too many layers for a small project. If your app is a simple CRUD with three tables, you don't need ports for everything. Hexagonal architecture is useful when there are real business rules. Don't use it only because it looks professional.
2. Ports that copy the database. A port like findByStatusAndCreatedAtGreaterThan() is not a business concept. It is SQL with a different name. Try to name ports with business words: findPendingTransfersOlderThan().
3. Anemic domain. Some teams move all the logic into the use cases and the entities have only getters and setters. Then the domain is empty. Put rules inside the entities when they protect the entity's state, for example withdraw() checks the balance.
4. Framework annotations everywhere. Doctrine attributes inside domain entities are a gray area. Many teams accept them for pragmatism. Others prefer XML mapping to keep the domain clean. Choose one way and be consistent.
5. Forgetting the outside is also code. Adapters need tests too. A bug in the SQL adapter is still a bug in production.
How to start in an existing project
You don't need to rewrite everything. This is what I usually do:
- Choose one important feature with real business rules.
- Extract the logic from the controller into a use case class.
- Find the technical dependencies (database, email, API) and create a port for each one.
- Move the current code into adapters that implement the ports.
- Write unit tests for the use case with in-memory adapters.
- Repeat with the next feature.
Step by step, the important parts of your system become clean, and the rest can wait.
Summary
- Hexagonal architecture separates the inside (business) from the outside (technology).
- Ports are interfaces owned by the inside. Adapters implement them with real technology.
- Dependencies point to the inside. The domain doesn't know the framework.
- The biggest benefits are fast tests, easy changes and clear code organization.
- It works very well for AI integrations: an LLM is just another adapter.
If you want to go deeper into the principles behind this design, read SOLID Principles with Real Examples. The "D" in SOLID, dependency inversion, is the heart of hexagonal architecture.