The Problem with "It Just Works" Code
Every Python project starts simple. A single file, a few functions, maybe a class or two. Then requirements grow: you add a database, an API, a background job queue. Before long, your "simple" script is a tangled mess where business logic lives alongside SQL queries and HTTP response formatting.
Clean Architecture is a set of principles, popularized by Robert C. Martin ("Uncle Bob"), designed to keep your core business logic independent from frameworks, databases, and external services.
Core Principle: The Dependency Rule
The fundamental rule is: source code dependencies must point inward. Inner layers know nothing about outer layers.
- Entities — Pure business objects and rules (innermost)
- Use Cases — Application-specific business logic
- Interface Adapters — Controllers, presenters, gateways
- Frameworks & Drivers — Django, Flask, SQLAlchemy, etc. (outermost)
Your database framework depends on your use cases. Your use cases do not depend on your database framework.
A Practical Python Example
Consider a simple user registration feature. Without clean architecture:
# Tightly coupled — hard to test, hard to change
def register_user(username, email):
db.execute("INSERT INTO users ...", username, email)
requests.post("https://email-service.com/send", {...})
return {"status": "ok"}
With clean architecture, we separate concerns:
# entities/user.py — pure Python, no dependencies
from dataclasses import dataclass
@dataclass
class User:
username: str
email: str
# use_cases/register_user.py — depends only on abstractions
class RegisterUserUseCase:
def __init__(self, user_repo, email_service):
self.user_repo = user_repo
self.email_service = email_service
def execute(self, username: str, email: str) -> User:
user = User(username=username, email=email)
self.user_repo.save(user)
self.email_service.send_welcome(user)
return user
Dependency Injection: The Glue
Clean architecture relies heavily on dependency injection — passing dependencies in rather than creating them inside functions. This makes testing trivial:
def test_register_user():
mock_repo = MockUserRepository()
mock_email = MockEmailService()
use_case = RegisterUserUseCase(mock_repo, mock_email)
user = use_case.execute("alice", "alice@example.com")
assert user.username == "alice"
assert mock_repo.saved_user == user
assert mock_email.welcome_sent is True
Recommended Project Structure
my_app/
├── entities/ # Business objects
│ └── user.py
├── use_cases/ # Business logic
│ └── register_user.py
├── adapters/ # Interfaces to external world
│ ├── repositories/
│ └── services/
├── infrastructure/ # Concrete implementations
│ ├── db/ # SQLAlchemy models
│ └── email/ # SMTP or API client
└── entrypoints/ # FastAPI, Flask, CLI
└── api/
When to Apply Clean Architecture
| Scenario | Recommendation |
|---|---|
| Simple script or one-off tool | Skip it — overkill |
| Small web app with limited logic | Partial separation is fine |
| Complex domain with many business rules | Strongly recommended |
| App likely to change frameworks or DBs | Essential |
| Team project with multiple developers | Strongly recommended |
Key Takeaways
- Your business logic should have zero imports from Django, SQLAlchemy, or any framework.
- Depend on abstractions (protocols/ABCs), not concrete implementations.
- Use dependency injection to wire things together at the edges.
- Start simpler and refactor toward clean architecture as complexity grows.