Skip to Content
HeadGym PABLO
ContentEngineering BlogAnatomy Of A Well Organised FastAPI Application

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., POST data).

  • 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 app from app.main and serves as the entry point for uvicorn.

  • Example: from app.main import app

app/main.py:

  • Initializes the FastAPI() application instance.

  • Includes all APIRouter instances from app.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’s BaseSettings to 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., SQLAlchemy engine and sessionmaker). It often provides a get_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 an APIRouter instance.

  • 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_document
  • deps.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_document

app/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 a POST request (e.g., document_id and upload_timestamp might be excluded).

  • DocumentResponse: What the API sends back (may include document_id and upload_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., SQLAlchemy declarative 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., schemas for data validation, crud for DB operations, routers for 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.

Last updated on