If you've ever tried to test a business rule buried inside a controller, or watched a framework upgrade break half your application, you already know the problem hexagonal architecture solves.
Your business logic — the code that makes your product valuable — should not depend on your web framework, your database, or your messaging queue. Yet in most codebases, it does. Hexagonal architecture (also known as Ports and Adapters) is a pattern that flips this dependency around, and it's the foundation of how we build applications at HexagonalGuru.
The Problem: Framework Coupling
In a typical MVC application, the flow goes from the HTTP request to the controller, from the controller to the service, and from the service to the database. The controller knows about HTTP. The service often knows about the ORM. The business rules are scattered across all three layers.
The consequences are predictable:
- Testing is painful. You can't verify a business rule without booting the framework, the database, and half the container.
- Framework upgrades are risky. Your domain logic is entangled with framework-specific annotations, base classes, and conventions.
- Switching infrastructure is expensive. Moving from MySQL to PostgreSQL, or from REST to a message queue, means rewriting business code, not just plumbing.
The Core Idea
Hexagonal architecture, coined by Alistair Cockburn in 2005, proposes a simple rule:
The application core defines the interfaces. The outside world adapts to them — never the other way around.
Instead of your business logic calling the database, your business logic declares "I need something that can save an order" (a port). The database layer provides an implementation (an adapter). The dependency arrow points inward:
The Three Building Blocks
1. Domain (the centre)
Pure business logic: entities, value objects, and domain services. No framework imports. No database annotations. Just the rules of your business, expressed in code.
2. Ports (the contracts)
Interfaces defined by the application core:
- Input ports (driving): what the outside world can ask the application to do. In practice, these are your use-case classes or interfaces, e.g.
PlaceOrder,RegisterUser. - Output ports (driven): what the application needs from the outside world, e.g.
OrderRepository,PaymentGateway,Mailer.
3. Adapters (the edges)
Implementations that connect ports to real technology:
- Driving adapters call input ports: an HTTP controller, a CLI command, a message consumer.
- Driven adapters implement output ports: a Doctrine repository, a Stripe client, an SMTP mailer.
Adapters are replaceable by design. Swap MySQL for MongoDB? Write a new adapter — the core doesn't change.
A Minimal Example
Suppose we need to register users. The input port is a use case:
interface RegisterUser
{
public function __invoke(string $email, string $password): void;
}
The output port is what the use case needs:
interface UserRepository
{
public function save(User $user): void;
public function findByEmail(string $email): ?User;
}
The use case lives in the core and knows nothing about frameworks:
final class RegisterUserUseCase implements RegisterUser
{
public function __construct(
private UserRepository $users,
private Mailer $mailer,
) {}
public function __invoke(string $email, string $password): void
{
if ($this->users->findByEmail($email)) {
throw new UserAlreadyExists($email);
}
$user = User::register($email, $password);
$this->users->save($user);
$this->mailer->sendWelcome($user->email());
}
}
The Doctrine adapter implements the port:
final class DoctrineUserRepository implements UserRepository
{
public function __construct(private EntityManager $em) {}
public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
// ...
}
And the HTTP adapter is a thin shell:
final class RegisterUserController
{
public function __construct(private RegisterUser $registerUser) {}
public function __invoke(Request $request): Response
{
($this->registerUser)($request->email, $request->password);
return new Response(status: 201);
}
}
Notice: the business rule ("a user can't register twice") is tested in isolation, with an in-memory repository — no HTTP layer, no database, no framework bootstrapping.
What You Gain
- Fast, isolated tests. Unit-test business rules in milliseconds with in-memory adapters. Integration tests only cover the adapters themselves.
- Framework independence. Laravel, Symfony, or the next hot framework becomes a detail. Upgrades touch the edges, not the core.
- True infrastructure swaps. Database, queue, email provider — all become adapter swaps.
- Clear boundaries. New developers find business rules instantly; they don't have to reverse-engineer them from controllers.
- Aligned with DDD and Clean Architecture. Hexagonal architecture pairs naturally with Domain-Driven Design and is a pragmatic subset of Clean Architecture's ideas.
When Not to Use It
We're honest about this: hexagonal architecture is not free. It adds indirection, more interfaces, and more files. Skip it (or use it partially) when:
- CRUD-heavy applications with little business logic — a standard MVC app is fine.
- MVPs and prototypes where speed of iteration matters more than long-term structure.
- Very small teams on short-lived projects.
A good rule of thumb: the more business logic you have, and the longer the project will live, the more hexagonal architecture pays off.
Common Mistakes
- Leaking framework code into the core. If your entity extends
Modelor is full of ORM annotations, the framework is back in charge. - One port per adapter without need. Don't create
MySQLUserRepositoryandUserRepositorywhen you'll never have two implementations — use the interface where it earns its keep (testability, external boundaries). - Anemic use cases. A use case that just forwards calls to the repository adds nothing. Use cases should orchestrate domain logic.
- Skipping the test. If you're not writing isolated tests for the core, you're paying the cost of the pattern without collecting the benefit.
The Bottom Line
Hexagonal architecture is not about drawing hexagons — it's about who depends on whom. Make your technology depend on your business rules, and your software becomes testable, replaceable, and built to last.
If your codebase is suffering from the symptoms we described — untestable logic, risky upgrades, expensive infrastructure changes — a software audit is often the best first step. We map your technical debt and give you a prioritised, actionable report. Request a free consultation →
Want to go deeper? Read the next article in the series: Domain-Driven Design explained with a real-world example.