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.tomlThe 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.
| Approach | Pros | Cons |
|---|---|---|
| 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.
from sqlalchemy import String, Numericfrom sqlalchemy.orm import Mapped, mapped_columnfrom 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.
from pydantic import BaseModelfrom 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.
[tool.pytest.ini_options]pythonpath = ["."]
[tool.ruff]src = ["src"]line-length = 88target-version = "py312"
[tool.mypy]python_version = "3.12"strict = trueWith 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.
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase): passDeclarativeBase 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.
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmakerfrom 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,BookServicetell 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
BookResponseand SQLAlchemyBooksolve different problems; conflating them creates tight coupling between your API contract and your database schema