Domain-Driven Design & Hexagonal Architecture: Building Decoupled, Testable Python Systems

Part of the Python in Production: DDD, Microservices, Asyncio & Rust series

When scaling Python applications, the biggest threat to long-term velocity is structural decay. As projects grow, database schemas, HTTP clients, and cloud SDKs tend to leak into business logic, leading to fragile, tightly-coupled codebases that are difficult to change or unit-test.

Establishing clean boundaries using Domain-Driven Design (DDD) and Hexagonal Architecture 1 also known as Ports & Adapters keeps systems highly maintainable. Using modern Python (3.12+) type safety features (such as PEP 544 Protocols and PEP 695 Type Parameters), this post defines compile-time contract definitions (ports) that isolate the business domain from external technical details.


Dependency Leakage in Large Codebases

In a traditional layered architecture, the business logic layer sits on top of the data access layer. While this layered design seems logical, the structure creates a transitively coupled chain where the business logic is forced to import and depend directly on the database access libraries or ORM models.

DATABASE PostgreSQL, SQLite, MySQL e.g., HTTP Controllers, FastAPI Routes BUSINESS LOGIC LAYER Direct dependency on ORM models & session e.g., SQLAlchemy ORM models, Engine Imports & Invokes Transitive Coupling Leak PRESENTATION LAYER (API) e.g., order_service.pay_invoice() DATA ACCESS LAYER (ORM) Reads & Writes (TCP/IP)

Dynamic coupling creates three major challenges:

  1. Testing friction requires mocking database connections, handling database sessions, or spinning up an in-memory database to test a simple business rule 2 e.g., calculating a discount .
  2. Infrastructure lock-in occurs because swapping an adapter 3 e.g., migrating from SQL to MongoDB, or from an external SMS provider to Twilio requires refactoring the core business logic.
  3. Implicit side effects occur when ORM models with lazy-loading attributes trigger unexpected database queries deep inside the business layer, leading to the N+1N+1 query problem at runtime.

High-level modules must not depend on low-level modules; instead, both module types must depend on abstractions. Hexagonal architecture implements the Dependency Inversion Principle by placing the business domain at the center and forcing all infrastructure to depend on that domain.


Locating the Source of Test Friction and DetachedInstanceErrors

To diagnose why our test suite ran slowly and frequently failed in CI, I profiled our domain tests. I initially assumed that the test friction was caused by slow network calls to our dev database, so I attempted to mock the SQLAlchemy database session globally.

However, this attempt failed. When running unit tests on our order confirmation service, accessing the customer relationship on the Order model threw a DetachedInstanceError. Our business logic relied on lazy-loaded relationships, which required an active database session even under test. This diagnostic mistake showed that wrapping the session or using mock adapters was a dead end: the ORM schemas themselves had transitively bound our business rules to the database driver.


Decoupling Systems with Hexagonal Architecture

Hexagonal Architecture reorganizes the application so that the Domain Layer sits at the absolute center, isolated from all external inputs and outputs.

Database Client INFRASTRUCTURE APPLICATION BOUNDARY PURE DOMAIN LAYER CLI REST Driving Port API Domain Models & Services Calls Driven Port Invokes (CLI Handler) (Repository)
  • Domain models contain pure data structures and business rules, maintaining zero dependencies on external libraries 4 no sqlalchemy, no httpx, no pydantic in terms of DB coupling .
  • Driving ports, or inbound interfaces, define how the external world triggers application workflows. In Python, public interface methods on application service classes represent these ports.
  • Driven ports, or outbound interfaces, define how the application reaches out to the external world 5 e.g. database persistence, event queues, mail servers . You can define these ports using typing.Protocol.
  • Adapters translate calls to and from the ports. For example, a PostgreSQLRepository is an adapter that implements a UserRepository port.

Directory Mapping

A clean Hexagonal directory structure enforces these import boundaries:

shop/
├── domain/
│ ├── __init__.py
│ └── order.py # Pure domain entities, zero external imports
├── ports/
│ ├── __init__.py
│ ├── repository.py # Outbound Protocols (e.g., OrderRepositoryPort)
│ └── gateway.py # Outbound Protocols (e.g., PaymentGatewayPort)
├── application/
│ ├── __init__.py
│ └── service.py # Inbound Driving Port / Service Orchestrator
└── adapters/
├── __init__.py
├── database.py # Postgres/SQLAlchemy adapter implementation
└── payment.py # Stripe HTTP client adapter implementation

Implementing Hexagonal Architecture in Python

To see how these abstract boundaries function in practice, we’ll build a toy type-safe order processing service.

We’ll start with the pure business logic in the domain, then defining our ports for external communication, orchestrating the flow in the application service, and finally implementing concrete adapters for the database and payment infrastructure.

The Pure Domain Layer

The order domain module contains only pure Python code and standard typing features.

