Anatomy Of A Well Organised FastAPI Application

Compiled as a personal reminder on a typical architecture of a FastAPI application, especially focusing on how to organise it cleanly when dealing with multiple models and modules.
Overall View of a FastAPI Application Architecture
At its core, a FastAPI application takes incoming HTTP requests, routes them to specific Python functions (path operations), processes them (often interacting with a database or other services), and returns an HTTP response.
Key Components within a FastAPI Application:
Main Application Instance: The FastAPI() object itself, which acts as the central router and configuration hub.
Path Operations (Endpoints): Python functions decorated with @app.get(), @app.post(), etc., that handle specific HTTP methods and URL paths.
Pydantic Models:
-
Request Models: Define the structure and validation rules for incoming request bodies (e.g.,
POSTdata). -
Response Models: Define the structure of the data returned by path operations, automatically serializing Python objects to JSON and filtering fields.
-
Database Models: (Often ORM-specific) Represent the structure of your database tables. These are often distinct from your request/response models to decouple your API from your database schema.
Dependency Injection System: FastAPI’s powerful mechanism for managing shared resources (like database connections), authentication, authorisation, and other common functionalities. Dependencies are simply functions that “inject” into your path operations.
Routers (APIRouter): A way to organize path operations into logical groups (e.g., all user-related endpoints, all document-related endpoints). This prevents main.py from becoming a monolithic file.
Database Interaction: Code that connects to your database, performs queries, and manages sessions (e.g., using SQLAlchemy, Tortoise ORM, or raw psycopg2).
Configuration Management: Handling environment variables, settings, and secrets.
Error Handling: Custom exception handlers for specific error conditions.
Static Files & Templates: (Less common for pure APIs, but possible) Serving static content or dynamic HTML.
Organizing an API Implementation with Multiple Models and Modules
The key to a clean and scalable FastAPI application lies in modularization and separation of concerns.
Here’s a common and highly recommended project structure:
your_fastapi_project/
├── .env # Environment variables (e.g., DB connection strings)
├── requirements.txt # Project dependencies
├── main.py # Entry point of the FastAPI application
├── app/ # Main application package
│ ├── __init__.py # Makes 'app' a Python package
│ ├── main.py # The FastAPI app instance and possibly includes routers
│ ├── core/ # Core configurations, settings, utility functions
│ │ ├── __init__.py
│ │ ├── config.py # Pydantic BaseSettings for configuration
│ │ ├── database.py # Database connection, session management
│ │ └── security.py # JWT, password hashing, authentication dependencies
│ ├── api/ # API endpoints organized by resource
│ │ ├── __init__.py
│ │ ├── routers/ # Contains APIRouter instances
│ │ │ ├── __init__.py
│ │ │ ├── users.py # User-related endpoints
│ │ │ ├── documents.py # Document-related endpoints
│ │ │ └── items.py # Other resource-related endpoints
│ │ └── deps.py # API-specific dependencies (e.g., get_current_user)
│ ├── crud/ # Create, Read, Update, Delete operations (database interactions)
│ │ ├── __init__.py
│ │ ├── users.py # CRUD operations for User model
│ │ └── documents.py # CRUD operations for Document model
│ ├── schemas/ # Pydantic models (request/response)
│ │ ├── __init__.py
│ │ ├── users.py # Request/Response models for users
│ │ └── documents.py # Request/Response models for documents
│ ├── models/ # Database ORM models (e.g., SQLAlchemy models)
│ │ ├── __init__.py
│ │ ├── users.py # SQLAlchemy model for User
│ │ └── documents.py # SQLAlchemy model for Document
│ └── tests/ # Unit and integration tests
│ ├── __init__.py
│ ├── test_users.py
│ └── test_documents.py
└── Dockerfile # (Optional) For containerization
​Explanation of Each Component and Its Role:
main.py (root level):
-
This is often just a stub that imports
appfromapp.mainand serves as the entry point foruvicorn. -
Example:
from app.main import app
app/main.py:
-
Initializes the
FastAPI()application instance. -
Includes all
APIRouterinstances fromapp.api.routers(e.g.,app.include_router(users_router)). -
May define global event handlers (
@app.on_event("startup"),@app.on_event("shutdown")). -
Can define global exception handlers.
app/core/ (Core Configuration & Utilities):
config.py: Uses Pydantic’sBaseSettingsto load environment variables and application settings. This centralizes all configurations.
from pydantic_settings import BaseSettings, SettingsConfigDict
​
class Settings(BaseSettings):
app_name: str = "My Awesome API"
database_url: str
# model_config = SettingsConfigDict(env_file=".env") # For Pydantic v2+
​
settings = Settings()-
database.py: Handles database connection details, ORM session management (e.g.,SQLAlchemyengine andsessionmaker). It often provides aget_db()dependency for path operations. -
security.py: Contains utilities for authentication (e.g., JWT token creation/decoding), password hashing, and authentication dependencies (e.g.,oauth2_scheme).
app/api/ (API Layer):
-
routers/: -
Each file here (
users.py,documents.py) defines anAPIRouterinstance. -
Contains all the path operations (your actual endpoints like
@router.post("/documents")). -
Dependencies specific to a router or its path operations are defined here or imported from
app.api.deps.py.
# app/api/routers/documents.py
from fastapi import APIRouter, Depends, HTTPException, status
from app.schemas.documents import DocumentCreate, DocumentResponse # Request/Response models
from app.crud import documents as crud_documents # Import CRUD operations
from app.core.database import get_db # Database dependency
from sqlalchemy.orm import Session # For SQLAlchemy
​
router = APIRouter(prefix="/documents", tags=["Documents"])
​
@router.post("/", response_model=DocumentResponse)
def create_document(document: DocumentCreate, db: Session = Depends(get_db)):
db_document = crud_documents.create_document(db=db, document=document)
return db_document
​
@router.get("/{document_id}", response_model=DocumentResponse)
def read_document(document_id: str, db: Session = Depends(get_db)):
db_document = crud_documents.get_document(db=db, document_id=document_id)
if not db_document:
raise HTTPException(status_code=404, detail="Document not found")
return db_documentdeps.py: For API-specific dependencies that are shared across multiple routers (e.g.,get_current_active_user).
app/crud/ (CRUD Operations / Services Layer):
-
Contains the business logic and direct database interaction code (Create, Read, Update, Delete).
-
Each file (
users.py,documents.py) encapsulates the operations for a specific database model. -
This layer uses the ORM models (from
app/models/) and receives a database session. -
Keeps database logic separate from your API endpoints, making both easier to test and maintain.
# app/crud/documents.py
from sqlalchemy.orm import Session
from app.models.documents import Document as DBDocument # ORM model
from app.schemas.documents import DocumentCreate # Pydantic schema
​
def get_document(db: Session, document_id: str):
return db.query(DBDocument).filter(DBDocument.document_id == document_id).first()
​
def create_document(db: Session, document: DocumentCreate):
# Note: In a real app, you'd handle UUID generation here or in DB
db_document = DBDocument(**document.model_dump()) # Pydantic v2 model_dump()
db.add(db_document)
db.commit()
db.refresh(db_document) # Refresh to get auto-generated fields like document_id
return db_documentapp/schemas/ (Pydantic Models - Request/Response):
-
Defines the input and output data structures for your API.
-
Crucially, these are typically distinct from your database models.
-
DocumentCreate: What the client sends in aPOSTrequest (e.g.,document_idandupload_timestampmight be excluded). -
DocumentResponse: What the API sends back (may includedocument_idandupload_timestamp, or other fields the client doesn’t send). -
Helps separate concerns and allows for API versioning more easily.
# app/schemas/documents.py
from pydantic import BaseModel, Field, HttpUrl
from typing import Optional
from datetime import datetime
import uuid
​
class DocumentBase(BaseModel):
filename: str = Field(…, max_length=255)
file_extension: str = Field(…, max_length=10)
content_type: str = Field(…, max_length=100)
size_bytes: int = Field(…, ge=0)
checksum: str = Field(…, min_length=32, max_length=64)
uploaded_by: str = Field(…, max_length=50)
storage_uri: HttpUrl
source_application: Optional[str] = Field(None, max_length=50)
​
class DocumentCreate(DocumentBase):
pass # Client sends all base fields
​
class DocumentUpdate(DocumentBase):
filename: Optional[str] = None
# … make other fields optional for update operations
​
class DocumentResponse(DocumentBase):
document_id: uuid.UUID # Generated by DB
upload_timestamp: datetime # Generated by DB
class Config:
from_attributes = True # Pydantic v2 for ORM_MODE (previously Config.orm_mode = True)app/models/ (Database ORM Models):
-
Defines the actual structure of your database tables using your chosen ORM (e.g.,
SQLAlchemydeclarative base classes). -
These closely map to your database schema.
# app/models/documents.py (if using SQLAlchemy)
from sqlalchemy import Column, String, BigInteger, DateTime, UUID
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
import uuid # For default UUID generation if not using DB native
​
Base = declarative_base() # This would typically come from app.core.database
​
class Document(Base):
__tablename__ = "documents"
​
document_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename = Column(String(255), nullable=False)
file_extension = Column(String(10), nullable=False)
content_type = Column(String(100), nullable=False)
size_bytes = Column(BigInteger, nullable=False)
checksum = Column(String(64), nullable=False)
upload_timestamp = Column(DateTime(timezone=True), default=func.now())
uploaded_by = Column(String(50), nullable=False)
storage_uri = Column(String, nullable=False) # Use String for TEXT
source_application = Column(String(50))
Advantages of This Architecture:
-
Separation of Concerns: Each part of the application has a clear responsibility (e.g.,
schemasfor data validation,crudfor DB operations,routersfor API endpoints). -
Maintainability: Easier to understand, debug, and modify specific parts of the codebase without affecting others.
-
Scalability: Allows multiple developers to work on different parts of the API simultaneously without major conflicts. Easily add new features or resources.
-
Testability: Each layer can be tested independently (e.g., unit tests for CRUD functions, integration tests for API endpoints).
-
Flexibility: Easier to swap out components (e.g., change from SQLAlchemy to Tortoise ORM, or change authentication mechanism) with minimal impact on other parts.
-
Readability: The project structure visually guides developers to where specific logic resides.
This structured approach transforms a simple main.py script into a robust and maintainable enterprise-grade FastAPI application.