Clean Architecture in Python: Building Maintainable APIs Without the Overkill

H

Himashi Meththasena

Guest
If you’ve ever inherited a Python backend built with FastAPI, Django, or Flask that grew past 50,000 lines of code, you know the dread. A request to change a database field cascades into modifying serializers, view functions, background workers, and unit tests. Everything is coupled to everything else.

To solve this, developers often discover Clean Architecture or Domain-Driven Design (DDD). They read about dependency inversion, entities, use cases, ports, and adapters. Excitedly, they dive in—and a week later, they’ve written 15 abstract base classes, 6 layers of data mapping, and custom DTOs just to save a single user record.

They wanted maintainability, but they got Enterprise Java-style boilerplate in Python.

You don’t need to drown in abstraction to write clean code. Here is how to implement practical Clean Architecture in Python to build maintainable APIs without the overkill.

Why Standard Python APIs Get Messy​


In a typical framework-driven app, your business logic leaks directly into your web framework. Your API route handles HTTP parsing, executes raw SQL queries, performs business validation, and formats JSON responses all in one function:



Code:
# The Tightly Coupled Trap
@app.post("/users")
def create_user(payload: UserCreateSchema, db: Session = Depends(get_db)):
    # 1. Business logic mixed with framework code
    if db.query(User).filter_by(email=payload.email).first():
        raise HTTPException(status_code=400, detail="Email already exists")
    
    # 2. Direct ORM coupling
    db_user = User(name=payload.name, email=payload.email, hashed_password=hash_pw(payload.password))
    db.add(db_user)
    db.commit()
    
    return {"id": db_user.id, "status": "created"}



As the app grows, testing this requires a live database, mocking HTTP contexts becomes a nightmare, and swapping your ORM (say, from SQLAlchemy to Tortoise) means rewriting your entire codebase.

The Core Philosophy: Dependency Inversion​


Clean Architecture boils down to one fundamental rule: Dependencies must point inward.

Your core business logic (what your application does) should have zero knowledge of how data is stored (SQL, MongoDB) or how it's delivered (HTTP, CLI, WebSockets).

In Python, we can achieve this separation cleanly using three main layers instead of seven:

  1. Domain (Entities): Pure Python data classes or Pydantic models defining your core business rules.
  2. Use Cases (Interactors): The workflow logic (e.g., "Register User"). They orchestrate data but don't care where it goes.
  3. Interface Adapters & Infrastructure (Frameworks/DB): FastAPI routers, SQLAlchemy models, and external API clients.

Pragmatic Clean Architecture in Action​


Let’s build a lightweight, pragmatic user registration feature that keeps layers independent without writing unnecessary wrappers.

Step 1: The Domain Entity​


The domain layer contains pure business logic and validation rules, using standard Python or Pydantic for data validation.



Code:
# domain/user.py
from pydantic import BaseModel, EmailStr

class User(BaseModel):
    id: str
    email: EmailStr
    name: str
    is_active: bool = True

    def deactivate(self):
        self.is_active = False



Step 2: The Repository Interface (Protocol)​


Instead of concrete database calls, use Python’s typing.Protocol (structural subtyping) to define what the use case needs from a database, without tying it to an implementation.



Code:
# application/ports.py
from typing import Protocol, Optional
from domain.user import User

class UserRepository(Protocol):
    def get_by_email(self, email: str) -> Optional[User]: ...
    def save(self, user: User) -> User: ...





Step 3: The Use Case (The Interactor)​


The use case handles the actual business rules. Notice it doesn't import FastAPI, SQLAlchemy, or requests. It only knows about the domain and the port interface.



Code:
# application/register_user.py
import uuid
from domain.user import User
from application.ports import UserRepository

class RegisterUserUseCase:
    def __init__(self, user_repo: UserRepository):
        self.user_repo = user_repo

    def execute(self, email: str, name: str) -> User:
        existing = self.user_repo.get_by_email(email)
        if existing:
            raise ValueError("User with this email already exists.")
        
        new_user = User(id=str(uuid.uuid4()), email=email, name=name)
        return self.user_repo.save(new_user)



Step 4: Infrastructure & Framework Layer (FastAPI & SQLAlchemy)​


Now, we wire up the database implementation and the FastAPI route. This is where frameworks belong.

Code:
# infrastructure/sqlite_repo.py
from domain.user import User
from infrastructure.db_models import UserModel # SQLAlchemy model

class SqliteUserRepository:
    def __init__(self, db_session):
        self.db = db_session

    def get_by_email(self, email: str) -> Optional[User]:
        db_user = self.db.query(UserModel).filter_by(email=email).first()
        if not db_user:
            return None
        return User(id=db_user.id, email=db_user.email, name=db_user.name)

    def save(self, user: User) -> User:
        db_user = UserModel(id=user.id, email=user.email, name=user.name)
        self.db.add(db_user)
        self.db.commit()
        return user



Finally, the FastAPI endpoint connects them together:

Code:
# entrypoints/api.py
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from infrastructure.sqlite_repo import SqliteUserRepository
from application.register_user import RegisterUserUseCase
from infrastructure.db import get_db

app = FastAPI()

class RegisterRequest(BaseModel):
    email: EmailStr
    name: str

@app.post("/users")
def register_user(payload: RegisterRequest, db = Depends(get_db)):
    repo = SqliteUserRepository(db)
    use_case = RegisterUserUseCase(repo)
    
    try:
        user = use_case.execute(email=payload.email, name=payload.name)
        return {"id": user.id, "email": user.email, "status": "success"}
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))



Why This Approach Wins​

  1. Lightning-Fast Unit Testing: Because RegisterUserUseCase only depends on the UserRepository protocol, you can write unit tests using a simple in-memory mock repository without starting up FastAPI, SQLite, or Docker.
  2. Framework Independence: If you decide to migrate from FastAPI to Litestar, or from SQLAlchemy to Prisma, your core business logic and use cases remain 100% untouched.
  3. No Over-Engineering: By leveraging Python features like Protocol and Pydantic, you avoid the massive inheritance trees and redundant data mapper classes found in traditional enterprise translations.

So,​


Clean Architecture in Python isn't about writing more files; it's about separation of concerns. By keeping your business rules independent of your web framework and database drivers, you future-proof your application.

Start small: extract your business logic out of your route handlers into independent use case classes, use protocols for your storage layer, and watch how much easier your Python APIs become to test, scale, and maintain.
 

Thread statistics

Created
Himashi Meththasena,
Replies
0
Views
2
Back
Top