To keep the domain layer clean and decoupled, the design relies strictly on standard library constructs rather than third-party frameworks. In this case, the domain models use a combination of enums and dataclasses (detailed structural safety aspects of dataclasses, enums, and immutability are covered in future posts). Using frozen=True dataclasses and returning updated copies via replace() is heavily inspired by Yehonathan Sharvit’s Data-Oriented Programming (DOP) principles of treating data as immutable structures. We will explore this paradigm in depth in Immutability & Safety.

domain/order.py
from dataclasses import dataclass, replace
from decimal import Decimal
from enum import StrEnum, auto
class OrderStatus(StrEnum):
PENDING = auto()
PAID = auto()
FAILED = auto()
@dataclass(frozen=True)
class Order:
id: str
customer_email: str
total_amount: Decimal
status: OrderStatus = OrderStatus.PENDING
def mark_as_paid(self) -> "Order":
if self.status != OrderStatus.PENDING:
raise ValueError(f"Cannot pay order in status {self.status}")
return replace(self, status=OrderStatus.PAID)

The Driven Ports

The driven port protocols define the contracts that infrastructure adapters must implement, using structural subtyping via typing.Protocol 6 ports/repository.py and ports/gateway.py .

In Python, Protocols implement structural subtyping (often called static duck typing). In contrast to traditional nominal inheritance (where a class must explicitly inherit from a parent class like abc.ABC), structural typing only requires that a class implements the attributes and methods specified by the Protocol. If it matches the shape, the type checker is satisfied.

Using Protocols for driven ports offers key advantages:

  • Unidirectional imports: adapters in the infrastructure layer do not need to import anything from the domain or ports layer just to inherit from them. They only need to implement the corresponding method signatures, ensuring that the domain has zero knowledge of the adapter implementations.
  • Simplified testing: you can write lightweight test doubles (fakes or stubs) without dragging along abstract base class inheritance boilerplate.
  • Separation of concerns: you can easily define narrow protocols that fit specific client needs, adhering to the Interface Segregation Principle.
shop/ports/repository.py
from typing import Protocol, runtime_checkable
from shop.domain.order import Order
@runtime_checkable
class RepositoryPort[T, ID](Protocol):
async def save(self, entity: T) -> None: ...
async def get_by_id(self, entity_id: ID) -> T | None: ...
@runtime_checkable
class OrderRepositoryPort(RepositoryPort[Order, str], Protocol):
...
shop/ports/gateway.py
from typing import Protocol, runtime_checkable
from shop.domain.order import Order
@runtime_checkable
class PaymentGatewayPort(Protocol):
async def charge(self, order: Order) -> bool: ...
async def refund(self, order: Order) -> None: ...

Under the hood, the generic RepositoryPort[T, ID] uses PEP 695 (introduced in Python 3.12) to declare type parameters:

  • Generic type parameters [T, ID]: this syntax declares T and ID as generic type parameters scoped directly to the class statement, eliminating the legacy TypeVar and Generic[...] boilerplate.
  • Type bounds: you can restrict type parameters by specifying an upper bound, e.g., class RepositoryPort[T: DomainEntity, ID: UUID | str](Protocol). This is conceptually similar to Rust’s generic bounds (e.g., T: Entity), ensuring that only subtypes of DomainEntity or specific ID types can satisfy the generic contract.
  • Scope and advantages: aside from dramatically cleaner syntax, the compiler scopes type variables strictly to the class block rather than polluting the module namespace, and type checkers can automatically infer variance. Variance describes how subtyping of a component type (e.g., Dog is an Animal) affects the subtyping of the container type (e.g., Container[Dog] vs Container[Animal]). It can be covariant (preserves subtyping), contravariant (reverses it), or invariant (requires an exact type match).
  • Constraints and limitations: Python bounds cannot express multiple independent protocols (e.g., no direct equivalent to Rust’s T: Display + Clone intersection bounds) unless you declare a single compound protocol that inherits from both. Furthermore, static type checkers (like Pyright or MyPy) check these bounds; standard Python does not enforce them at runtime.
  • Untyped fallback: if the design omits the type parameters entirely, the port falls back to operating on Any, which disables static check safety and defeats the purpose of structural contracts.

The Application Service

The application service acts as the driving port orchestrator. The service coordinates database checks and payment triggers strictly using the abstractions.

application/service.py
from shop.ports.repository import OrderRepositoryPort
from shop.ports.gateway import PaymentGatewayPort
class OrderProcessorService:
def __init__(
self,
repository: OrderRepositoryPort,
payment_gateway: PaymentGatewayPort,
) -> None:
self.repository = repository
self.payment_gateway = payment_gateway
async def process_payment(self, order_id: str) -> bool:
order = await self.repository.get_by_id(order_id)
if order is None:
raise ValueError(f"Order {order_id} not found")
success = await self.payment_gateway.charge(order)
if not success:
return False
try:
paid_order = order.mark_as_paid()
await self.repository.save(paid_order)
return True
except Exception:
await self.payment_gateway.refund(order)
raise

