Hexagonal vs. Onion Architecture Explained
You are staring at a pull request. The author claims it follows “Hexagonal Architecture,” but the folder structure looks like “Onion Architecture.” There are folders named Domain, Application, and Infrastructure, yet every service is wrapped in an interface “just in case.”
Confusion between these two patterns isn’t just a naming issue. It creates architectural debt. When your team doesn’t agree on where the “boundary” is versus where the “core” lives, you get leaky abstractions. You end up with database logic in your domain or, worse, domain logic scattered across Symfony Controllers.
Today, we are going to stop the guessing game. We will break down exactly why these two get mixed up, where they differ, and how to implement a clean version in PHP
Why Do We Keep Mixing Them Up?
The confusion happens because both architectures share the same “North Star”: Dependency Inversion. Both want to protect your business logic from external tools like databases, APIs, and UIs.
In both worlds:
- The Domain is the boss.
- The Database is just a detail.
- Dependencies always point inward.
Because they solve the same problem, people started using the terms interchangeably. But their “mental models” are different. One looks at the boundary (Hexagonal), while the other looks at the layers (Onion).
Hexagonal Architecture: It’s All About the Boundary
Alistair Cockburn created Hexagonal Architecture (also known as Ports and Adapters) in 2005. The goal was simple: allow an application to be equally driven by users, programs, automated tests, or scripts.
The Mental Model
Imagine your application as a hexagon. The shape doesn’t matter; the sides do. Each side represents a “Port.”
- Driving Side (Primary): These are the triggers. A CLI command, a Web Controller, or a Cron job.
- Driven Side (Secondary): These are the tools. A MySQL database, an SQS queue, or a Mailer.
The PHP Implementation
In Symfony, a “Port” is usually just a PHP Interface. An “Adapter” is the implementation.
// src/Domain/Port/Output/NotificationPort.php
namespace App\Domain\Port\Output;
/**
* The "Port" - This lives inside the Hexagon.
* It defines what the domain needs, not how it's done.
*/
interface NotificationPort
{
public function send(string $message): void;
}The Adapter lives outside the hexagon:
// src/Infrastructure/Adapter/EmailNotificationAdapter.php
namespace App\Infrastructure\Adapter;
use App\Domain\Port\Output\NotificationPort;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
/**
* The "Adapter" - This lives outside.
* It connects the Hexagon to a specific tool (Symfony Mailer).
*/
final readonly class EmailNotificationAdapter implements NotificationPort
{
public function __construct(
private MailerInterface $mailer,
private string $adminEmail
) {}
public function send(string $message): void:
{
$email = (new Email())
->from($this->adminEmail)
->to('[email protected]')
->subject('System Alert')
->text($message);
$this->mailer->send($email);
}
}Onion Architecture: It’s All About the Layers
Jeffrey Palermo introduced Onion Architecture in 2008. While Hexagonal focuses on the edges, Onion focuses on the internal structure. It uses concentric circles to represent different levels of your application.
The Core Principles
- The Center: The Domain Model (Entities and Value Objects) is at the absolute center.
- Inner Layers: These define interfaces.
- Outer Layers: These implement interfaces.
- Direction: All code can depend on layers closer to the center, but never on layers further out.
How it differs from Hexagonal
In Hexagonal, everything outside the hexagon is “Infrastructure.” In Onion, we distinguish between the Application Layer (use cases/orchestration) and the Infrastructure Layer (technical details).
The Comparison: Side-by-Side
| Feature | Hexagonal Architecture | Onion Architecture |
| Primary Focus | The Boundary (Input/Output) | The Internal Layers |
| Terminology | Ports & Adapters | Domain, Application, Infrastructure |
| Visual Model | A Hexagon with sides | Concentric circles (an onion) |
| Dependency Rule | Outside depends on inside | Outer circles depend on inner circles |
| Use Case Location | Usually inside the “Hexagon” | Specifically in the “Application” layer |
Implementing the “Hybrid” in Symfony (Production Ready)
Most modern PHP teams use a hybrid approach. We use the folder structure of Onion but the “Port/Adapter” naming for the infrastructure.
The Service (Application Layer)
This is where the magic happens. We use PHP 8.4 features like readonly classes and typed properties to keep it clean.
// src/Application/UseCase/RegisterUser.php
namespace App\Application\UseCase;
use App\Domain\Entity\User;
use App\Domain\Port\Output\UserRepositoryPort;
use App\Domain\ValueObject\Email;
final readonly class RegisterUser
{
public function __construct(
private UserRepositoryPort $userRepository
) {}
public function execute(string $emailAddress): void
{
$email = new Email($emailAddress);
if ($this->userRepository->existsByEmail($email)) {
throw new \InvalidArgumentException('User already exists.');
}
$user = new User($email);
$this->userRepository->save($user);
}
}The Infrastructure (Outer Ring)
Here is how we wire it up using Symfony’s services.yaml. We leverage autowiring to map the Port (Interface) to the Adapter (Implementation).
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
# Port to Adapter mapping
App\Domain\Port\Output\UserRepositoryPort:
class: App\Infrastructure\Adapter\Persistence\DoctrineUserRepositoryAdapterTroubleshooting: When Architecture Goes Wrong
1. The “Anemic” Domain
If your Domain folder only contains getters and setters, you are doing it wrong. That’s not a domain; that’s a data transfer object.
- Fix: Move logic (like “can this user subscribe?”) into the
Userentity itself.
2. Interface Overkill
Don’t create a Port for a service that will only ever have one implementation and isn’t an “external tool.”
- Fix: Only use Ports for things that cross the boundary (DB, Mail, External APIs, File System).
3. Leaky Exceptions
If your UserRepositoryPort throws a Doctrine\ORM\Exception, your domain is now coupled to Doctrine.
- Fix: Wrap infrastructure exceptions in Domain-specific exceptions.
Agentic AI: The 2026 Way to Refactor
In the current landscape, you shouldn’t be manually moving files to fix your architecture. We now use Agentic AI to handle the “papercuts” of refactoring.
You can prompt an AI agent to:
- Analyze your
src/directory. - Identify violations of the Dependency Inversion Principle (e.g., a Domain entity importing a Symfony Component).
- Automatically generate the necessary Ports and Adapters.
AI Prompt Example: “Scan the
src/Domaindirectory. Find any classes that importSymfony\Component\HttpFoundation. Extract those dependencies into a Port interface and create a concrete Adapter insrc/Infrastructure.”
The Verdict: Which One Should You Choose?
Don’t sweat the choice too much. If you focus on Dependency Inversion, you are already 90% there.
- Use Hexagonal if you have many different entry points (CLI, Web, Grpc, WebSockets) and want to be very strict about the “outside world.”
- Use Onion if you have a complex business domain with many layers of logic and want to ensure the “Core” stays pure.
In reality, most of us build “Hexagonal-ish Onions.” And that’s okay—as long as your dependencies point the right way.