Aller au contenu principal

Repository pattern : pourquoi séparer ton accès aux données

Une requête vers la base posée directement dans un contrôleur, puis une variante dans un service, puis une troisième dans un script. Cet article explique pourquoi cette dispersion coûte cher, comment le repository pattern pose une frontière entre métier et stockage, avec du code Python, un cas de cache, et les six pièges qui rendent le pattern inutile.

Architecture logicielle ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

En vous inscrivant, vous acceptez de recevoir notre newsletter. Désinscription possible à tout moment.

La plupart des applications démarrent pareil. Une route, un contrôleur, et une requête vers la base posée juste là, dans la foulée. Ça marche, ça part vite, et personne ne s'en plaint pendant les premières semaines.

Puis le projet grossit. Les appels à la base se retrouvent dispersés dans les contrôleurs, les services, parfois même les vues, et le moindre changement côté données oblige à fouiller partout où le code touche la base.

La cause de cette lourdeur tient en une phrase : la logique métier et l'accès aux données vivent au même endroit, mélangés, sans frontière entre les deux. Le repository pattern sert exactement à poser cette frontière. Voici comment il marche, ce qu'il rapporte, et dans quels cas il devient un poids inutile.

Le problème : l'accès aux données est partout

Prenons une petite API qui gère des utilisateurs. Dans le contrôleur, une requête récupère un compte par son email. Plus loin, dans le service d'inscription, presque la même requête revient avec une condition en plus. Dans un script de migration, encore une variante. Trois endroits, trois façons d'interroger la même table, et aucun des trois ne sait que les deux autres existent.

Tant que le projet tient dans une tête, rien ne fait mal. Le malaise arrive plus tard, et il arrive par plusieurs portes.

Il y a le changement de base de données. Passage de MySQL à PostgreSQL, ajout d'un cache, migration d'un ORM vers un autre : comme les requêtes sont éparpillées, il faut les retrouver une par une. Un endroit oublié, et un bug apparaît en production des semaines plus tard.

Il y a les tests. Une fonction d'inscription qui appelle directement la base exige, pour être testée, une vraie base qui tourne, avec des données préparées et nettoyées à chaque exécution. Les tests deviennent lents et fragiles, et on finit par ne plus les lancer.

Il y a l'arrivée d'un nouveau développeur. En formation, quand je fais lire à un groupe un service qui mélange règles métier et SQL brut, la question qui revient le plus souvent n'est pas technique. C'est "mais où est la règle, là-dedans". L'intention est noyée sous les détails de stockage.

Il y a enfin les incohérences silencieuses. Chaque requête dupliquée est une occasion d'oublier une condition ici, de mettre un filtre différent là. Le jour où une règle change, par exemple "ne plus compter les comptes désactivés", il faut la corriger partout, et un seul oubli suffit pour que deux écrans de la même application affichent des chiffres différents.

Le fil rouge de ces quatre scènes : le code métier sait comment les données sont stockées. Il connaît les noms de tables, le dialecte SQL, la forme exacte de l'ORM. Ce savoir devrait rester enfermé quelque part.

Le principe : une frontière entre ton métier et ta base

Un repository est un objet qui se comporte comme une collection d'entités métier. Tu lui demandes un utilisateur par son identifiant, tu lui en ajoutes un, tu en supprimes un. Tu ne lui parles jamais de tables ni de colonnes. Lui seul sait comment traduire ces demandes en accès réel à la base.

L'idée tient dans une règle de dépendance : ta logique métier dépend d'une interface, pas d'une implémentation. Elle dit "donne-moi l'utilisateur numéro 42" et s'arrête là. Derrière l'interface, tu branches ce que tu veux, une base SQL, un fichier, une API externe, ou un faux objet pour tes tests.

Une image aide à le saisir. Dans une cuisine de restaurant, le serveur transmet la commande mais ne va jamais farfouiller dans le frigo ni allumer les fourneaux lui-même. Il passe par un guichet, et ce qui se trouve derrière, gaz ou induction, frigo neuf ou ancien, ne le regarde pas. Le repository joue ce rôle de guichet entre ta logique métier et ta base : la salle commande, la cuisine exécute, et chacun change ses outils sans déranger l'autre.

Cette séparation applique un principe d'architecture plus large, celui qui veut que le code de haut niveau ne dépende pas des détails de bas niveau. Le sujet est développé sous plusieurs angles dans notre hub des 20 principes de code maintenable.

Ce que tu gagnes se mesure sur trois points, et ils sont liés :

  • Un seul endroit qui sait comment les données sont rangées. Tu changes de base, tu modifies un fichier.
  • Une logique métier testable sans base de données, en remplaçant le vrai repository par une version en mémoire.
  • Un code métier qui se lit comme une intention, sans SQL qui pollue l'histoire.
  • Un point de passage unique pour la journalisation, le cache ou les optimisations de requêtes, le jour où ces besoins arrivent.

Reste une question que la définition ne tranche pas : où placer ces fichiers. La convention la plus lisible sépare le contrat et son implémentation. L'interface se range près du métier, avec les entités qu'elle manipule, puisqu'elle appartient au langage du domaine. Les implémentations, elles, vivent dans un dossier d'infrastructure, à côté de la configuration de la base. Cette organisation rend visible d'un coup d'oeil ce qui dépend de la technique et ce qui n'en dépend pas, et elle évite les discussions sans fin en revue de code.

À noter : le repository ne remplace pas ton ORM, il l'enveloppe. Doctrine, Hibernate et SQLAlchemy continuent de faire le travail lourd. Le repository ajoute une porte d'entrée propre par-dessus, pour que le reste du code n'ait jamais à les voir directement.

À quoi ça ressemble en vrai

Commençons par la version mélangée, celle que tout le monde écrit au départ, en Python. Le service d'inscription parle directement à la base.

# Métier et SQL dans le même bloc
def inscrire(email, mot_de_passe):
    cur = connexion.cursor()
    cur.execute(
        "SELECT id FROM users WHERE email = %s", (email,)
    )
    if cur.fetchone():
        raise ValueError("Email déjà utilisé")
 
    hash = hacher(mot_de_passe)
    cur.execute(
        "INSERT INTO users (email, hash) VALUES (%s, %s)",
        (email, hash),
    )
    connexion.commit()

Cette fonction fait deux métiers à la fois : elle décide des règles d'inscription et elle gère le détail technique du stockage. Pour la tester, il faut une vraie connexion. Version séparée maintenant, en commençant par l'interface, le contrat que le métier connaît.

from abc import ABC, abstractmethod
 
class UserRepository(ABC):
    @abstractmethod
    def par_email(self, email): ...
 
    @abstractmethod
    def ajouter(self, user): ...

Le service d'inscription ne dépend que de ce contrat. Il ignore si les données vivent dans Postgres, dans un fichier JSON ou dans la mémoire vive.

def inscrire(repo: UserRepository, email, mot_de_passe):
    if repo.par_email(email):
        raise ValueError("Email déjà utilisé")
    user = User(email=email, hash=hacher(mot_de_passe))
    repo.ajouter(user)
    return user

Relis cette fonction : elle raconte une histoire claire, sans une ligne de SQL. On y retrouve l'esprit du principe Tell Don't Ask, où l'on demande au repository de faire le travail au lieu de lui arracher ses entrailles. L'implémentation concrète, elle, vit dans sa propre classe.

class PostgresUserRepository(UserRepository):
    def __init__(self, connexion):
        self.connexion = connexion
 
    def par_email(self, email):
        cur = self.connexion.cursor()
        cur.execute(
            "SELECT id, email, hash FROM users WHERE email = %s",
            (email,),
        )
        ligne = cur.fetchone()
        return User(*ligne) if ligne else None
 
    def ajouter(self, user):
        cur = self.connexion.cursor()
        cur.execute(
            "INSERT INTO users (email, hash) VALUES (%s, %s)",
            (user.email, user.hash),
        )
        self.connexion.commit()

Tout le SQL est rassemblé ici, dans un seul fichier. Le jour où tu changes de base, tu écris une nouvelle classe et tu la branches, le service d'inscription ne bouge pas d'une ligne.

Un détail compte dans ce code : le repository renvoie un objet User, pas une ligne brute ni un dictionnaire de colonnes. Cette traduction est l'endroit exact où tu absorbes les différences entre deux bases, deux schémas ou deux versions de ton modèle. Sans elle, ces détails remontent jusque dans ton code métier et le contaminent.

Côté tests, le gain est immédiat. Un faux repository en mémoire suffit, sans aucune base.

class FakeUserRepository(UserRepository):
    def __init__(self):
        self.users = {}
 
    def par_email(self, email):
        return self.users.get(email)
 
    def ajouter(self, user):
        self.users[user.email] = user
 
# Test rapide, sans base, sans réseau
def test_email_deja_pris():
    repo = FakeUserRepository()
    inscrire(repo, "a@b.fr", "secret")
    with pytest.raises(ValueError):
        inscrire(repo, "a@b.fr", "autre")

Ce test tourne en quelques millisecondes et valide la règle métier sans toucher la base. C'est ce découplage qui rend une suite de tests agréable à vivre plutôt qu'un fardeau qu'on évite, comme détaillé dans notre guide sur les tests et le TDD pour développeur débutant.

Le jour où un besoin arrive

L'intérêt de la frontière se voit surtout quand le contexte change. Imagine qu'une page consulte le même utilisateur des dizaines de fois par requête et que la base commence à souffrir. Sans repository, il faut trouver chaque appel et y ajouter un cache, en espérant n'en oublier aucun. Avec un repository, tu écris une classe qui enveloppe l'existant.

class CachedUserRepository(UserRepository):
    def __init__(self, source: UserRepository):
        self.source = source
        self.cache = {}
 
    def par_email(self, email):
        if email not in self.cache:
            self.cache[email] = self.source.par_email(email)
        return self.cache[email]
 
    def ajouter(self, user):
        self.source.ajouter(user)
        self.cache[user.email] = user

Tu branches cette classe à la place de l'autre au démarrage de l'application, et aucun service métier ne s'en rend compte. La même mécanique fonctionne pour ajouter des mesures de performance, une journalisation des accès, ou un basculement temporaire vers une source de secours pendant une migration.

