Retour au cours

backend / fastapi

Routers et organisation de projet

Leçon 81 exercice

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.

DossierContientNe contient jamais
models/Modèles SQLAlchemy (ORM)Logique de validation API
schemas/Modèles Pydantic (validation API)Requêtes SQL
routers/Endpoints, routage HTTPLogique métier complexe
services/Logique métierDé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

text
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
python
# 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
python
# 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)
python
# 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
python
# 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
python
# 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_router découpent une API en modules cohérents par ressource métier.
  • Séparer models (ORM), schemas (Pydantic API) et services (logique métier) évite un couplage excessif.
  • pydantic-settings centralise et valide la configuration issue des variables d'environnement.
  • lifespan remplace les anciens événements startup/shutdown pour le cycle de vie de l'application.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →