FastAPI Project Structure & Patterns
When to Use This Skill
- Scaffolding a new route, service, component, config concern, or startup step in a FastAPI project
- Deciding which directory/file a piece of logic belongs in (
routes/vsservices/vsdb/vsstartops/) - Reviewing whether an existing FastAPI project follows consistent structure and naming
- Adding SSE streaming endpoints, dependency injection, or error-handling to a route
Dependencies
| Package | Role | Required |
|---|---|---|
fastapi |
Web framework — routing, DI, StreamingResponse |
Yes |
pydantic v2 |
BaseModel request/response models, Field, alias_generators.to_camel |
Yes |
pydantic-settings |
BaseSettings config classes (see references/configs.md) |
Yes |
sqlalchemy (async, 2.0-style Mapped/mapped_column) |
ORM layer (db/) |
Yes, if using a database |
asyncpg / aiosqlite |
Async DB drivers for Postgres / SQLite respectively | Yes, matching your DB_TYPE |
python-dotenv |
Loads the .config env file at startup (startops/set_env.py) |
Yes |
sse-starlette |
Alternative SSE response wrapper (see references/routes.md) |
Optional |
uvicorn |
ASGI server to run the app | Yes, for local/dev running |
This skill documents conventions on top of these libraries, not the libraries themselves — see each library's own docs for API details.
Project Layout
<package>/
├── app.py # FastAPI app factory + lifespan
├── _types.py # Cross-cutting domain models (shared across all layers)
│
├── configs/ # Pydantic Settings — split by concern
│ ├── __init__.py # Instantiates & exports the merged config singleton
│ ├── <name>_conf.py # One file per concern: deployment, db, feature, logging
│ └── <main>_conf.py # Merges all via multiple inheritance → single config class
│
├── db/ # ORM models ONLY — no logic here
│ ├── __init__.py # Re-exports all ORM classes
│ ├── base.py # DeclarativeBase (one line)
│ ├── config.py # Config-domain ORM models
│ └── state.py # Runtime-state ORM models
│
├── services/ # Business logic singletons
│ ├── base_db_service.py # Shared async SQLAlchemy base class
│ └── <service_name>/ # One subdirectory per service
│ ├── __init__.py # Re-exports with module docstring
│ ├── main.py # Service class + get_*/set_* singleton functions
│ └── schema.py # Pydantic domain/response models for this service
│
├── routes/ # FastAPI route handlers
│ ├── __init__.py # Aggregates all sub-routers into one api_router
│ └── <domain>/ # One subdirectory per route domain
│ ├── __init__.py # Exports router
│ ├── views.py # APIRouter + route handler functions
│ ├── models.py # Request & Response Pydantic models
│ └── runner.py # (optional) Re-exports dependencies needed by views
│
├── startops/ # Startup & shutdown discrete steps
│ ├── __init__.py # Re-exports all setup/shutdown functions
│ ├── set_*.py # Synchronous setup: env vars, loggers, timezone, warnings
│ ├── setup_*.py # Async setup: DB, app manager, external services
│ └── shutdown_setup.py # Graceful shutdown + signal handlers
│
├── apps/ # Application runners (agentic / non-agentic)
│ ├── base.py # BaseApp abstract class
│ ├── builder.py # AppBuilder factory — dispatches to correct app type
│ ├── <type>_app.py # Concrete app implementations
│ ├── services/ # Shared helpers used across components
│ └── component/ # Pipeline components
│ ├── base.py # BaseComponent abstract class
│ └── <component_name>/ # One subdirectory per component
│ ├── __init__.py
│ ├── component.py # Component logic
│ ├── builder.py # Builder classmethod factory
│ └── schema.py # Component-specific domain models
│
└── common/ # Shared utilities — not domain-specific
File Naming Conventions
Every file name signals its role. Always follow these names:
| File | Role |
|---|---|
views.py |
FastAPI APIRouter + all route handler functions for a domain |
models.py |
Request & Response Pydantic models scoped to a route module |
main.py |
Service class implementation + singleton getter/setter |
schema.py |
Pydantic domain models / response schemas for a service or component |
builder.py |
@classmethod build(...) factory — no __init__ instantiation |
component.py |
Component business logic class |
base.py |
Abstract base class for a layer (BaseApp, BaseComponent, Base ORM) |
runner.py |
Re-exports of dependencies/singletons needed by route views.py |
_types.py |
Cross-cutting domain dataclasses/models shared across multiple layers |
set_*.py |
Synchronous startup step (env, loggers, timezone, warnings) |
setup_*.py |
Async startup step (DB, managers, external services) |
shutdown_setup.py |
Graceful shutdown + OS signal handlers |
Layer Responsibilities
configs/ — Configuration
- Split config into focused
BaseSettingssubclasses, one file per concern - Merge all via multiple inheritance into one
MainConfigclass - Instantiate once in
__init__.pyas a module-level singleton
# configs/main_conf.py
class AppConfig(DeploymentConfig, LoggingConfig, DatabaseConfig, FeatureConfig):
model_config = SettingsConfigDict(env_file=".config", frozen=True, extra="ignore")
# configs/__init__.py
from .main_conf import AppConfig
app_conf = AppConfig() # ← single instantiation point
__all__ = ("AppConfig", "app_conf")
→ Full patterns, @property helpers, adding new concerns: references/configs.md
db/ — ORM Models Only
base.pycontains onlyDeclarativeBase— nothing else- Split ORM models into files by domain concern (not by table count)
- No business logic, no queries — just schema definitions
__init__.pyre-exports every ORM class
# db/base.py
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase): ...
# db/__init__.py
from .base import Base
from .config import AppConfigOrm, AgentConfigOrm
from .state import SessionOrm, UserStateOrm
__all__ = ("Base", "AppConfigOrm", "AgentConfigOrm", "SessionOrm", "UserStateOrm")
→ Full ORM anatomy, JSONB patterns, indexes, flag_modified, state vs config tables: references/db-orm.md
services/<name>/ — Service Singletons
Each service is a package (subdirectory), not a flat file.
main.pyholds the service class + two functions:get_<name>()andset_<name>()schema.pyholds all Pydantic / dataclass models the service owns__init__.pyre-exports the public API with a module-level docstring
# services/session/main.py
_session_service: SessionService | None = None
def get_session_service() -> SessionService:
if _session_service is None:
raise RuntimeError("SessionService not initialized")
return _session_service
def set_session_service(service: SessionService) -> None:
global _session_service # noqa: PLW0603
_session_service = service
set_*is called only fromstartops/setup_*.py— never from route handlersget_*is used everywhere else (routes, other services, apps)
→ Full service class, BaseDBService, schema.py dataclasses, keyword-only args: references/services.md
routes/<domain>/ — Route Handlers
Each route domain is a package with exactly these files:
routes/
├── __init__.py ← aggregates all routers into api_router
└── sessions/
├── __init__.py ← exports only: router
├── views.py ← APIRouter + handlers
├── models.py ← Request/Response Pydantic models
└── runner.py ← (optional) dependency re-exports
routes/__init__.py — aggregate all routers, no other logic:
from fastapi.routing import APIRouter
from <package>.routes import chat, config, health, sessions
api_router = APIRouter()
api_router.include_router(config.router)
api_router.include_router(sessions.router)
api_router.include_router(chat.router)
api_router.include_router(health.router)
Mounted globally in app.py under /api/v1:
def add_routers(app: FastAPI) -> None:
from <package>.routes import api_router # ruff: noqa: PLC0415
app.include_router(router=api_router, prefix="/api/v1")
→ Full CRUD patterns, SSE streaming, Annotated DI, error handling, logging rules: references/routes.md
startops/ — Startup & Shutdown Steps
set_*.py= synchronous (env, loggers, tz, warnings) — called inadd_startup_ops()setup_*.py= async (DB, managers, external services) — called insidelifespan()- Startup order is strict — each step depends on the previous
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
# ruff: noqa: PLC0415
from <package>.startops import graceful_shutdown, setup_db_service, setup_agent_manager, setup_mcp_connection
await setup_db_service() # 1. DB first
await setup_agent_manager() # 2. cache — depends on DB
if not await setup_mcp_connection():
raise RuntimeError("MCP server unavailable") # 3. fail fast
yield
await graceful_shutdown() # 4. reverse-order teardown
→ Full set_* / setup_* implementations, graceful_shutdown, emergency_cleanup, atexit: references/startops.md
apps/component/<name>/ — Pipeline Components
Each component is a package with exactly:
component.py— the class, extendsBaseComponent, implementsrun_async()builder.py—@classmethod build(cls, config) -> ComponentTypeschema.py— Pydantic models this component produces/consumes__init__.py— re-exports
Builder pattern — always a @classmethod, never an instance method:
class TransactionPosterBuilder:
@classmethod
async def build(cls, config: AppConfig) -> TransactionPoster:
return TransactionPoster(...)
# Usage — never instantiate the builder
poster = await TransactionPosterBuilder.build(config)
Rules
- Never put business logic in
db/— ORM models only, no queries - Never instantiate singletons in route handlers — use
get_*()functions set_*()is called only fromstartops/— never from routes or servicesbuilder.pyalways exposes a@classmethod build()— never instantiate buildersrunner.pyis only for re-exporting dependencies into a route module — no logic- Inline imports in
app.pyandstartops/are intentional — suppress with# ruff: noqa: PLC0415 __init__.pyin every package re-exports the public API — nothing else_types.py(underscore prefix) signals cross-cutting types shared across layersschema.pyvsmodels.py:schema.py= service/component domain types;models.py= HTTP request/response types scoped to one route module
Templates
Copy-paste starting points for the layers scaffolded most often — don't write these files from scratch. Copy the skeleton, rename item/Item/items to your actual domain, then fill in the logic.
| Scaffolding | Files |
|---|---|
| New route domain | templates/routes/ — __init__.py (aggregator) + items/{__init__,views,models,runner}.py |
| New service | templates/services/item/ — {__init__,main,schema}.py |
| New ORM base | templates/db/base.py |
| New pipeline component | templates/component/ — {__init__,component,builder,schema}.py |
| New config concern | templates/configs/main_conf_example.py |
| New startup step | templates/startops/setup_example.py |
References
| Topic | File |
|---|---|
Config singleton, BaseSettings split, @property helpers |
references/configs.md |
ORM anatomy, JSONB, indexes, flag_modified, state vs config tables |
references/db-orm.md |
| Route handlers, models, SSE streaming, DI, error handling | references/routes.md |
Service class, BaseDBService, singleton pattern, schema.py |
references/services.md |
set_* vs setup_*, lifespan ordering, graceful/emergency shutdown |
references/startops.md |