Le pattern voyage bien d'un langage à l'autre. Avec Symfony, Doctrine fournit déjà des classes de repository que tu étends. En Java, Spring Data génère une bonne partie du code à partir d'une simple interface. Ces deux approches sont travaillées dans nos formations Symfony 7 et Spring Boot, et l'équivalent Python dans la formation API REST avec FastAPI.

Les pièges à éviter

Mal employé, le pattern crée autant de problèmes qu'il en résout. Voici les erreurs que je vois revenir le plus souvent chez ceux qui le découvrent.

Le repository passe-plat. Une classe qui rappelle les méthodes de l'ORM une par une, sans rien ajouter, empile une couche qui complique la lecture sans protéger quoi que ce soit. Un repository mérite son existence quand il cache une vraie complexité d'accès, pas quand il recopie l'API du framework.

Le repository générique unique. À mon avis, c'est l'erreur la plus coûteuse de la liste, et pourtant la plus tentante. Un seul Repository<T> pour toutes les entités produit une boîte fourre-tout qui expose des méthodes valables pour personne. Préfère un repository par concept métier, avec les méthodes que ce concept réclame, du type par_email ou commandes_en_attente.

La logique métier qui fuit dans le repository. Ranger et retrouver des données, oui. Décider des règles, non. Des calculs de prix ou des conditions d'autorisation glissés dans un repository recréent le mélange que tu voulais éviter, à un autre endroit.

L'abstraction trop tôt. Sur un script de cinquante lignes ou un prototype jetable, monter une interface et trois implémentations coûte plus que ça ne rapporte. Le bon réflexe consiste à attendre le second cas d'usage qui réclame la même donnée, moment où la frontière devient utile plutôt que décorative.

Les requêtes qui débordent. Méfie-toi du repository qui renvoie une entité complète juste pour lire un champ, ou qui charge mille lignes pour en garder dix. Une interface élégante peut cacher une requête désastreuse, et la séparation propre ne dispense jamais de regarder ce que ta base exécute réellement.

Le repository qui laisse fuiter l'ORM. Une méthode qui renvoie un objet de requête Doctrine, ou un QuerySet Django que l'appelant complète ensuite, annule tout le bénéfice. Le framework traverse la frontière et se retrouve dans le métier, avec ses conventions et ses pièges. Une méthode de repository renvoie une entité ou une liste d'entités, rien d'autre.

Une règle simple pour arbitrer : garde toujours dans des méthodes distinctes ce qui lit des données et ce qui modifie l'état. C'est l'hygiène du principe Command Query Separation, appliquée à ta couche de données.

Questions fréquentes

Le repository pattern n'est-il pas redondant avec un ORM comme Doctrine ou SQLAlchemy ?

Non, les rôles diffèrent. L'ORM traduit tes objets en lignes de base de données. Le repository cache l'ORM derrière une interface métier, pour que le reste du code ne dépende pas du framework. Tu peux ainsi changer d'ORM ou ajouter un cache devant sans toucher à ta logique. Doctrine fournit d'ailleurs sa propre notion de repository pour cette raison.

À partir de quelle taille de projet ça vaut le coup ?

Le signal n'est pas la taille en lignes mais la présence d'une logique métier qui compte et d'un besoin de tests fiables. Une application avec des règles d'inscription, de facturation ou d'autorisation à valider sans base rentabilise vite le pattern. Pour un script jetable ou un prototype, un accès direct reste plus honnête.

Faut-il un repository par table ou par entité métier ?

Par entité métier. Une entité peut s'étaler sur plusieurs tables, et un repository doit refléter un concept que ton métier comprend, du genre Commande ou Utilisateur. Un repository par table ramène vers le détail technique que tu cherchais à cacher.

Comment tester ma logique sans base de données grâce au repository ?

Écris une implémentation en mémoire de ton interface, qui stocke les entités dans un dictionnaire ou une liste. Ta logique métier reçoit ce faux repository à la place du vrai. Les tests ne dépendent alors ni d'une base ni du réseau, ce qui permet de les lancer à chaque modification sans attendre.

Le repository pattern ralentit-il l'application ?

L'abstraction en elle-même n'a pas de coût mesurable, il s'agit d'un appel de méthode de plus. Les soucis de performance viennent des requêtes mal écrites ou des chargements excessifs, qui existent avec ou sans repository. Le pattern offre même un endroit unique pour optimiser ces requêtes quand le besoin se présente.

Quelle différence avec le Data Access Object (DAO) ?

Les deux séparent l'accès aux données, mais à des niveaux différents. Le DAO colle souvent à une table et expose des opérations proches de la base. Le repository se place plus haut, du côté du métier, et raisonne en collection d'entités. En pratique, un repository peut s'appuyer sur un ou plusieurs DAO pour faire son travail.

Si tu veux un point de départ ce soir, prends la requête que tu as dupliquée le plus souvent dans ton projet actuel et donne-lui un seul endroit où vivre. Le reste suivra, ou pas, selon ce que ton code te réclame ensuite.

Crédits images : macrovector sur Magnific Tous les crédits

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous