Avancé

Architecture Backend

Construire une API, ce n'est pas simplement écrire des routes. Une architecture en couches permet de séparer les responsabilités et de rendre le code maintenable et testable.

Vue d'ensemble

Le traitement d'une requête suit un parcours à travers plusieurs couches :

Chargement du diagramme…

Chaque couche a un rôle précis et ne connaît que la couche immédiatement en dessous. Le controller ne parle jamais directement à la base de données, le service ne connaît pas HTTP.

Le Router

Le router est le point d'entrée des requêtes HTTP. Il associe un couple Méthode + URL à une fonction.

Avec Flask, ce rôle est joué par les Blueprints et les décorateurs :

python
from flask import Blueprint

# Création du Blueprint (module de routes)
books_bp = Blueprint('books', __name__, url_prefix='/api/books')

# Le décorateur associe GET /api/books à la fonction get_books
@books_bp.route('', methods=['GET'])
def get_books():
    # ...
    pass

# Le décorateur associe GET /api/books/:id à la fonction get_book
@books_bp.route('/<int:book_id>', methods=['GET'])
def get_book(book_id: int):
    # ...
    pass

# Enregistrement du Blueprint dans l'application
app = Flask(__name__)
app.register_blueprint(books_bp)

Organisation des Blueprints

En Flask, il est courant d'avoir un Blueprint par ressource (ou par domaine fonctionnel). Cela permet de structurer l'application en modules indépendants :

routes/
├── __init__.py
├── books.py       # Blueprint pour /api/books
├── authors.py     # Blueprint pour /api/authors
└── users.py       # Blueprint pour /api/users

Le Controller

Le controller reçoit la requête du router. Son rôle est de :

  1. Récupérer les données de la requête (body, params, query)
  2. Valider ces données (avec Pydantic)
  3. Vérifier l'authentification et l'autorisation
  4. Appeler le service
  5. Formater et renvoyer la réponse (body + code de statut)
python
from flask import request, jsonify
from http import HTTPStatus
from pydantic import BaseModel, ValidationError

class CreateBookDTO(BaseModel):
    title: str
    author: str
    year: int

@books_bp.route('', methods=['POST'])
def create_book():
    """Controller : création d'un livre"""

    # 1. Vérifier l'authentification
    token = request.headers.get('Authorization')
    if not token:
        return jsonify({"error": "Authentification requise"}), HTTPStatus.UNAUTHORIZED

    user = auth_service.verify_token(token)
    if not user:
        return jsonify({"error": "Token invalide"}), HTTPStatus.UNAUTHORIZED

    # 2. Récupérer et valider les données
    data = request.get_json()
    try:
        dto = CreateBookDTO(**data)
    except ValidationError as e:
        return jsonify({"error": "Données invalides", "details": e.errors()}), HTTPStatus.BAD_REQUEST

    # 3. Appeler le service
    new_book = book_service.create(dto, created_by=user.id)

    # 4. Formater et renvoyer la réponse
    return jsonify(new_book), HTTPStatus.CREATED

Le controller ne contient pas de logique métier

Le controller coordonne, il ne calcule pas. Si vous écrivez des if/else complexes ou des transformations de données dans un controller, c'est que cette logique devrait être dans le service.

Les DTOs (Data Transfer Objects)

Les DTOs sont des objets dédiés au transport de données entre les couches. Avec Pydantic, ils assurent à la fois le typage et la validation :

python
from pydantic import BaseModel, field_validator
from typing import Optional

class CreateBookDTO(BaseModel):
    """DTO pour la création d'un livre"""
    title: str
    author: str
    year: int
    isbn: Optional[str] = None

    @field_validator('year')
    @classmethod
    def year_must_be_valid(cls, v: int) -> int:
        if v < 0 or v > 2030:
            raise ValueError("L'année doit être comprise entre 0 et 2030")
        return v

    @field_validator('title')
    @classmethod
    def title_must_not_be_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("Le titre ne peut pas être vide")
        return v.strip()

Quel est le rôle principal du controller ?

Le Service

Le service contient la logique métier de l'application. Il centralise les traitements indépendants de la couche HTTP.

Règles importantes :

  • Le service ne connaît pas HTTP : il ne manipule ni request, ni response, ni codes de statut
  • Il reçoit des données typées (DTOs ou entités) et retourne des résultats
  • Il effectue les transformations de données nécessaires avant la persistance
python
from typing import Optional

class BookService:
    def __init__(self, book_repository: BookRepository):
        self.book_repository = book_repository

    def create(self, dto: CreateBookDTO, created_by: int) -> dict:
        """Créer un livre avec les règles métier"""
        # Vérifier si un livre avec le même ISBN existe déjà
        if dto.isbn:
            existing = self.book_repository.find_by_isbn(dto.isbn)
            if existing:
                raise DuplicateISBNError(f"ISBN {dto.isbn} déjà utilisé")

        # Transformer le DTO en données de persistance
        book_data = {
            "title": dto.title,
            "author": dto.author,
            "year": dto.year,
            "isbn": dto.isbn,
            "created_by": created_by,
        }

        # Déléguer la persistance au repository
        return self.book_repository.create(book_data)

    def get_by_id(self, book_id: int) -> Optional[dict]:
        """Récupérer un livre par son identifiant"""
        return self.book_repository.find_by_id(book_id)

    def find_all(self, offset: int = 0, limit: int = 20) -> list[dict]:
        """Récupérer une liste paginée de livres"""
        return self.book_repository.find_all(offset=offset, limit=limit)

    def delete(self, book_id: int) -> bool:
        """Supprimer un livre"""
        book = self.book_repository.find_by_id(book_id)
        if not book:
            return False
        self.book_repository.delete(book_id)
        return True

