DEV Community

Cover image for Hexagonal Architecture in Python: Wiring Adapters, Dependency Injection, and the Application Layer
Pablo Ifrán
Pablo Ifrán

Posted on • Originally published at elpic.Medium

Hexagonal Architecture in Python: Wiring Adapters, Dependency Injection, and the Application Layer

You have a domain model with real behavior. You have ports that define the boundaries. You have adapters that fulfill them.

None of it runs until you wire it together.

This post covers exactly that: the application layer in hexagonal architecture, how FastAPI's dependency injection connects concrete adapters to abstract ports, and the folder structure that keeps the dependency direction honest. By the end, you'll have a fully wired hexagonal FastAPI service you can swap, test, and extend without touching your domain.


Where Does Each Piece Live?

Before writing code, fix the map. A hexagonal FastAPI project has five distinct zones:

app/
├── domain/
│   ├── models.py       # Money, Discount, OrderItem, Customer, Order
│   └── ports.py        # CustomerRepository, OrderRepository (Protocol)
├── adapters/
│   ├── sqlalchemy/
│   │   ├── orm.py      # SQLAlchemy table definitions
│   │   └── repositories.py  # SQLAlchemyCustomerRepository, SQLAlchemyOrderRepository
│   └── memory/
│       └── repositories.py  # InMemoryCustomerRepository, InMemoryOrderRepository
├── application/
│   └── order_service.py     # OrderService
├── api/
│   └── orders.py            # FastAPI router, request/response schemas
├── dependencies.py          # Dependency providers (get_session, get_order_service)
└── main.py                  # App factory, router registration
Enter fullscreen mode Exit fullscreen mode

The rule for where something lives: if it imports from sqlalchemy, it belongs in adapters/. If it imports from fastapi, it belongs in api/. domain/ imports from nothing outside the standard library. application/ imports from domain/ only.

This layered structure is the foundation of ports and adapters each zone has a single responsibility and a clear dependency direction.


The Application Layer

OrderService is the application layer. It sits between the API and the domain: takes raw inputs, coordinates domain objects and repositories, returns a result.

No HTTP. No SQLAlchemy. It only speaks domain terms.

# app/application/order_service.py
from typing import List

from app.domain.models import Order, OrderItem
from app.domain.ports import CustomerRepository, OrderRepository


class OrderService:
    def __init__(
        self,
        customer_repo: CustomerRepository,
        order_repo: OrderRepository,
    ) -> None:
        self.customer_repo = customer_repo
        self.order_repo = order_repo

    def place_order(self, customer_id: str, items: List[OrderItem]) -> Order:
        customer = self.customer_repo.find_by_id(customer_id)
        if not customer:
            raise ValueError(f"Customer {customer_id} not found")

        order = Order(
            customer_id=customer_id,
            items=items,
            discount=customer.discount(),
        ).place()

        return self.order_repo.save(order)
Enter fullscreen mode Exit fullscreen mode

The service accepts CustomerRepository and OrderRepository Protocols from domain/ports.py. It has no idea whether those are backed by PostgreSQL, SQLite, or a Python dict. That's the point.

The application layer is what makes hexagonal architecture composable: business logic is expressed purely in domain terms, while the infrastructure details stay outside.


The SQLAlchemy Adapters

The adapters live in adapters/sqlalchemy/. They know about the database. Nothing else needs to.

# app/adapters/sqlalchemy/repositories.py
from typing import Optional

from sqlalchemy.orm import Session

from app.adapters.sqlalchemy.orm import CustomerRow, OrderRow, OrderItemRow
from app.domain.models import Customer, Money, Order, OrderItem


class SQLAlchemyCustomerRepository:
    def __init__(self, session: Session) -> None:
        self.session = session

    def find_by_id(self, customer_id: str) -> Optional[Customer]:
        row = self.session.query(CustomerRow).filter_by(id=customer_id).first()
        if not row:
            return None
        return Customer(id=row.id, loyalty_points=row.loyalty_points)