The Infrastructure Adapter

The Stripe payment adapter implements the payment gateway port and imports external network client libraries like httpx.

adapters/payment.py
import httpx
from shop.domain.order import Order
from shop.ports.gateway import PaymentGatewayPort
class StripePaymentAdapter(PaymentGatewayPort):
def __init__(self, api_key: str, client: httpx.AsyncClient) -> None:
self.api_key = api_key
self.client = client
async def charge(self, order: Order) -> bool:
try:
response = await self.client.post(...)
return response.status_code == 200
except httpx.HTTPError:
return False
async def refund(self, order: Order) -> None:
try:
await self.client.post(...)
except httpx.HTTPError:
pass

Architectural Pitfalls and Anti-Patterns

Primitive Obsession in Ports

We can easily fall into the trap of primitive obsession by defining port signatures using raw primitive types rather than rich domain objects. For example, a port defined with primitives bypasses type safety at the compiler level:

# Anti-pattern: primitive obsession in port signatures
async def save(self, order_id: str, total_amount: float) -> None: ...

This configuration forces adapters to handle database validation logic that belongs in the domain. Instead, we should pass fully formed domain models directly across our boundaries:

# Domain-centric port signature
async def save(self, order: Order) -> None: ...

Passing the Order model ensures that our business invariants are validated inside the domain boundaries before any persistence attempt.

Leaking Infrastructure Types Upstream

I’ve seen codebases leak infrastructure details upstream, which completely defeats the purpose of architectural boundaries. For example, catching database-specific exceptions inside our application services couples us to a single persistence technology:

# Anti-pattern: catching infrastructure-specific exceptions in service layers
try:
await self.repository.save(order)
except SQLAlchemyError as e: # Couples service directly to SQLAlchemy
raise ApplicationError("Database update failed") from e

To maintain boundary isolation, database adapters must catch their own technology-specific exceptions internally and raise custom domain exceptions 7 e.g., DomainRepositoryError or return None :

# Proper pattern: adapters map exceptions to custom domain exceptions
try:
await self.session.commit()
except SQLAlchemyError as e:
raise DomainRepositoryError("Database persistence failed") from e

Raising custom domain exceptions shields the application service from database details and keeps our error handling independent of the infrastructure.

Overusing Nominal Class Inheritance

If you come from OOP languages, you might be used to overusing nominal class inheritance by defining ports as abstract base classes (abc.ABC). Nominal inheritance 8 an adapter must explicitly inherit from the ABC forces adapters to import and subclass the interface explicitly:

# Nominal inheritance coupling
from shop.ports.repository import OrderRepositoryABC
class PostgresRepository(OrderRepositoryABC): ...

This creates rigid inheritance chains and complicates testing. By defining ports using typing.Protocol, we rely instead on structural typing 9 any class with matching method signatures satisfies it :

# Structural subtyping (no imports needed in the adapter file)
class PostgresRepository:
# Naturally conforms to OrderRepositoryPort by implementing the required methods
async def save(self, order: Order) -> None: ...

Any class with matching signatures automatically satisfies the port without explicit inheritance, allowing us to build lightweight stubs and decoupled adapters with ease.

Fragile Mocking vs In-Memory Fakes

Relying heavily on mocking libraries 10 like unittest.mock.AsyncMock inside a test suite makes our tests fragile. Mocks simulate behaviour but won’t warn us if a port’s method signature changes, leading to false green results in CI when the real code is broken.

An alternative approach is writing an in-memory fake adapter for testing. A fake is a lightweight, zero-dependency class that simulates a database or gateway using simple local Python dictionaries or lists:

from shop.domain.order import Order
from shop.ports.repository import OrderRepositoryPort
class InMemoryOrderRepository(OrderRepositoryPort):
def __init__(self) -> None:
self._orders: dict[str, Order] = {}
async def save(self, order: Order) -> None:
self._orders[order.id] = order
async def get_by_id(self, order_id: str) -> Order | None:
return self._orders.get(order_id)

Injecting this fake adapter into our service tests lets us verify the orchestration flow of our application services without network overhead, database configuration, or fragile mock setups.

Strategic Trade-offs and Limitations

While Hexagonal Architecture provides high maintainability, it is not a silver bullet. You should avoid this approach in the following scenarios:

  • Simple CRUD applications: if your application is a straightforward interface for database records with little to no complex business logic, creating ports, adapters, and domain models introduces unnecessary boilerplate and development overhead.
  • Microservices with single responsibilities: if a service only acts as an event proxy or simple translator (e.g., read from Kafka, write to Elasticsearch), layered or transaction-script patterns are faster to implement and maintain.
  • Rapid prototyping: during the early phase of a startup or product when the requirements are highly fluid and speed-to-market is the only priority, the strict isolation boundaries can slow down rapid pivots.

References and Additional Resources