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.
Dynamic coupling creates three major challenges:
- 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 .
- 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.
- Implicit side effects occur when ORM models with lazy-loading attributes trigger unexpected database queries deep inside the business layer, leading to the 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.
- Domain models contain pure data structures and business rules, maintaining zero dependencies on external libraries 4
no
sqlalchemy, nohttpx, nopydanticin 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
PostgreSQLRepositoryis an adapter that implements aUserRepositoryport.
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 implementationImplementing 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.
from dataclasses import dataclass, replacefrom decimal import Decimalfrom 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.
from typing import Protocol, runtime_checkablefrom shop.domain.order import Order
@runtime_checkableclass RepositoryPort[T, ID](Protocol): async def save(self, entity: T) -> None: ... async def get_by_id(self, entity_id: ID) -> T | None: ...
@runtime_checkableclass OrderRepositoryPort(RepositoryPort[Order, str], Protocol): ...from typing import Protocol, runtime_checkablefrom shop.domain.order import Order
@runtime_checkableclass 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 declaresTandIDas generic type parameters scoped directly to the class statement, eliminating the legacyTypeVarandGeneric[...]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 ofDomainEntityor 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.,
Dogis anAnimal) affects the subtyping of the container type (e.g.,Container[Dog]vsContainer[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 + Cloneintersection 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.
from shop.ports.repository import OrderRepositoryPortfrom 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) raiseThe Infrastructure Adapter
The Stripe payment adapter implements the payment gateway port and imports external network client libraries like httpx.
import httpxfrom shop.domain.order import Orderfrom 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: passArchitectural 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 signaturesasync 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 signatureasync 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 layerstry: await self.repository.save(order)except SQLAlchemyError as e: # Couples service directly to SQLAlchemy raise ApplicationError("Database update failed") from eTo 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 exceptionstry: await self.session.commit()except SQLAlchemyError as e: raise DomainRepositoryError("Database persistence failed") from eRaising 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 couplingfrom 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 Orderfrom 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
- PEP 695 (Type Parameter Syntax 11 Python 3.12 Generics ): https://peps.python.org/pep-0695/
- PEP 544 – Protocols: Structural subtyping (Static Duck Typing): https://peps.python.org/pep-0544/
- PEP 673 – Self Type: https://peps.python.org/pep-0673/
- PEP 585 – Type Hinting Generics in Standard Collections: https://peps.python.org/pep-0585/
- PEP 604 – Allow writing union types as X | Y: https://peps.python.org/pep-0604/
- Official Python
typingdocumentation: https://docs.python.org/3/library/typing.html - Domain-Driven Design Reference by Eric Evans: https://domainlanguage.com/ddd/reference/
- Hexagonal Architecture by Alistair Cockburn: https://alistair.cockburn.us/hexagonal-architecture/