class SQLAlchemyOrderRepository:
    def __init__(self, session: Session) -> None:
        self.session = session

    def save(self, order: Order) -> Order:
        row = OrderRow(
            customer_id=order.customer_id,
            status=order.status,
            total=order.total().amount,
        )
        for item in order.items:
            row.items.append(
                OrderItemRow(
                    product_id=item.product_id,
                    price=item.price.amount,
                    quantity=item.quantity,
                )
            )
        self.session.add(row)
        self.session.flush()
        order.id = str(row.id)
        return order
Enter fullscreen mode Exit fullscreen mode

session.flush() not session.commit() intentional. When to commit and when to roll back is the caller's job. Repositories save; they don't own transactions.

These adapters implement the ports defined in domain/ports.py. They are the concrete side of the ports and adapters pattern pluggable, replaceable, and invisible to the domain.


Wiring FastAPI with Dependency Injection

FastAPI's Depends is the dependency injection mechanism. Register a function that builds a dependency, and FastAPI calls it per request.

The wiring is split across two files to avoid a circular import: api/orders.py imports the dependency provider, and main.py imports the router they can't both import each other.

# app/dependencies.py
from typing import Generator

from fastapi import Depends
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session

from app.adapters.sqlalchemy.repositories import (
    SQLAlchemyCustomerRepository,
    SQLAlchemyOrderRepository,
)
from app.application.order_service import OrderService

DATABASE_URL = "postgresql://user:pass@localhost/orders_db"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)


def get_session() -> Generator[Session, None, None]:
    session = SessionLocal()
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()


def get_order_service(session: Session = Depends(get_session)) -> OrderService:
    return OrderService(
        customer_repo=SQLAlchemyCustomerRepository(session),
        order_repo=SQLAlchemyOrderRepository(session),
    )
Enter fullscreen mode Exit fullscreen mode
# app/main.py
from fastapi import FastAPI

from app.api import orders

app = FastAPI()
app.include_router(orders.router)
Enter fullscreen mode Exit fullscreen mode

get_order_service is the composition root the one place where a concrete adapter gets wired to an abstract port. This is where dependency injection makes hexagonal architecture operational. Swap SQLAlchemy for something else? dependencies.py is the only file that changes.


The FastAPI Route

The route handler is thin on purpose. It translates HTTP into domain objects, calls the service, handles errors.

# app/api/orders.py
from typing import List

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel

from app.application.order_service import OrderService
from app.dependencies import get_order_service
from app.domain.models import Money, OrderItem

router = APIRouter(prefix="/orders")


class OrderItemRequest(BaseModel):
    product_id: str
    price: float
    quantity: int


class PlaceOrderRequest(BaseModel):
    customer_id: str
    items: List[OrderItemRequest]


class PlaceOrderResponse(BaseModel):
    order_id: str
    total: float
    status: str


@router.post("/", response_model=PlaceOrderResponse, status_code=201)
def place_order(
    body: PlaceOrderRequest,
    service: OrderService = Depends(get_order_service),
) -> PlaceOrderResponse:
    try:
        domain_items = [
            OrderItem(
                product_id=i.product_id,
                price=Money(i.price),
                quantity=i.quantity,
            )
            for i in body.items
        ]
        order = service.place_order(body.customer_id, domain_items)
    except ValueError as exc:
        raise HTTPException(status_code=422, detail=str(exc))

    return PlaceOrderResponse(
        order_id=order.id,
        total=order.total().amount,
        status=order.status,
    )
Enter fullscreen mode Exit fullscreen mode

Parse, call, respond. ValueError from the domain becomes a 422. Business logic errors don't reach the route the domain handles them before it gets that far.

The application layer (OrderService) absorbs all the complexity; the route stays clean because it has nothing to reason about except HTTP translation.


