backend / fastapi
Routers et organisation de projet
Explication
Ce que vous allez apprendre
- Découper une application FastAPI en routers organisés par ressource métier
- Séparer clairement le modèle SQLAlchemy (ORM) du modèle Pydantic (validation API)
- Isoler la logique métier dans une couche services, distincte des endpoints
- Centraliser la configuration de l'application avec
pydantic-settings - Gérer le cycle de vie de l'application (démarrage/arrêt) avec
lifespan
Dans quel contexte ?
Après six mois de développement, app/main.py contient 45 routes différentes dans un seul fichier de 800 lignes, mélangeant utilisateurs, produits et commandes. Deux développeurs qui travaillent en même temps sur des fonctionnalités différentes provoquent des conflits Git à chaque fusion, car ils modifient tous les deux ce même fichier. Découper main.py en routers/users.py, routers/products.py et routers/orders.py, chacun avec son propre APIRouter, élimine ces conflits et rend le projet immédiatement navigable.
Le problème, d'abord
Un fichier main.py unique contenant toutes les routes fonctionne très bien... pour dix routes. Passé ce seuil, il devient difficile à naviguer.
Les fusions Git entre collègues travaillant sur des fonctionnalités différentes provoquent alors des conflits constants. Il devient aussi impossible de savoir d'un coup d'œil quelles routes concernent quelle partie métier.
APIRouter répond exactement à ce problème. C'est un mini sous-ensemble d'application FastAPI, qui regroupe des routes liées à une même ressource avec un préfixe et des tags communs.
Il se "branche" ensuite sur l'application principale via include_router. Cette organisation par domaine métier, et non par type technique, reflète directement la façon dont on pense l'application.
Une fois les routes organisées, une confusion fréquente reste à éviter : mélanger le modèle de base de données et le modèle de validation API. Ce sont deux préoccupations différentes, qui évoluent à des rythmes différents.
L'ORM décrit comment la donnée est stockée, Pydantic décrit ce qu'un client envoie ou reçoit. Les séparer clairement évite qu'un changement de l'un force à modifier l'autre sans raison.
De même, la logique métier mérite sa propre couche, séparée des endpoints. Les endpoints ne devraient faire que router la requête vers cette logique, rien de plus.
Une fois cette organisation en place, il reste un dernier mécanisme à connaître : le cycle de vie de l'application. lifespan remplace les anciens événements startup/shutdown.
Il permet d'exécuter du code au démarrage, comme ouvrir un pool de connexions, et à l'arrêt, pour le fermer proprement. Ce point deviendra concret dans les leçons suivantes sur les bases de données.
| Dossier | Contient | Ne contient jamais |
|---|---|---|
models/ | Modèles SQLAlchemy (ORM) | Logique de validation API |
schemas/ | Modèles Pydantic (validation API) | Requêtes SQL |
routers/ | Endpoints, routage HTTP | Logique métier complexe |
services/ | Logique métier | Détails HTTP (status codes, headers) |
Piège fréquent
Oublier de préfixer les routers de manière cohérente, comme /api/v1, rend le versionnement de l'API beaucoup plus douloureux une fois qu'elle a des utilisateurs externes qui dépendent d'une URL stable.
Commandes & code
Routers et organisation de projet
app/
├── main.py
├── core/
│ ├── config.py
│ ├── database.py
│ └── security.py
├── models/ # modèles SQLAlchemy (ORM)
│ ├── user.py
│ └── product.py
├── schemas/ # modèles Pydantic (validation API)
│ ├── user.py
│ └── product.py
├── routers/ # endpoints, découpés par ressource
│ ├── users.py
│ └── products.py
├── services/ # logique métier
│ └── user_service.py
└── dependencies.py # dépendances FastAPI partagées# app/routers/products.py
from fastapi import APIRouter, Depends, HTTPException, status
from typing import Annotated
from app.schemas.product import ProductCreate, ProductOut
from app.dependencies import get_db, get_current_user
router = APIRouter(prefix="/products", tags=["products"])
@router.get("", response_model=list[ProductOut])
def list_products(db: Annotated["Session", Depends(get_db)]):
return db.query(Product).all()
@router.get("/{product_id}", response_model=ProductOut)
def get_product(product_id: int, db: Annotated["Session", Depends(get_db)]):
product = db.get(Product, product_id)
if not product:
raise HTTPException(status_code=404, detail="Produit introuvable")
return product
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(
data: ProductCreate,
db: Annotated["Session", Depends(get_db)],
current_user: Annotated["User", Depends(get_current_user)],
):
product = Product(**data.model_dump(), owner_id=current_user.id)
db.add(product)
db.commit()
db.refresh(product)
return product# app/main.py — assemblage final, versionnement d'API
from fastapi import FastAPI
from app.routers import users, products, orders
app = FastAPI(title="Mon API", version="1.0.0")
API_V1_PREFIX = "/api/v1"
app.include_router(users.router, prefix=API_V1_PREFIX)
app.include_router(products.router, prefix=API_V1_PREFIX)
app.include_router(orders.router, prefix=API_V1_PREFIX)# app/core/config.py — configuration centralisée avec pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str
secret_key: str
debug: bool = False
cors_origins: list[str] = ["http://localhost:3000"]
settings = Settings() # chargé une seule fois, importable partout# app/routers/orders.py — router avec sous-router imbriqué (versionnement de sous-ressource)
from fastapi import APIRouter
router = APIRouter(prefix="/orders", tags=["orders"])
items_router = APIRouter(prefix="/{order_id}/items", tags=["order-items"])
@items_router.get("")
def list_order_items(order_id: int):
return {"order_id": order_id, "items": []}
router.include_router(items_router) # GET /orders/{order_id}/items# Lifespan — code exécuté au démarrage/arrêt de l'application
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
print("Démarrage : connexion aux ressources externes...")
yield
print("Arrêt : fermeture propre des ressources...")
app = FastAPI(lifespan=lifespan)Résumé
APIRouter+include_routerdécoupent une API en modules cohérents par ressource métier.- Séparer
models(ORM),schemas(Pydantic API) etservices(logique métier) évite un couplage excessif. pydantic-settingscentralise et valide la configuration issue des variables d'environnement.lifespanremplace les anciens événementsstartup/shutdownpour le cycle de vie de l'application.
Exercices pratiques
Mission : le fichier main.py de 800 lignes qui bloque toute l'équipe
Objectif : Découper un main.py monolithique en routers par ressource et corriger un mélange dangereux entre modèle ORM et modèle de sortie API.
Contexte
app/main.py contient 45 routes pour les utilisateurs, produits et commandes, dans un seul fichier de 800 lignes. Deux développeurs qui modifient ce fichier en parallèle provoquent des conflits Git à chaque fusion. De plus, l'endpoint GET /users/{id} retourne directement l'objet SQLAlchemy User, exposant sans le vouloir la colonne hashed_password de la table.