Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 071 / 100

Clean architecture in Python: what developers should know

Keep frameworks at the edge.

Expona Admin9 min read

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:

python
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.CANCELLED

Notice: 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:

python
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:

python
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:

python
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:

python
# 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.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles