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 :
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 :
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 :
- Récupérer les données de la requête (body, params, query)
- Valider ces données (avec Pydantic)
- Vérifier l'authentification et l'autorisation
- Appeler le service
- Formater et renvoyer la réponse (body + code de statut)
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 :
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, niresponse, 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
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.
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.
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