Decoupling Your Domain: Refactoring FastAPI with Ports and Adapters
The Problem with Framework Coupling
Many developers start a
When your domain logic raises HTTPException, it becomes untestable without a running web server. When it accepts a database Session object, you can't run a unit test without mocking complex

Prerequisites
To follow this tutorial, you should be comfortable with
Key Libraries & Tools
- FastAPI: A modern, fast web framework for building APIs with Python.
- SQLAlchemy: The Python SQL toolkit and ORM used here to manage database interactions.
- typing.Protocol: A structural typing mechanism used to define "Ports" without forced inheritance.
- dataclasses: Built-in Python decorators used to create clean domain models.
Code Walkthrough: Isolating the Domain
The first step is creating a pure domain layer. This layer must have zero imports from external frameworks. We define our own exceptions to replace HTTP errors.
# domain/errors.py
class DomainError(Exception):
pass
class OutOfStock(DomainError):
def __init__(self, sku: str, requested: int, available: int):
super().__init__(f"SKU {sku} is out of stock. Requested {requested}, only {available} left.")
Next, we define the "Port." In Protocol. It defines what the domain needs from the outside world without specifying how it's done.
# domain/ports.py
from typing import Protocol
class InventoryPort(Protocol):
def get_stock(self, sku: str) -> int: ...
def reserve(self, sku: str, quantity: int) -> int: ...
def exists(self, sku: str) -> bool: ...
Now, we write the "Use Case." This is pure logic that only speaks the language of the domain. It takes the InventoryPort as an argument, allowing us to swap the real database for a simple mock during testing.
# domain/use_cases.py
def place_order(inventory: InventoryPort, request: OrderRequest) -> OrderPlaced:
if not inventory.exists(request.sku):
raise UnknownSKU(request.sku)
available = inventory.get_stock(request.sku)
if available < request.quantity:
raise OutOfStock(request.sku, request.quantity, available)
remaining = inventory.reserve(request.sku, request.quantity)
return OrderPlaced(sku=request.sku, quantity=request.quantity, remaining_stock=remaining)
Implementing the Adapter
The Adapter is the glue code. The SQLAlchemyInventoryAdapter implements the InventoryPort using real database calls. The OrderRequest, calls the use case, and maps domain errors back to HTTP status codes.
# adapters/sql_alchemy.py
class SQLAlchemyInventoryAdapter(InventoryPort):
def __init__(self, conn: Connection):
self.conn = conn
def get_stock(self, sku: str) -> int:
# Real SQL Alchemy logic here
...
Syntax Notes & Best Practices
- Structural Typing: Using
typing.Protocolis preferred overabc.ABCbecause it allows for duck-typing. Any class with the right methods automatically satisfies the port. - Data Classes: Use
@dataclass(frozen=True)for domain models. Immutability prevents side effects within your business logic. - Error Translation: Always catch domain errors at the edge (the API adapter) and translate them to transport-specific errors. This keeps your domain "clean."
Tips & Gotchas
Don't let the extra files intimidate you. While db.commit() calls. This keeps your architecture flexible for future changes.

Fancy watching it?
Watch the full video and context