Architecting Scalable FastAPI Systems
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.
What is the primary principle behind the Hexagonal Architecture (Ports and Adapters)?
Which statement accurately describes the Dependency Rule in Hexagonal Architecture?