Swapping Adapters for Tests

Here's the same route tested without a database. The key is overriding get_order_service in FastAPI's dependency injection system.

# tests/test_orders_api.py
import pytest
from fastapi.testclient import TestClient

from app.main import app
from app.dependencies import get_order_service
from app.application.order_service import OrderService
from app.adapters.memory.repositories import (
    InMemoryCustomerRepository,
    InMemoryOrderRepository,
)
from app.domain.models import Customer


def make_test_service(customers: dict) -> OrderService:
    return OrderService(
        customer_repo=InMemoryCustomerRepository(customers),
        order_repo=InMemoryOrderRepository(),
    )


@pytest.fixture
def client_with_customer():
    customers = {"c-1": Customer(id="c-1", loyalty_points=600)}
    service = make_test_service(customers)

    app.dependency_overrides[get_order_service] = lambda: service
    yield TestClient(app)
    app.dependency_overrides.clear()


def test_place_order_returns_201(client_with_customer):
    response = client_with_customer.post("/orders/", json={
        "customer_id": "c-1",
        "items": [{"product_id": "p-1", "price": 100.0, "quantity": 2}],
    })
    assert response.status_code == 201


def test_place_order_applies_loyalty_discount(client_with_customer):
    response = client_with_customer.post("/orders/", json={
        "customer_id": "c-1",
        "items": [{"product_id": "p-1", "price": 100.0, "quantity": 2}],
    })
    data = response.json()
    assert data["total"] == 180.0
    assert data["status"] == "pending"


def test_place_order_unknown_customer_returns_422():
    service = make_test_service({})
    app.dependency_overrides[get_order_service] = lambda: service
    client = TestClient(app)

    response = client.post("/orders/", json={
        "customer_id": "c-999",
        "items": [{"product_id": "p-1", "price": 50.0, "quantity": 1}],
    })

    app.dependency_overrides.clear()
    assert response.status_code == 422
Enter fullscreen mode Exit fullscreen mode

app.dependency_overrides swaps a dependency for the test's lifetime. No database, no env vars, no Docker. The test runs the exact same route code as production. Only the adapters differ.

The route, the service, and the domain are all tested with zero infrastructure. The SQLAlchemy* adapters get their own tests narrow, fast, and only hitting real infrastructure when you want them to.

This is dependency injection doing its job: the test harness replaces the production adapter without touching a single line of application or domain code.


Dependency Direction in Hexagonal Architecture — the Final Picture

HTTP request
    ↓
api/orders.py          ← knows about FastAPI, Pydantic, HTTP
    ↓
application/order_service.py  ← knows about domain only
    ↓
domain/ports.py (Protocol)    ← the boundary; no concrete dependencies
    ↑
adapters/sqlalchemy/   ← knows about SQLAlchemy
adapters/memory/       ← knows about Python dicts
Enter fullscreen mode Exit fullscreen mode

Nothing in domain/ imports from adapters/. Nothing in application/ imports from api/. The arrows only point inward.

This is the hexagonal architecture dependency rule enforced in code: the domain stays pure, the application layer orchestrates, and the adapters plug in at the edges.

When you reach for a Session inside OrderService, or raise HTTPException in a domain model those are the signals. A boundary is being crossed. In ports and adapters, that crossing is always a design smell.


What You've Built

Across this series, you've assembled every layer of a working hexagonal architecture in Python:

  • Domain objects with real behavior no framework leakage
  • Ports (Protocols) as explicit contracts at every boundary
  • Adapters that implement those contracts for SQL, memory, or anything else
  • An application layer that coordinates without knowing the infrastructure
  • Dependency injection via FastAPI's Depends to wire it all at startup
  • Tests that swap adapters without touching production code

The question worth asking: is this always worth it?

Post 5 is the honest answer. Trade-offs, pitfalls, when hexagonal architecture earns its complexity and when it doesn't.

Top comments (0)