Tutorial

Clean Code Python: Project Structure and Naming That Scales

Most Python backend projects start clean and become unmaintainable. Here is the folder layout and naming convention that keeps a FastAPI + SQLAlchemy codebase navigable at any size.

Tin Dang avatar
Tin Dang
Clean organized file folders on a desk with Python snake icon subtly visible

Every Python backend starts with a main.py and a vague promise to “organize it later.” By the time you have 15 endpoints, “later” is an emergency. The file that was supposed to be a small router has become a 600-line god module. You know a refactor is overdue, but every file touches every other file and there’s no obvious seam to cut.

The fix is not a refactor — it’s a structure you establish on day one. This post shows you the folder layout and naming conventions for a production FastAPI + SQLAlchemy codebase. We will use a bookstore API as the running example throughout this series, adding one pattern per post.

The Running Example: BookStore API

The BookStore API manages books, authors, orders, and users. It is small enough to understand quickly and complex enough to need real architecture — authenticated users, multi-item orders, and a catalog that needs full-text search.

Here is the target directory structure we will build toward across this series:

bookstore/
├── src/
│ ├── api/
│ │ ├── v1/
│ │ │ ├── books.py
│ │ │ ├── orders.py
│ │ │ └── users.py
│ │ └── deps.py
│ ├── core/
│ │ ├── config.py
│ │ └── security.py
│ ├── db/
│ │ ├── base.py
│ │ └── session.py
│ ├── models/
│ │ ├── book.py
│ │ └── user.py
│ ├── repositories/
│ │ ├── book_repository.py
│ │ └── user_repository.py
│ ├── services/
│ │ ├── book_service.py
│ │ └── order_service.py
│ └── schemas/
│ ├── book.py
│ └── user.py
├── tests/
│ ├── conftest.py
│ └── test_books.py
├── alembic/
└── pyproject.toml

The dependency direction is enforced structurally: API routers call services. Services call repositories. Repositories talk to the database. Nothing ever goes backward.

Every arrow points in one direction. If you find yourself importing from src/api inside src/services, you have an architectural violation — fix it before it compounds.

Why Layers, Not Features

There are two common approaches to organizing a backend: feature-based (one folder per domain) and layer-based (one folder per architectural concern). Both work. Which you choose depends on your team size and service boundary.

ApproachProsCons
Feature-based (books/, orders/, users/) Easy to find all code for a domain in one place. Natural for microservices. Cross-cutting concerns (auth, pagination) scatter across features. Dependency rules are implicit.
Layer-based (models/, services/, repositories/) Dependency direction is enforced by structure. Easy to test each layer in isolation. Single place for each type of concern. More files to navigate for a complete feature view. Requires discipline to keep layers clean.

Naming Rules That Eliminate Guessing

Good naming means a developer who has never seen the codebase can find any file in under 30 seconds. These five rules cover the entire BookStore API.

1. Models — Singular nouns, SQLAlchemy 2.0 style

SQLAlchemy model classes use singular PascalCase nouns: Book, User, Order. The file is the snake_case singular: book.py.

src/models/book.py
from sqlalchemy import String, Numeric
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(255))
isbn: Mapped[str] = mapped_column(String(13), unique=True)
price: Mapped[float] = mapped_column(Numeric(10, 2))

The Mapped[T] annotation with mapped_column() is SQLAlchemy 2.0’s type-safe ORM API. Avoid the legacy Column() pattern — it loses type information that mypy and pyright rely on.

2. Schemas — Pydantic models with intent suffix

Pydantic validation models use the pattern {Entity}{Intent}: BookCreate, BookUpdate, BookResponse. They live in src/schemas/ separate from ORM models.

src/schemas/book.py
from pydantic import BaseModel
from decimal import Decimal
class BookCreate(BaseModel):
title: str
isbn: str
price: Decimal
class BookResponse(BaseModel):
id: int
title: str
isbn: str
price: Decimal
model_config = {"from_attributes": True}

The from_attributes = True config enables BookResponse.model_validate(orm_instance) — converting a SQLAlchemy model to a Pydantic response without manual field mapping.

3. Repositories — One class per entity, methods describe intent

Repository classes use the Repository suffix: BookRepository, UserRepository. Methods name the query intent, never the implementation: get_by_id, list_by_author, create. Never get_data, fetch, or query.

If a method name requires the word “and” — get_book_and_author — it is doing too much. Split it.

4. Services — Business logic lives here

Service classes use the Service suffix: BookService, OrderService. They contain the business rules that do not belong in a route handler and do not belong in a repository. BookService.calculate_bulk_discount() is a service method. BookRepository.get_by_isbn() is a repository method.

Services call repositories. Repositories do not call services.

5. API routers — Files match URL segments

The file src/api/v1/books.py handles routes under /api/v1/books. This one-to-one mapping eliminates the question “which file handles this URL?” It is always the file whose path matches the URL.

The pyproject.toml Setup

Python’s import system is the source of many avoidable bugs. A src/ layout with explicit path configuration eliminates sys.path hacks in tests and makes the import structure mirror the file structure.

pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
[tool.ruff]
src = ["src"]
line-length = 88
target-version = "py312"
[tool.mypy]
python_version = "3.12"
strict = true

With pythonpath = ["."], both pytest and your application code use from src.models.book import Book — the same import everywhere, no special test configuration.

Bootstrapping the Database Session

Every other post in this series builds on two files: src/db/base.py and src/db/session.py. Create them first.

src/db/base.py
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

DeclarativeBase is the SQLAlchemy 2.0 replacement for the legacy declarative_base() function. All ORM models inherit from this single Base. Alembic’s env.py imports it to detect schema changes.

src/db/session.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.core.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

expire_on_commit=False prevents SQLAlchemy from expiring instance attributes after a commit — important when you return Pydantic schemas from repository methods, since accessing expired attributes would require an additional database roundtrip.

The settings object is a Pydantic BaseSettings model in src/core/config.py. We cover its configuration in Part 5 (Dependency Injection).

Key Takeaways

  • Layer-based layout enforces dependency direction by structure: API → Services → Repositories → DB
  • Naming conventions are a communication tool — BookCreate, BookRepository, BookService tell the reader the file’s responsibility before they open it
  • One concern per file keeps files small enough to read in a single sitting
  • Dependency arrows always point inward — if a lower layer imports from a higher layer, it’s an architecture violation that will cost you later
  • Schemas are separate from models — Pydantic BookResponse and SQLAlchemy Book solve different problems; conflating them creates tight coupling between your API contract and your database schema
0

Next in this series

Clean Code Python: Type Safety with Protocols Over ABCs

Continue reading