Introduction
After running a trading platform for three years, we learned the hard way that Python backends without clear structure become unmaintainable within months. Clean architecture in Python: what developers should know is not about rigid rules or over-engineering—it's about drawing a boundary between what your business logic does and how it talks to the outside world. This simple discipline keeps your codebase testable, portable, and ready for the inevitable pivots that real products demand.
Why clean architecture matters in Python
Python's flexibility is both a gift and a trap. It's easy to start with a FastAPI route that calls a Django ORM model that reaches into a third-party API client, all in the same file. Six months later, switching from PostgreSQL to MongoDB requires rewriting half your routes. Adding a gRPC server means duplicating business logic. Writing tests means mocking your entire HTTP stack.
Clean architecture solves this by creating concentric layers: your business logic sits in the center, untouched by framework choices. As you move outward, dependencies on external tools increase. Tests run against the core without touching HTTP, databases, or APIs. This structure is not unique to Python—it works in .NET, NestJS, and Go—but Python's dynamic nature makes it both easier to ignore and more valuable to adopt.
Domain — Pure Python classes and rules
Your domain is the innermost layer. It contains the rules that make your product what it is, expressed as plain Python classes with zero imports from web frameworks or libraries. No from fastapi import, no from django.db import. Just business logic.
Consider a ride-hailing service. A ride has a status, a driver, a passenger, and a fare calculation. The domain looks like this:
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from datetime import datetime
class RideStatus(Enum):
REQUESTED = "requested"
ACCEPTED = "accepted"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
@dataclass
class Location:
latitude: float
longitude: float
@dataclass
class Passenger:
id: str
name: str
phone: str
@dataclass
class Driver:
id: str
name: str
vehicle: str
rating: float
class Ride:
def __init__(
self,
ride_id: str,
passenger: Passenger,
driver: Driver,
pickup: Location,
dropoff: Location,
base_fare: Decimal,
):
self.ride_id = ride_id
self.passenger = passenger
self.driver = driver
self.pickup = pickup
self.dropoff = dropoff
self.base_fare = base_fare
self.status = RideStatus.REQUESTED
self.created_at = datetime.utcnow()
def accept(self) -> None:
if self.status != RideStatus.REQUESTED:
raise ValueError("Only requested rides can be accepted")
self.status = RideStatus.ACCEPTED
def complete(self, distance_km: float) -> Decimal:
if self.status != RideStatus.IN_PROGRESS:
raise ValueError("Only in-progress rides can be completed")
self.status = RideStatus.COMPLETED
fare = self.base_fare + Decimal(distance_km) * Decimal("2.50")
return fare
def cancel(self, reason: str) -> None:
if self.status == RideStatus.COMPLETED:
raise ValueError("Cannot cancel a completed ride")
self.status = RideStatus.CANCELLEDNotice: no HTTP, no database decorators, no external dependencies. This class enforces the rules. A ride cannot complete without being in progress. The state machine is explicit. Tests can create a Ride object and call methods in microseconds, no database needed.
Domain classes often include value objects (like Location) and entities (like Ride). The distinction matters: value objects are immutable and identified by their content; entities have identity. Keep both simple and focused.
Use cases — Orchestrating the domain
Use cases (also called application services) sit one layer out. They take domain objects, apply business rules, and coordinate with the outside world. A use case might say: "When a driver accepts a ride, check if they're online, update the ride status, send a notification to the passenger, and log the event."
Here's a use case for accepting a ride:
from typing import Protocol
class RideRepository(Protocol):
def get_by_id(self, ride_id: str) -> Ride | None:
...
def save(self, ride: Ride) -> None:
...
class NotificationService(Protocol):
def notify_passenger(self, passenger_id: str, message: str) -> None:
...
class AcceptRideUseCase:
def __init__(
self,
repository: RideRepository,
notification_service: NotificationService,
):
self.repository = repository
self.notification_service = notification_service
def execute(self, ride_id: str, driver_id: str) -> dict:
ride = self.repository.get_by_id(ride_id)
if not ride:
raise ValueError(f"Ride {ride_id} not found")
if ride.driver.id != driver_id:
raise ValueError("Driver not assigned to this ride")
ride.accept()
self.repository.save(ride)
self.notification_service.notify_passenger(
ride.passenger.id,
f"Driver {ride.driver.name} accepted your ride"
)
return {
"ride_id": ride.ride_id,
"status": ride.status.value,
"driver": ride.driver.name,
}Key points: The use case depends on abstractions (RideRepository, NotificationService`) defined as Protocol types, not concrete implementations. This lets you swap PostgreSQL for MongoDB, or a webhook service for SMS, without touching this code. The use case orchestrates, but the domain object (Ride) owns the state transitions.
Typically, you have one use case per business action: create ride, accept ride, complete ride, cancel ride. Keep them single-responsibility.
Adapters — Web routes, repositories, and external clients
Adapters are where frameworks live. A FastAPI adapter exposes use cases as HTTP routes. A PostgreSQL adapter implements the repository protocol. A Twilio adapter sends SMS. This is the outermost layer, and it's okay for it to be messy because it's isolated.
A FastAPI adapter might look like:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class AcceptRideRequest(BaseModel):
ride_id: str
driver_id: str
@app.post("/rides/{ride_id}/accept")
async def accept_ride(ride_id: str, request: AcceptRideRequest):
try:
result = accept_ride_use_case.execute(
ride_id=request.ride_id,
driver_id=request.driver_id,
)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))And a PostgreSQL adapter implementing the repository:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
class PostgresRideRepository:
def __init__(self, engine):
self.engine = engine
def get_by_id(self, ride_id: str) -> Ride | None:
with Session(self.engine) as session:
row = session.query(RideORM).filter_by(id=ride_id).first()
if not row:
return None
return self._to_domain(row)
def save(self, ride: Ride) -> None:
with Session(self.engine) as session:
orm_obj = self._to_orm(ride)
session.merge(orm_obj)
session.commit()
def _to_domain(self, orm_obj: RideORM) -> Ride:
return Ride(
ride_id=orm_obj.id,
passenger=Passenger(id=orm_obj.passenger_id, ...),
driver=Driver(id=orm_obj.driver_id, ...),
# ... map ORM to domain
)
def _to_orm(self, ride: Ride) -> RideORM:
return RideORM(
id=ride.ride_id,
passenger_id=ride.passenger.id,
# ... map domain to ORM
)The repository is now just a translation layer. Your domain doesn't know SQLAlchemy exists. If you need to support two databases temporarily during a migration, both adapters implement the same protocol. If you add gRPC later, it's another adapter—no changes to domain or use cases.
Stay pragmatic — Small services do not need every layer
Clean architecture is powerful, but it's not mandatory everywhere. A small Lambda function that transforms CloudWatch logs and writes to S3 doesn't need five layers. A simple cron job that fetches data and sends an email can be pragmatic: put the domain logic inline, call the adapter, done.
The rule of thumb: introduce a layer when you feel pain. If you have one route and one way to fetch data, keep it flat. When you add a second route that reuses logic, extract a use case. When you add a second data source, extract the repository abstraction.
For a multi-tenant CRM with dozens of entities and integrations, invest upfront in clean separation. For a rapid prototype in a hackathon, keep it simple. The structure scales when your team grows and your product matures.
A pragmatic starting point for a new microservice:
# app/domain.py - pure business logic
class Order:
def apply_discount(self, percent: int) -> None:
if percent < 0 or percent > 100:
raise ValueError("Invalid discount")
self.discount = percent
# app/use_cases.py - orchestration
def create_order_use_case(customer_id: str, items: list) -> Order:
order = Order(customer_id=customer_id, items=items)
repository.save(order)
return order
# app/routes.py - FastAPI entry point
@app.post("/orders")
async def create_order(req: CreateOrderRequest):
order = create_order_use_case(req.customer_id, req.items)
return {"order_id": order.id}This is clean, testable, and minimal. As complexity grows, you add adapters and abstractions.
Common mistakes
Importing framework code into the domain
If your domain class has from fastapi import or from django.db import, it's not really a domain class anymore. It's coupled to the framework. Fight the urge to use ORM decorators in the domain; let adapters handle the translation.
Creating too many layers too early
Over-architecting a two-route API introduces needless abstraction. You end up with a repository pattern where the only implementation is PostgreSQL, and the "benefit" is just more files to search through. Add layers when you have concrete reasons—multiple data sources, multiple delivery mechanisms, or a large team.
Leaking domain logic into adapters
Your FastAPI route should not contain business rules. If a ride can only be accepted when the driver has a rating above 4.5, that check belongs in the domain or use case, not in the route handler. Adapters receive requests, call use cases, format responses. That's it.
Ignoring the cost of abstraction
Protocols and dependency injection add indirection. Tracing a bug sometimes requires following the dependency chain. This is an acceptable cost for large systems; for tiny scripts, it's overhead. Be honest about your project's scope.
Key takeaways
- Domain layer first: Write pure Python classes with no framework dependencies. These classes express your business rules and are the most valuable code you own.
- Use cases orchestrate: Application services take domain objects, apply workflows, and coordinate with adapters. They stay framework-agnostic.
- Adapters sit on the edge: Web routes, database drivers, and external clients live here. They translate between the outside world and your domain.
- Test the domain directly: With clean boundaries, you test core logic without HTTP, database, or external API mocks.
- Start simple: A small service doesn't need full layering. Introduce structure as your team and codebase grow.
- Use protocols for abstraction: Python's typing.Protocol is more flexible than inheritance and makes dependency injection natural.
- Keep frameworks at the edge: FastAPI, Django, SQLAlchemy are tools, not your architecture. Your architecture is your domain.






Comments
Loading comments…