No history yet

Hexagonal Architecture Foundations

The Hexagonal Blueprint

Standard layered architectures often create a direct, top-down dependency flow: presentation depends on business logic, which depends on data access. This tight coupling makes the system rigid. A change in the database schema or a switch to a new web framework can trigger cascading changes throughout the application's core.

Hexagonal Architecture, or Ports and Adapters, inverts this relationship. It isolates the application's core domain and logic from the outside world, whether that's a UI, a database, or a third-party API. The core defines its own needs through a set of interfaces called 'ports'. The external tools then connect to these ports through 'adapters' that implement the required interface. This model treats all external dependencies as interchangeable plugins.

The hexagonal approach allows your teams to focus on core business logic while keeping implementation details at the edges of your application.

Layers of Responsibility

This architecture is typically organized into three concentric layers. Each layer has a distinct responsibility and can only communicate with adjacent layers through well-defined interfaces.

Domain Layer: This is the heart of your application. It contains the business entities and the core, framework-agnostic business rules. This code is pure Python, with no knowledge of databases, web frameworks, or any external system. It represents the 'what' of your business.

Application Layer: This layer orchestrates the domain layer to fulfill specific use cases or application services. It defines the application's behavior but doesn't implement it. It knows what to do by calling domain objects, but not how external tools (like a database) will perform their part. It contains the 'ports' of the architecture.

Infrastructure Layer: This is the outermost layer. It contains all the concrete implementations and integrations with external technologies. This includes web controllers (FastAPI routers), database repository implementations (SQLAlchemy or Motor), and clients for external services. It provides the 'adapters' that plug into the application layer's ports.

The critical rule is the Dependency Rule: source code dependencies can only point inwards. Nothing in an inner layer can know anything at all about an outer layer. Specifically, the domain and application layers must be completely ignorant of the infrastructure layer.

Defining Ports with ABCs

In Python, a 'port' is simply an interface. We define these interfaces using Python's built-in abc module to create Abstract Base Classes (ABCs). These classes define the methods that the application layer needs to function, without providing any concrete implementation. They form a contract that any adapter must fulfill.

For example, to handle user persistence, the application layer doesn't need to know about SQL tables or NoSQL documents. It just needs a way to save and retrieve a user object. We can define a port for this functionality.

# application/ports/user_repository.py

from abc import ABC, abstractmethod
from typing import Optional

from domain.entities.user import User

class UserRepository(ABC):
    """Port defining the contract for user persistence."""

    @abstractmethod
    def get_by_id(self, user_id: int) -> Optional[User]:
        pass

    @abstractmethod
    def save(self, user: User) -> User:
        pass

This UserRepository port lives in the application layer. Any service within the application layer will depend on this abstraction, not on a specific database technology.

Implementing Adapters

Adapters are the concrete implementations of our ports. They reside in the infrastructure layer and contain all the technology-specific code. Because they inherit from the port's ABC, we are guaranteed that they will fulfill the contract required by the application layer.

Here is a potential implementation of our UserRepository port using SQLAlchemy.

# infrastructure/repositories/sqlalchemy_user_repository.py

from sqlalchemy.orm import Session
from typing import Optional

from domain.entities.user import User
from application.ports.user_repository import UserRepository

class SQLAlchemyUserRepository(UserRepository):
    """Adapter for SQLAlchemy-based user persistence."""

    def __init__(self, session: Session):
        self._session = session

    def get_by_id(self, user_id: int) -> Optional[User]:
        # SQLAlchemy-specific query logic here
        return self._session.query(User).filter_by(id=user_id).first()

    def save(self, user: User) -> User:
        # SQLAlchemy-specific commit logic here
        self._session.add(user)
        self._session.commit()
        self._session.refresh(user)
        return user

If we decided to switch to a NoSQL database like MongoDB, the domain and application layers would remain completely untouched. We would simply write a new adapter that fulfills the UserRepository contract.

# infrastructure/repositories/mongo_user_repository.py

from pymongo.database import Database
from typing import Optional

from domain.entities.user import User
from application.ports.user_repository import UserRepository

class MongoUserRepository(UserRepository):
    """Adapter for MongoDB-based user persistence."""

    def __init__(self, db: Database):
        self._collection = db.users

    def get_by_id(self, user_id: int) -> Optional[User]:
        # PyMongo-specific query logic here
        user_data = self._collection.find_one({"_id": user_id})
        return User(**user_data) if user_data else None

    def save(self, user: User) -> User:
        # PyMongo-specific insert/update logic here
        self._collection.update_one(
            {"_id": user.id},
            {"πŸ’²set": user.dict()},
            upsert=True
        )
        return user

This is the power of dependency inversion. High-level application policies are not dependent on low-level implementation details; both depend on abstractions.

Project Structure

A project structure that reflects this architecture enforces the boundaries between layers. A high-scale application might be organized as follows. Note how the top-level directories map directly to the architectural layers.

src/
β”œβ”€β”€ domain/
β”‚   β”œβ”€β”€ __init__.py
β”‚   └── entities/
β”‚       β”œβ”€β”€ __init__.py
β”‚       └── user.py
β”œβ”€β”€ application/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ services/
β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   └── user_service.py
β”‚   └── ports/
β”‚       β”œβ”€β”€ __init__.py
β”‚       └── user_repository.py
└── infrastructure/
    β”œβ”€β”€ __init__.py
    β”œβ”€β”€ web/
    β”‚   β”œβ”€β”€ __init__.py
    β”‚   └── routers/
    β”‚       β”œβ”€β”€ __init__.py
    β”‚       └── user_router.py
    └── repositories/
        β”œβ”€β”€ __init__.py
        └── sqlalchemy_user_repository.py
main.py

In this layout, main.py is the composition root. It's where the concrete dependencies are created and injected. For instance, it would instantiate the SQLAlchemyUserRepository and pass it to the UserService, which only knows about the abstract UserRepository port. This structure ensures your core logic is decoupled and adaptable, ready for scaling and future changes.

Quiz Questions 1/6

What is the primary principle behind the Hexagonal Architecture (Ports and Adapters)?

Quiz Questions 2/6

Which statement accurately describes the Dependency Rule in Hexagonal Architecture?