Understanding Hexagonal Architecture with practical example

Available to registered members only
  • avatar
  • 84 Views
  • 7 mins read

Hexagonal architecture, also called ports and adapters, is a software design approach that helps structure an application by clearly separating the core logic from technical details and external systems. Instead of shaping your app around frameworks, protocols, or storage, you keep your focus on what the application does, and let everything else connect to it through interfaces. The pattern isn't tied to any specific language. The examples in this article are written in PHP to show how the idea can be applied, but the approach works the same way in any backend system.

Core ideas behind Hexagonal Architecture

The core of your application contains business logic and use cases. It doesn't know anything about HTTP, databases, or other tools. External components like web frameworks, ORMs, or messaging systems connect to this core through ports and adapters.

  • Ports are defined as interfaces. They describe what the application expects (input) or produces (output).

  • Adapters are the concrete implementations of these ports, written to deal with technical concerns.

This structure allows you to test the core logic in isolation, change technical details without affecting your use cases, and support multiple ways of interacting with the application (HTTP, CLI, jobs, etc.) without rewriting the logic.

Realistic project structure

A clean project structure following hexagonal architecture often looks like this:

src/
Domain/
User/
UserModel
UserRepository
Application/
User/
CreateUser
Infrastructure/
Persistence/
MySQLUserRepository
UserInterface/
Controller/
CreateUserController

Each folder has a clear responsibility:

  • Domain contains core business logic and interfaces

  • Application holds use cases that describe system behavior

  • Infrastructure provides technical implementations

  • UserInterface handles incoming requests and maps them to use cases

This structure helps avoid blurry lines between layers and keeps things easy to follow. In larger projects, the UserInterface layer often grows to support:

  • Validation of user input

  • Response formatting (JSON, HTML, etc.)

  • Mapping exceptions to proper output

  • Support for multiple interfaces like APIs, web dashboards, or commands

The goal is to keep anything related to how the user interacts with the system out of the core logic. Controllers exist only to call use cases and format the results.

When this pattern helps

Hexagonal architecture is useful when:

  • The system will have more than one way to interact with it (API, CLI, workers)

  • You want to avoid framework lock-in

  • You plan to test business logic without involving the full stack

  • You want to replace technical parts (database, queues, services) without risk

If you're building a tiny one-off project or script, this setup might feel heavy. But for real applications that will grow or change, the separation usually saves time later.

Practical use case

Let's go through a practical example using the create user feature. This example covers all the layers of the architecture and shows how they interact without overlapping.

This structure is minimal but already makes responsibilities clear:

  • The domain layer defines what a user is and what saving a user means

  • The application layer implements the actual logic of creating a user

  • The infrastructure layer handles storing the user in a MySQL database

  • The user interface layer exposes the feature (for example, via HTTP)

By keeping logic inside its correct layer, each part of the system stays easy to read, test, and change. You can swap the database adapter, add a CLI interface, or mock the repository for tests, and none of that affects your core logic.

Here's the complete flow:

src/Domain/User/UserModel.php

<?php

namespace App\\Domain\\User;

readonly class UserModel
{
public function __construct(
public string $id,
public string $name,
public string $email
) {}
}

This file defines what a User is. It has no knowledge of databases, HTTP, or framework-specific logic. It's a pure business object.

src/Domain/User/UserRepository.php

<?php

namespace App\\Domain\\User;

interface UserRepository
{
public function save(UserModel $user): void;
}

The repository interface describes what the application expects: a way to save users. The core doesn't care how or where the user gets saved.

src/Application/User/CreateUser.php

<?php

namespace App\\Application\\User;

use App\\Domain\\User\\UserModel;
use App\\Domain\\User\\UserRepository;

class CreateUser
{
public function __construct(
private UserRepository $repository
) {}

public function execute(string $name, string $email): void
{
$user = new UserModel(
id: uniqid('', true),
name: $name,
email: $email
);

$this->repository->save($user);
}
}

This is the actual use case. It receives input, creates the UserModel, and delegates the saving operation. It doesn't know about HTTP, SQL, or anything technical. It only uses the domain model and the repository interface.

src/Infrastructure/Persistence/MySQLUserRepository.php

<?php

namespace App\\Infrastructure\\Persistence;

use App\\Domain\\User\\UserModel;
use App\\Domain\\User\\UserRepository;
use PDO;

class MySQLUserRepository implements UserRepository
{
public function __construct(
private PDO $pdo
) {}

public function save(UserModel $user): void
{
$stmt = $this->pdo->prepare('INSERT INTO users (id, name, email) VALUES (:id, :name, :email)');

$stmt->execute([
'id' => $user->id(),
'name' => $user->name(),
'email' => $user->email()
]);
}
}

This class connects to the actual database using PDO. It implements the UserRepository interface, so the CreateUser use case can use it without knowing anything about SQL. You can later replace this class with another one using a different driver, or a test version with in-memory storage.

src/UserInterface/Controller/CreateUserController.php

<?php

namespace App\\UserInterface\\Controller;

use App\\Application\\User\\CreateUser;

class CreateUserController
{
public function __construct(
private CreateUser $createUserService
) {}

public function __invoke(array $input): void
{
$name = $input['name'] ?? '';
$email = $input['email'] ?? '';

$this->createUserService->execute($name, $email);

echo 'User created';
}
}

The controller acts as an entry point. It receives the user input (maybe from an HTTP request), passes it to the use case, and returns a response. The controller knows about the request format, but it doesn't contain business logic. It doesn't care how the user gets saved either.

You can later expose this controller in a route or command, or reuse the same use case in other interfaces.

Conclusion

Hexagonal architecture gives your application a clear shape by separating concerns through ports and adapters. Even with a basic structure like this, you get a cleaner system that's easier to understand, test, and extend. As your project grows, this kind of structure helps prevent chaos and keeps the code maintainable for the long run.

colored logo

This article is available to HiBit members only.

If you're new to HiBit, create a free account to read this article.

 Join Our Monthly Newsletter

Get the latest news and popular articles to your inbox every month

We never send SPAM nor unsolicited emails

0 Comments

Leave a Reply

Your email address will not be published.

Replying to the message: View original

Hey visitor! Unlock access to featured articles, remove ads and much more - it's free.