Le service ne retourne jamais de code HTTP

Le service retourne un résultat (un objet, une liste, un booléen) ou lève une exception. C'est le controller qui traduit ce résultat en code HTTP. Cette séparation permet de réutiliser le service dans d'autres contextes (CLI, tâches planifiées, tests).

Le Repository

Le repository assure la persistance des objets en base de données. Son rôle est d'isoler la couche de persistance : l'idée est de pouvoir changer de moteur de base de données sans impacter le reste de l'application.

python
class BookRepository:
    def __init__(self, db):
        self.db = db

    def find_all(self, offset: int = 0, limit: int = 20) -> list[dict]:
        """Récupérer tous les livres avec pagination"""
        cursor = self.db.execute(
            "SELECT * FROM books ORDER BY id LIMIT ? OFFSET ?",
            (limit, offset)
        )
        return [dict(row) for row in cursor.fetchall()]

    def find_by_id(self, book_id: int) -> Optional[dict]:
        """Récupérer un livre par son ID"""
        cursor = self.db.execute(
            "SELECT * FROM books WHERE id = ?",
            (book_id,)
        )
        row = cursor.fetchone()
        return dict(row) if row else None

    def find_by_isbn(self, isbn: str) -> Optional[dict]:
        """Récupérer un livre par son ISBN"""
        cursor = self.db.execute(
            "SELECT * FROM books WHERE isbn = ?",
            (isbn,)
        )
        row = cursor.fetchone()
        return dict(row) if row else None

    def create(self, data: dict) -> dict:
        """Créer un livre en base de données"""
        cursor = self.db.execute(
            """INSERT INTO books (title, author, year, isbn, created_by)
               VALUES (?, ?, ?, ?, ?)""",
            (data['title'], data['author'], data['year'],
             data.get('isbn'), data['created_by'])
        )
        self.db.commit()
        return self.find_by_id(cursor.lastrowid)

    def delete(self, book_id: int) -> None:
        """Supprimer un livre"""
        self.db.execute("DELETE FROM books WHERE id = ?", (book_id,))
        self.db.commit()

Pourquoi isoler la persistance dans un repository plutôt que d'écrire les requêtes SQL directement dans le service ?

Le Mapper

Le mapper convertit les données d'un format à un autre :

  • DTO → DAO : données de requête vers données de persistance
  • DAO → DTO : données de base vers données de réponse

C'est une forme de service spécialisé dans la conversion de format.

python
class BookMapper:
    @staticmethod
    def dto_to_entity(dto: CreateBookDTO, created_by: int) -> dict:
        """Convertir un DTO en données de persistance"""
        return {
            "title": dto.title,
            "author": dto.author,
            "year": dto.year,
            "isbn": dto.isbn,
            "created_by": created_by,
        }

    @staticmethod
    def entity_to_response(entity: dict) -> dict:
        """Convertir une entité en réponse API"""
        return {
            "id": entity["id"],
            "title": entity["title"],
            "author": entity["author"],
            "year": entity["year"],
            "isbn": entity.get("isbn"),
        }

Structure de fichiers complète

Voici comment organiser un projet Flask avec cette architecture :

my_api/
├── app.py                  # Point d'entrée Flask
├── routes/                 # Routers (Blueprints)
│   ├── __init__.py
│   └── books.py
├── controllers/            # Controllers (logique HTTP)
│   ├── __init__.py
│   └── book_controller.py
├── services/               # Services (logique métier)
│   ├── __init__.py
│   └── book_service.py
├── repositories/           # Repositories (persistance)
│   ├── __init__.py
│   └── book_repository.py
├── dtos/                   # DTOs (Pydantic models)
│   ├── __init__.py
│   └── book_dto.py
├── mappers/                # Mappers
│   ├── __init__.py
│   └── book_mapper.py
└── config.py               # Configuration

En pratique avec Flask

Avec Flask, le router et le controller sont souvent fusionnés dans le même fichier (la fonction décorée fait office de controller). C'est acceptable pour des projets de taille modeste. Lorsque le projet grossit, il est recommandé de séparer la logique de routage de la logique de contrôle.

À retenir

Points clés

  • L'architecture en couches sépare les responsabilités : Router → Controller → Service → Repository
  • Le Router (Blueprint Flask) associe des URL à des fonctions
  • Le Controller valide les données, gère l'authentification et formate la réponse
  • Le Service contient la logique métier, indépendamment de HTTP
  • Le Repository isole la persistance en base de données
  • Le Mapper convertit les données entre les couches (DTO ↔ DAO)
  • Les DTOs (Pydantic) assurent la validation et le typage des données entrantes