Hexagonal Architecture (Python)
Hexagonal Architecture (also known as Ports and Adapters) is one of the best patterns for keeping your core business logic pure, testable, and completely decoupled from external noise like databases, APIs, or UI frameworks.
To get started, I will explain these core concepts and how they are applied using FastAPI, PostgreSQL, and Pytest.
The core philosophy
The main idea is simple: Your business logic does not care what database or web framework you use. Instead of building your application in traditional database-centric layers, you put your Domain (Business Logic) at the absolute center. Anything outside that core (FastAPI, PostgreSQL, Celery, External APIs) is considered an “external detail.”
The Inside (Domain/Core): Contains your business rules, entities, and use cases. It has zero dependencies on external libraries.
Ports (Interfaces): Define how the outside world can talk to the core (Inbound/Driving), or how the core talks to the outside world (Outbound/Driven). In Python, these are usually Abstract Base Classes (ABCs).
Adapters (Implementation): The glue code. An inbound adapter translates an HTTP request (FastAPI) into a core command. An outbound adapter translates a core database request into an actual SQL query (SQLAlchemy/Postgres).
Examples
I’ll create a lightweight implementation for a user registration system including: model, repository, service, and endpoint
The Domain (inside)
We define our model and the Port (Interface) for saving user, notice there are no FastAPI or SQLAlchemy (ORM) imports here.
# domain.py
from abc import ABC, abstractmethod
from pydantic import BaseModel
# Domain Model (Could be dataclass, attrs class, Pydantic model or simple class)
class User(BaseModel):
id: int | None = None
email: str
username: str
# Outbound Port: What the core expects from a database
# (interfaces that would be implemented via ORM, SQL or other depending on the adapter)
class UserRepositoryPort(ABC):
@abstractmethod
def save(self, user: User) -> User:
pass
@abstractmethod
def get_by_email(self, email: str) -> User | None:
pass
# Core Use Case / Service
class UserService:
def __init__(self, user_repo: UserRepositoryPort):
self.user_repo = user_repo # Injecting the port
def register_user(self, email: str, username: str) -> User:
if self.user_repo.get_by_email(email):
raise ValueError("Email already exists")
new_user = User(email=email, username=username)
return self.user_repo.save(new_user)The Adapter (Outbound)
This would be the actual database logic that could be implemented using an ORM like SQLAlchemy or using SQL with Psycopg2
# Implementation using SQLAlchemy 2.0 style
# postgres_adapter.py
from sqlalchemy import String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from domain import UserRepositoryPort, User
class Base(DeclarativeBase):
pass
# Database table model using modern Mapped types
class UserModel(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
email: Mapped[str] = mapped_column(String, unique=True, index=True)
username: Mapped[str] = mapped_column(String)
class PostgresUserRepository(UserRepositoryPort):
def __init__(self, db_session: Session):
self.db_session = db_session
def save(self, user: User) -> User:
db_user = UserModel(email=user.email, username=user.username)
self.db_session.add(db_user)
self.db_session.commit()
self.db_session.refresh(db_user)
return User(id=db_user.id, email=db_user.email, username=db_user.username)
def get_by_email(self, email: str) -> User | None:
stmt = select(UserModel).where(UserModel.email == email)
db_user = self.db_session.scalars(stmt).first()
if not db_user:
return None
return User(id=db_user.id, email=db_user.email, username=db_user.username)The Adapter (Inbound) - FastAPI
FastAPI here acts as an inbound adapter that catches HTTP requests, wires up the dependencies, and invokes the domain service.
# main.py
from fastapi import FastAPI, Depends, HTTPException, status
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine
from domain import UserService
from postgres_adapter import PostgresUserRepository, Base
from pydantic import BaseModel
# Standard SQLAlchemy setup, on production or other env it might require permissions
DATABASE_URL = "postgresql://user:password@localhost:5432/mydb"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
app = FastAPI()
# Dependency Injection helper
def get_user_service():
db = SessionLocal()
try:
# Wire adapter into port, and port into service
repo = PostgresUserRepository(db)
yield UserService(repo)
finally:
db.close()
# FastAPI Request Schema
class UserCreateRequest(BaseModel):
email: str
username: str
@app.post("/users/", response_model=dict, status_code=status.HTTP_201_CREATED)
def create_user(request: UserCreateRequest, service: UserService = Depends(get_user_service)):
try:
user = service.register_user(email=request.email, username=request.username)
return {"id": user.id, "email": user.email, "username": user.username}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))Tests
Service mock helper
# test_user_service.py service mock helper
import pytest
from domain import UserRepositoryPort, User, UserService
# An Outbound Adapter created strictly for testing purposes
class InMemoryUserRepository(UserRepositoryPort):
def __init__(self):
self.users: dict[str, User] = {}
self._id_counter = 1
def save(self, user: User) -> User:
user.id = self._id_counter
self.users[user.email] = user
self._id_counter += 1
return user
def get_by_email(self, email: str) -> User | None:
return self.users.get(email)Tests
def test_register_user_successfully():
# Arrange: Setup the mock repository and the service
mock_repo = InMemoryUserRepository()
service = UserService(mock_repo)
# Act: Run the production logic
new_user = service.register_user(email="[email protected]", username="testuser")
# Assert: Verify the results
assert new_user.id == 1
assert new_user.email == "[email protected]"
# Ensure it actually saved into our fake repository
assert mock_repo.get_by_email("[email protected]") is not None
def test_register_user_duplicate_email_raises_error():
# Arrange: Setup repository and pre-populate an existing user
mock_repo = InMemoryUserRepository()
service = UserService(mock_repo)
service.register_user(email="[email protected]", username="first_user")
# Act & Assert: Verify that duplicating an email raises a ValueError
with pytest.raises(ValueError) as exc_info:
service.register_user(email="[email protected]", username="second_user")
assert str(exc_info.value) == "Email already exists"Tests are deterministic, business logic tests will never fail because of a database connection timeout or a faulty network layer. If a test fails, it’s strictly because the business logic is broken.
No side effects to worry about cleaning up the database, test will run fast, and it can be
run in parallel using the pytest plugin pytest-xdist spawning a number of workers equal to CPUs, and distribute the tests randomly across them.
Benefits
Mocking and testing comes easy and fast, because you won’t need a real PostgreSQL database to test your service, for example you can write an in memory repository that inherits from
UserRepositoryPortusing a simple Python dict, and test your business logic in milliseconds.Swapping tech stacks, if you decide to migrate from PostgreSQL to another database like MongoDB, you only change your outbound adapter,
domain.pyandmain.pywill remain untouched.
Scaling Up: Managing Multiple Repositories & Transactions
The previous example is simple, normally on production in large system, one would be dealing with multiple endpoints and services the approach for these case is to rely on FastAPI's Dependency Injection (Depends) with the Unit of Work or Repository pattern
here are some examples:
Note: OrderService, PostgresOrderRepository, and OrderCreate are assumed they exists and are similar to the other repositories and services
Example: One Repository, Reusable Services
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy import create_engine
# --- 1. SETUP DATABASE ---
DATABASE_URL = "postgresql://user:password@localhost:5432/mydb"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# --- 2. FASTAPI DEPENDENCY HELPERS ---
# This is your single source of truth for managing the DB session lifecycle
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Factory for UserService
def get_user_service(db: Session = Depends(get_db)) -> UserService:
# 1. Instantiate the DB adapter
user_repo = PostgresUserRepository(db)
# 2. Inject it into the service and return it
return UserService(user_repo)
# Factory for OrderService (Sharing the exact same DB session helper!)
def get_order_service(db: Session = Depends(get_db)) -> OrderService:
order_repo = PostgresOrderRepository(db)
return OrderService(order_repo)
app = FastAPI()
# Endpoint 1: Uses the User Service
@app.post("/users/")
def create_user(request: UserCreate, service: UserService = Depends(get_user_service)):
return service.register_user(request.email, request.username)
# Endpoint 2: Uses the User Service (Reusing the same service structure!)
@app.get("/users/{user_id}")
def get_user(user_id: int, service: UserService = Depends(get_user_service)):
return service.get_user_profile(user_id)
# Endpoint 3: Uses a completely different Service, but uses the same DB engine under the hood
@app.post("/orders/")
def create_order(request: OrderCreate, service: OrderService = Depends(get_order_service)):
return service.place_order(request.user_id, request.items)If an endpoint needs to do complex work across multiple domains, there are two good options
- Service-to-Service Injection
def get_order_service(db: Session = Depends(get_db)) -> OrderService:
order_repo = PostgresOrderRepository(db)
user_repo = PostgresUserRepository(db) # We grab the user repo too
return OrderService(order_repo, user_repo)- The Unit of Work Pattern, using
SQLAlchemy, this is more advance pattern involve transactions and implement a couple of extra methods in the interface like__enter__,__exit__,commit,rollback
Domain (Ports)
# domain/ports.py
from abc import ABC, abstractmethod
from typing import Self
from domain.models import User, Order # Assuming simple Pydantic/dataclass models
class UserRepositoryPort(ABC):
@abstractmethod
def save(self, user: User) -> User: pass
@abstractmethod
def get_by_id(self, user_id: int) -> User | None: pass
class OrderRepositoryPort(ABC):
@abstractmethod
def save(self, order: Order) -> Order: pass
# The Unit of Work Port
class UnitOfWorkPort(ABC):
users: UserRepositoryPort
orders: OrderRepositoryPort
@abstractmethod
def __enter__(self) -> Self: pass
@abstractmethod
def __exit__(self, exc_type, exc_val, exc_tb): pass
@abstractmethod
def commit(self): pass
@abstractmethod
def rollback(self): passCore Business logic (use case)
# domain/services.py
from domain.ports import UnitOfWorkPort
from domain.models import Order
class OrderPlacementService:
def __init__(self, uow: UnitOfWorkPort):
self.uow = uow
def place_order(self, user_id: int, item_name: str, price: float) -> Order:
# We wrap our database operations in the UoW context manager
with self.uow:
# 1. Use the user repository attached to the UoW
user = self.uow.users.get_by_id(user_id)
if not user:
raise ValueError("User not found")
# 2. Business Rule validation
if user.is_suspended:
raise ValueError("Suspended users cannot place orders")
# 3. Create and save order using the order repository attached to the UoW
new_order = Order(user_id=user_id, item_name=item_name, price=price)
saved_order = self.uow.orders.save(new_order)
# 4. Explicitly commit if all operations pass successfully
self.uow.commit()
return saved_order
# If any exception is raised within the `with` block,
# the UoW's __exit__ method automatically triggers a rollback.Adapter
# adapters/postgres.py
from sqlalchemy.orm import Session
from domain.ports import UnitOfWorkPort
from adapters.repositories import PostgresUserRepository, PostgresOrderRepository
class SQLAlchemyUnitOfWork(UnitOfWorkPort):
def __init__(self, session_factory):
self.session_factory = session_factory # A sessionmaker instance
def __enter__(self):
self.session: Session = self.session_factory()
# Bind repositories to the newly opened session
self.users = PostgresUserRepository(self.session)
self.orders = PostgresOrderRepository(self.session)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
self.rollback() # An error occurred inside the 'with' block
self.session.close()
def commit(self):
self.session.commit()
def rollback(self):
self.session.rollback()The wiring (Inbound Adapter)
# app/main.py
from fastapi import FastAPI, Depends, HTTPException, status
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from adapters.postgres import SQLAlchemyUnitOfWork
from domain.services import OrderPlacementService
from pydantic import BaseModel
DATABASE_URL = "postgresql://user:password@localhost:5432/mydb"
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
app = FastAPI()
# Single DI Factory for the use case
def get_order_service() -> OrderPlacementService:
uow = SQLAlchemyUnitOfWork(SessionLocal)
return OrderPlacementService(uow)
class OrderRequest(BaseModel):
user_id: int
item_name: str
price: float
@app.post("/orders/", status_code=status.HTTP_201_CREATED)
def create_order(payload: OrderRequest, service: OrderPlacementService = Depends(get_order_service)):
try:
order = service.place_order(
user_id=payload.user_id,
item_name=payload.item_name,
price=payload.price
)
return {"status": "success", "order_id": order.id}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))References
https://alistair.cockburn.us/hexagonal-architecture
https://en.wikipedia.org/wiki/Hexagonal_architecture_(software)
https://docs.sqlalchemy.org/en/20/orm/index.html
https://fastapi.tiangolo.com/learn/