Accueil / Articles

Pattern de répertoire pour des backends Node.js scalables

Le pattern de repository en Node.js : cacher Prisma derrière des repositories injectés et une usine, afin que les services restent testables et que les transactions restent atomiques en TypeScript.

Éditeur : REACTAPP.TOP4 min de lecture946 mots

Sources: Decoupling Data: Building Scalable Node.js Backends with the Repository Pattern

Les backends initiaux interrogent souvent la base de données directement depuis les méthodes de service. Cela convient pour de petits projets. Lorsque les systèmes nécessitent une fiabilité en production, le couplage étroit entre les règles métier et les appels Prisma devient un goulot d’étranglement pour la maintenance. Le pattern Repository est une façon de maintenir le codebase scalable et testable.

Le problème : le « service divin »

Les services peuvent commencer à ressembler à ceci :

await prisma.user.findUnique({ where: { email } });
await prisma.user.create({ data });
// ... 100 more lines of database logic mixed with business rules ...

Cela semble simple au début. Avec le temps, les problèmes apparaissent :

  • Fuites de logique : des détails de type SQL s’infiltrent dans les règles métier.
  • Duplication : les mêmes appels Prisma sont copiés d’un service à l’autre.
  • Testabilité : les tests de logique nécessitent une base de données en ligne.
  • Fragilité : le renomage d’une table ou d’un champ a des conséquences dans tous les services.

C’est ce qu’on appelle un couplage étroit : la logique d’inscription ou d’authentification est intégrée directement à Prisma. Il devient presque impossible de réaliser des tests unitaires sans base de données réelle. Une couche d’accès aux données dédiée constitue donc l’élément manquant.

La solution : le pattern du repository

Idee fondamentale : les services doivent se concentrer sur ce qu’ils reçoivent, et non sur la manière dont cela a été obtenu.

Définition d’un contrat générique

export interface IRepository<T> {
  findById(id: string): Promise<T | null>;
  create(data: any): Promise<T>;
  update(id: string, data: any): Promise<T>;
  delete(id: string): Promise<T>;
}

UserRepository (seule la logique de base de données)

La fonction d’un repository est la persistance. Il ne doit pas créer Prisma ; il doit recevoir un client via le constructeur.

export class UserRepository {
  constructor(private prisma: PrismaClient) {}

  async findByEmail(email: string) {
    return this.prisma.user.findUnique({
      where: { email },
    });
  }

  async markEmailAsVerified(id: string) {
    return this.prisma.user.update({
      where: { id },
      data: { isEmailVerified: true },
    });
  }
}

Le repository ne contrôle pas la création de Prisma — il reçoit une instance.

Pourquoi le passage de PrismaClient est obligatoire (injection de dépendances)

Évitez de créer des clients à l’intérieur des repositories :

new PrismaClient(); // ❌ inside repository

Injectez plutôt :

constructor(private prisma: PrismaClient) {}

Pourquoi c’est important

  • Connexions partagées : empêche la création accidentelle de groupes de clients.
  • Transactions atomiques : plusieurs repositories peuvent partager une même session transactionnelle.
  • Facilité de test : le client (ou le repository) peut être simulé dans les tests unitaires.

Celui-ci est un modèle d’injection de dépendances — une pratique standard en conception backend.

Fabrique singleton : une source unique de vérité pour les repositories

Une usine de repositories avec une instance singleton et une création différée permet de maintenir un câblage ordonné :

export class RepositoryFactory {
  private static instance: RepositoryFactory;
  private _userRepository?: UserRepository;

  private constructor(private prisma: PrismaClient) {}

  static getInstance(prisma: PrismaClient) {
    if (!this.instance) {
      this.instance = new RepositoryFactory(prisma);
    }
    return this.instance;
  }

  // Lazy Initialization: Only creates the repo if it's called
  public get userRepository(): UserRepository {
    if (!this._userRepository) {
      this._userRepository = new UserRepository(this.prisma);
    }
    return this._userRepository;
  }
}t

Les services appellent ensuite des méthodes propres sans SQL :

this.userRepository.findByEmail(email);

Pourquoi un singleton ? Une seule usine pour le processus signifie un seul PrismaClient partagé, aucune connexion redondante, et des instances de repositories cohérentes.

Le grand avantage : les transactions atomiques

Les flux d’authentification nécessitent souvent une intégrité à plusieurs étapes. Lorsqu’un utilisateur vérifie son e-mail, le système peut mettre à jour l’utilisateur et supprimer un OTP. Les deux opérations doivent réussir ou échouer ensemble.

Puisque les repositories partagent le client injecté, une transaction peut les envelopper :

await prisma.$transaction(async (tx) => {
  // We pass the transaction 'tx' into our factory
  const repos = RepositoryFactory.getInstance(tx);

  await repos.userRepository.markEmailAsVerified(userId);
  await repos.otpRepository.delete(otpId);
});

Si quelque chose échoue en cours de route, Prisma annule toute l’opération — pas d’état de vérification partiellement appliqué.

Pourquoi c’est important :

  • La vérification et le nettoyage de l’OTP réussissent ensemble
  • L’échec annule tout
  • Les données restent cohérentes
  • Découplage

    Auparavant : les services dépendaient directement de Prisma.

    Après : les services dépendent des repositories ; les repositories dépendent de PrismaClient.

    AuthService → UserRepository → Prisma
    

    Cela signifie que Prisma peut être remplacé ultérieurement sans avoir à réécrire les services, la logique métier reste lisible, et le code de base de données peut évoluer indépendamment.

    Le super-pouvoir secret : la testabilité (mimulation)

    Lorsque les requêtes sont cachées à l’intérieur des services, les tests ont besoin d’une base de données en temps réel et deviennent lents ou instables. Avec les repositories, les tests unitaires peuvent utiliser des simulations.

    Au lieu de MySQL, transmettez un UserRepository fictif à AuthService :

    // Example: Testing the Register logic without a database
    const mockUserRepository = {
      findByEmail: jest.fn().mockResolvedValue(null), // Pretend user doesn't exist
      create: jest.fn().mockResolvedValue({ id: '123', email: 'test@test.com' })
    };
    
    // Now I can test the Service logic in isolation
    const authService = new AuthService(mockUserRepository as any);
    const result = await authService.register(userData);
    
    expect(result.user.email).toBe('test@test.com');
    expect(mockUserRepository.create).toHaveBeenCalled();
    

    Pourquoi c’est important sur le plan opérationnel

    • Vitesse : des milliers de tests peuvent être exécutés sans surcharge réseau ou de base de données.
    • Fiableté : les cas limites tels que les délais d’attente sont faciles à simuler en faisant planter le mock.
    • Confiance : les refacturations reçoivent un retour immédiat lorsque la logique s’éloigne de son objectif.

    Ce que cette implémentation enseigne

    1. Séparation des préoccupations : les services gèrent les règles métier ; les repositories gèrent les requêtes.
    2. Évolutivité : de nouveaux domaines (réservations, locations, etc.) suivent la même structure.
    3. Structure de production : l’organisation correspond aux schémas courants dans les systèmes plus importants.

    Pensées finales

    Mettre Prisma derrière les repositories va au-delà d’un simple déplacement de fichiers. Les services deviennent plus clairs, les tests sont moins coûteux, et le backend est mieux préparé pour sa croissance. Si les appels à Prisma transforment les services en objets divins, le pattern des repositories constitue une étape pratique suivante.