Retour au blog
mai 2026·4 min de lectureGénéré par IA
Langue:DeutschEnglishFrançaisÖsterreichischKlingonian Version originale: English

Le patron Repository : avantages, inconvénients et quand s'en passer

#architecture#design-patterns#backend

Le patron repository est recommandé par défaut dans beaucoup de tutoriels de backend, juste à côté de l'injection de dépendances, comme si s'en passer était manifestement une erreur. Ce n'est pas le cas. C'est un patron réellement utile pour un ensemble précis de problèmes, et un surcoût réellement inutile pour beaucoup d'applications qui l'adoptent quand même par habitude.

Ce que c'est réellement

Un repository est une abstraction entre votre logique métier/domaine et votre code d'accès aux données. Au lieu que votre couche de service appelle un ORM ou écrive du SQL directement, elle appelle une interface de repository, et le repository est la seule chose qui sait comment les données sont réellement stockées.

interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

Une implémentation concrète pourrait utiliser un ORM :

class PostgresUserRepository implements UserRepository {
  constructor(private db: Pool) {}

  async findById(id: string): Promise<User | null> {
    const { rows } = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
    return rows[0] ? mapRowToUser(rows[0]) : null;
  }

  async findByEmail(email: string): Promise<User | null> {
    const { rows } = await this.db.query('SELECT * FROM users WHERE email = $1', [email]);
    return rows[0] ? mapRowToUser(rows[0]) : null;
  }

  async save(user: User): Promise<void> {
    await this.db.query(
      'INSERT INTO users (id, email, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2, name = $3',
      [user.id, user.email, user.name],
    );
  }
}

Votre code de service ne dépend que de UserRepository, jamais de Pool ni de SQL directement :

class RegisterUserService {
  constructor(private users: UserRepository) {}

  async register(email: string, name: string): Promise<User> {
    const existing = await this.users.findByEmail(email);
    if (existing) throw new Error('email already registered');

    const user = new User(crypto.randomUUID(), email, name);
    await this.users.save(user);
    return user;
  }
}

L'argumentaire en sa faveur

La testabilité sans vraie base de données. Une implémentation factice de UserRepository est triviale à écrire et ne demande ni conteneurs de test, ni migrations, ni réseau :

class InMemoryUserRepository implements UserRepository {
  private users = new Map<string, User>();

  async findById(id: string) {
    return this.users.get(id) ?? null;
  }

  async findByEmail(email: string) {
    return [...this.users.values()].find((u) => u.email === email) ?? null;
  }

  async save(user: User) {
    this.users.set(user.id, user);
  }
}

Les tests de RegisterUserService s'exécutent en millisecondes et n'ont besoin d'aucune infrastructure.

Une vraie couture quand le stockage change réellement. Si vous migrez de Postgres vers DynamoDB, ou ajoutez une couche de cache devant les lectures, l'interface du repository est le seul endroit qui change : tous les appelants restent intacts, car ils n'ont jamais dépendu du mécanisme de stockage.

Un endroit unique pour imposer les conventions de requêtes. Filtrage de suppression logique, cloisonnement par locataire dans un système multi-locataire, mappage cohérent des colonnes : les centraliser dans le repository fait que chaque appelant les obtient automatiquement, au lieu que chaque site d'appel doive s'en souvenir.

L'argumentaire contre

C'est une abstraction pour un changement qui n'arrive presque jamais. En pratique, très peu d'applications changent réellement de moteur de base de données. Le plus grand argument de vente du patron repository (« vous pouvez changer la persistance sans toucher à la logique métier ») est une assurance contre un événement qui, pour la plupart des projets, ne se produit jamais. Vous en payez le coût (une interface de plus, une implémentation de plus, une couche de plus à parcourir en lisant le code) chaque jour pour un bénéfice que vous ne toucherez peut-être jamais.

Les ORM modernes vous isolent déjà du SQL brut. Une partie de la motivation d'origine du patron repository était de garder le SQL hors de la logique métier. Le query builder d'un ORM le fait déjà. Envelopper un appel d'ORM dans une méthode de repository qui se contente de le transmettre ajoute une couche sans ajouter d'isolation que vous n'aviez pas déjà :

// This "repository" method isn't abstracting anything the ORM wasn't already abstracting.
async findById(id: string) {
  return this.db.user.findUnique({ where: { id } });
}

Il peut cacher la vraie requête exécutée derrière un nom qui n'indique pas son coût. orderRepository.findActiveWithLineItems() a l'air innocent au site d'appel. Savoir s'il s'agit d'une requête indexée ou d'une boucle N+1 sur les lignes de commande reste invisible tant que l'on n'a pas lu l'implémentation : l'abstraction censée faciliter le raisonnement sur le code finit par rendre les caractéristiques de performance plus difficiles à voir.

La plupart des suites de tests n'ont pas vraiment besoin de remplacer la base de données. Les conteneurs de test qui font tourner un vrai Postgres dans Docker sont devenus assez rapides et fiables pour que « tester sans vraie base » soit un argument plus faible qu'avant ; et tester contre la vraie chose attrape des problèmes (un mauvais index, une violation de contrainte, un vrai plan de requête) qu'un faux en mémoire ne peut pas attraper.

Un compromis

Pour une application de petite ou moyenne taille qui parle à une base qu'elle ne remplacera pas, de façon réaliste, une fine couche d'accès aux données construite directement sur le query builder de votre ORM, sans interface de repository derrière, apporte l'essentiel du bénéfice organisationnel (requêtes centralisées, non éparpillées dans la logique métier) sans l'indirection supplémentaire :

// db/users.ts — plain functions, no interface, no DI required
export async function findUserByEmail(db: PrismaClient, email: string) {
  return db.user.findUnique({ where: { email } });
}

export async function saveUser(db: PrismaClient, user: User) {
  return db.user.upsert({
    where: { id: user.id },
    update: user,
    create: user,
  });
}

Les tests peuvent toujours substituer une base de test ou utiliser le schéma « annulation de transaction par test » de votre ORM, sans interface construite spécialement pour permettre ce remplacement.

Quand y recourir réellement

  • Vous construisez une bibliothèque ou un module qui doit réellement prendre en charge plusieurs backends (une application auto-hébergée qui doit tourner à la fois sur Postgres et SQLite, par exemple).
  • La logique de domaine est assez complexe pour que vous vouliez des tests unitaires sans aucune E/S, et qu'une vraie base de test soit trop lente ou indisponible dans votre environnement de CI.
  • Vous faites délibérément du Domain-Driven Design avec un modèle de domaine riche qui ne devrait pas savoir que la persistance existe.

En dehors de ces cas, une interface de repository avec exactement une implémentation que vous écrirez jamais, c'est de la complexité payée à crédit contre un avenir qui, pour la plupart des projets, n'arrive pas.

Plus récentConstruire une file de tâches simple avec RedisPlus ancienTransactions de base de données et niveaux d'isolation expliqués