Le pattern Repository dans NestJS : accéder aux données

Le pattern Repository dans NestJS : accéder aux données

Dans une application NestJS, l’accès aux données est un sujet central : on veut écrire du code lisible, testable et facile à faire évoluer, sans disséminer des requêtes SQL (ou des détails ORM) partout. Le pattern Repository répond précisément à ce besoin en isolant la couche d’accès aux données derrière une API dédiée.

Dans ce tutoriel, on va voir comment utiliser le pattern Repository avec TypeORM dans NestJS, depuis la configuration du module jusqu’aux requêtes avancées avec QueryBuilder, en passant par les transactions avec QueryRunner. Les exemples s’appuient sur des services typiques d’un projet “un projet de production”, notamment des services proches de OracleService et ServicesService.

Prérequis : connaître les bases de NestJS (modules, providers, injection de dépendances) et avoir déjà une connexion TypeORM fonctionnelle (DataSource configurée, entités créées).

Comprendre le pattern Repository (et pourquoi il est utile)

Le Repository est un objet qui encapsule l’accès aux données pour une entité donnée. Au lieu d’écrire des requêtes directement dans vos contrôleurs ou services métier, vous déléguez :

  • la lecture (find, findOne, filtres, pagination),
  • l’écriture (save, update, delete),
  • et parfois des requêtes plus spécifiques (méthodes personnalisées).

Une analogie simple : si votre entité est un “dossier”, le repository est le “classement” qui sait comment retrouver, ranger, modifier et supprimer ce dossier dans l’armoire (la base de données). Votre service métier ne manipule pas l’armoire directement.

Avantages principaux :

  • Séparation des responsabilités : le service métier orchestre, le repository persiste.
  • Testabilité : vous pouvez mocker le repository dans les tests unitaires.
  • Lisibilité : le code d’accès aux données est centralisé et cohérent.
  • Évolutivité : changer une stratégie de requête ou optimiser une query n’impacte pas tout le code.

Déclarer les repositories avec TypeOrmModule.forFeature([Entity])

Dans NestJS, l’intégration TypeORM repose sur des modules. Pour pouvoir injecter un repository lié à une entité dans un provider (service), vous devez déclarer cette entité dans le module via TypeOrmModule.forFeature.

Exemple de module qui expose un service métier (style “ServicesService”) travaillant sur une entité ServiceEntity :

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

import { ServicesService } from './services.service';
import { ServicesController } from './services.controller';
import { ServiceEntity } from './entities/service.entity';

@Module({
  imports: [
    // Rend Repository<ServiceEntity> injectable dans ce module
    TypeOrmModule.forFeature([ServiceEntity]),
  ],
  controllers: [ServicesController],
  providers: [ServicesService],
  exports: [ServicesService],
})
export class ServicesModule {}

Piège courant : oublier forFeature. Dans ce cas, NestJS ne sait pas fournir le repository et vous obtiendrez une erreur d’injection du type “Nest can’t resolve dependencies…”.

Injecter un repository : @InjectRepository(Entity)

Une fois l’entité déclarée dans forFeature, vous pouvez injecter le repository dans un service avec @InjectRepository(Entity).

Exemple dans un service (inspiré d’un ServicesService qui liste et administre des “services” affichés sur le site) :

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { ServiceEntity } from './entities/service.entity';

@Injectable()
export class ServicesService {
  constructor(
    @InjectRepository(ServiceEntity)
    private readonly serviceRepository: Repository<ServiceEntity>,
  ) {}

  async findAll(): Promise<ServiceEntity[]> {
    return this.serviceRepository.find();
  }

  async findOneOrFail(id: number): Promise<ServiceEntity> {
    const service = await this.serviceRepository.findOne({ where: { id } });
    if (!service) {
      throw new NotFoundException(`Service ${id} introuvable`);
    }
    return service;
  }
}

Bonnes pratiques :

  • Injectez Repository<Entity> dans les services, pas dans les contrôleurs.
  • Centralisez la gestion d’erreur (ex. NotFoundException) au bon niveau.

Méthodes de base : find, findOne, findOneBy, save, update, delete

TypeORM fournit des méthodes “standard” très pratiques. Voici celles que vous utiliserez le plus souvent.

find() : récupérer une liste

async listActive(): Promise<ServiceEntity[]> {
  return this.serviceRepository.find({
    where: { isActive: true },
    order: { position: 'ASC' },
  });
}

findOne() et findOneBy() : récupérer un élément

findOne prend un objet d’options (pratique pour inclure relations, order, etc.). findOneBy est plus direct quand vous n’avez besoin que d’un where simple.

async findBySlug(slug: string): Promise<ServiceEntity | null> {
  return this.serviceRepository.findOneBy({ slug });
}

async findWithRelations(id: number): Promise<ServiceEntity | null> {
  return this.serviceRepository.findOne({
    where: { id },
    relations: {
      // Exemple : charge une relation (selon votre modèle)
      // category: true,
    },
  });
}

save() : créer ou mettre à jour

save persiste une entité. Si la clé primaire est absente, TypeORM fait un INSERT, sinon un UPDATE. Très pratique, mais attention : cela peut déclencher des cascades si elles sont configurées.

async create(payload: Partial<ServiceEntity>): Promise<ServiceEntity> {
  const entity = this.serviceRepository.create({
    ...payload,
    isActive: payload.isActive ?? true,
  });

  return this.serviceRepository.save(entity);
}

update() : mettre à jour sans charger l’entité

update exécute une requête d’update directe. C’est performant, mais vous ne récupérez pas l’entité mise à jour automatiquement.

async setActive(id: number, isActive: boolean): Promise<void> {
  const result = await this.serviceRepository.update({ id }, { isActive });
  if (result.affected === 0) {
    throw new NotFoundException(`Service ${id} introuvable`);
  }
}

delete() : supprimer

async remove(id: number): Promise<void> {
  const result = await this.serviceRepository.delete({ id });
  if (result.affected === 0) {
    throw new NotFoundException(`Service ${id} introuvable`);
  }
}

Options de recherche : where, order, take, skip, relations

Les options de find et findOne permettent de couvrir une grande partie des besoins sans écrire de SQL.

where : filtrer

where accepte un objet (conditions simples) ou un tableau (OR). Exemple : lister des services actifs d’un type donné.

async listByType(type: string): Promise<ServiceEntity[]> {
  return this.serviceRepository.find({
    where: {
      type,
      isActive: true,
    },
  });
}

order : trier

async listOrdered(): Promise<ServiceEntity[]> {
  return this.serviceRepository.find({
    order: {
      position: 'ASC',
      createdAt: 'DESC',
    },
  });
}

take / skip : pagination

take = limite, skip = offset. À utiliser pour une pagination simple.

async paginate(page = 1, limit = 20): Promise<ServiceEntity[]> {
  const take = Math.min(limit, 100); // évite des pages trop lourdes
  const skip = (page - 1) * take;

  return this.serviceRepository.find({
    take,
    skip,
    order: { createdAt: 'DESC' },
  });
}

relations : charger des relations

Si votre entité a des relations (OneToMany, ManyToOne, etc.), vous pouvez demander à TypeORM de les charger via relations. Attention : charger trop de relations peut coûter cher (N+1, volumes importants).

async findOneWithRelations(id: number): Promise<ServiceEntity | null> {
  return this.serviceRepository.findOne({
    where: { id },
    relations: {
      // Exemple (à adapter à votre modèle réel)
      // oracle: true,
      // tags: true,
    },
  });
}

QueryBuilder : pour les requêtes complexes

Dès que vous avez besoin de jointures fines, de conditions dynamiques, d’agrégations ou de sous-requêtes, le QueryBuilder devient l’outil le plus adapté. Il reste typé, composable, et évite de basculer trop tôt en SQL brut.

Exemple : une recherche “avancée” (style back-office) qui combine statut, tri et pagination, et prépare le terrain pour des jointures.

async search(params: {
  q?: string;
  isActive?: boolean;
  page?: number;
  limit?: number;
}): Promise<{ items: ServiceEntity[]; total: number }> {
  const page = params.page ?? 1;
  const limit = Math.min(params.limit ?? 20, 100);

  const qb = this.serviceRepository
    .createQueryBuilder('service')
    .orderBy('service.createdAt', 'DESC')
    .skip((page - 1) * limit)
    .take(limit);

  if (typeof params.isActive === 'boolean') {
    qb.andWhere('service.isActive = :isActive', { isActive: params.isActive });
  }

  if (params.q && params.q.trim().length > 0) {
    // Exemple simple : recherche sur un champ texte
    qb.andWhere('service.name LIKE :q', { q: `%${params.q}%` });
  }

  const [items, total] = await qb.getManyAndCount();
  return { items, total };
}

Pourquoi c’est utile : vous construisez la requête “brique par brique” sans multiplier les variantes de find() ni écrire des if/else difficiles à maintenir.

Créer des méthodes personnalisées dans le service (style OracleService / ServicesService)

Dans beaucoup de projets, on ne crée pas forcément un “custom repository” TypeORM (selon la version et les pratiques), mais on centralise des méthodes métier dans le service NestJS qui utilise le repository.

Voici des exemples de méthodes personnalisées inspirées d’un contexte “un projet de production” :

  • Publier/dépublier un service (toggle d’un flag)
  • Réordonner des services (champ position)
  • Récupérer une liste “front” filtrée et triée
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ServiceEntity } from './entities/service.entity';

@Injectable()
export class ServicesService {
  constructor(
    @InjectRepository(ServiceEntity)
    private readonly serviceRepository: Repository<ServiceEntity>,
  ) {}

  async listForFront(): Promise<ServiceEntity[]> {
    return this.serviceRepository.find({
      where: { isActive: true },
      order: { position: 'ASC' },
      take: 50,
    });
  }

  async publish(id: number): Promise<ServiceEntity> {
    const service = await this.serviceRepository.findOneBy({ id });
    if (!service) throw new NotFoundException(`Service ${id} introuvable`);

    service.isActive = true;
    return this.serviceRepository.save(service);
  }

  async reorder(idsInOrder: number[]): Promise<void> {
    if (idsInOrder.length === 0) {
      throw new BadRequestException('La liste d’IDs ne peut pas être vide');
    }

    // Mise à jour simple : une requête par ligne (OK pour petites listes)
    // Pour de gros volumes, préférer une stratégie batch/transaction.
    for (let i = 0; i < idsInOrder.length; i++) {
      await this.serviceRepository.update({ id: idsInOrder[i] }, { position: i + 1 });
    }
  }
}

Point important : ces méthodes ne “font pas de SQL”. Elles expriment des intentions métier et s’appuient sur le repository pour la persistance.

Transactions avec QueryRunner (cas concrets)

Une transaction est indispensable quand vous devez garantir que plusieurs écritures se font “toutes ou rien”. Exemple typique : réordonner des services (plusieurs updates) ou synchroniser des données liées (un service + ses paramètres).

Avec TypeORM, vous pouvez utiliser un QueryRunner pour piloter explicitement une transaction.

Exemple : réordonner une liste de services dans une transaction, pour éviter un état partiellement mis à jour si une requête échoue au milieu.

import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { ServiceEntity } from './entities/service.entity';

@Injectable()
export class ServicesService {
  constructor(
    private readonly dataSource: DataSource,
    @InjectRepository(ServiceEntity)
    private readonly serviceRepository: Repository<ServiceEntity>,
  ) {}

  async reorderInTransaction(idsInOrder: number[]): Promise<void> {
    if (idsInOrder.length === 0) {
      throw new BadRequestException('La liste d’IDs ne peut pas être vide');
    }

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // Important : utiliser queryRunner.manager pour que toutes les requêtes
      // participent à la même transaction.
      for (let i = 0; i < idsInOrder.length; i++) {
        await queryRunner.manager.update(
          ServiceEntity,
          { id: idsInOrder[i] },
          { position: i + 1 },
        );
      }

      await queryRunner.commitTransaction();
    } catch (e) {
      await queryRunner.rollbackTransaction();
      throw e;
    } finally {
      await queryRunner.release();
    }
  }
}

Pièges courants :

  • Mélanger des appels via this.serviceRepository et queryRunner.manager dans la même transaction : les opérations hors manager peuvent ne pas être dans la transaction.
  • Oublier release() : cela peut épuiser le pool de connexions.
  • Faire des transactions trop longues (beaucoup de logique métier, appels HTTP externes) : gardez la transaction courte.

Cas d’usage “OracleService” : accès aux données et requêtes avancées

Dans un projet “un projet de production”, un OracleService peut représenter une entité “Oracle” (profil, disponibilité, spécialités, etc.). Sans figer votre modèle, l’idée est la même : le service NestJS orchestre, le repository exécute.

Exemple de requête avancée : récupérer des oracles “actifs”, trier, paginer, et potentiellement joindre une relation (par ex. spécialités).

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { OracleEntity } from './entities/oracle.entity';

@Injectable()
export class OracleService {
  constructor(
    @InjectRepository(OracleEntity)
    private readonly oracleRepository: Repository<OracleEntity>,
  ) {}

  async listForFront(params: { page?: number; limit?: number }): Promise<{ items: OracleEntity[]; total: number }> {
    const page = params.page ?? 1;
    const limit = Math.min(params.limit ?? 20, 100);

    const qb = this.oracleRepository
      .createQueryBuilder('oracle')
      .where('oracle.isActive = :isActive', { isActive: true })
      .orderBy('oracle.position', 'ASC')
      .skip((page - 1) * limit)
      .take(limit);

    // Exemple : si vous avez une relation à charger via jointure
    // qb.leftJoinAndSelect('oracle.specialties', 'specialty');

    const [items, total] = await qb.getManyAndCount();
    return { items, total };
  }
}

Ce type de méthode est typique d’un service “front” : elle expose une API stable, et vous pouvez optimiser la query (index, join, champs sélectionnés) sans changer le contrôleur.

Tests : isoler le service en mockant le repository

Un bénéfice concret du pattern Repository est la facilité de test. Vous pouvez tester ServicesService sans base de données, en mockant Repository<ServiceEntity>.

Exemple minimal (approche classique NestJS) :

import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { ServicesService } from './services.service';
import { ServiceEntity } from './entities/service.entity';

describe('ServicesService', () => {
  let service: ServicesService;
  let repo: Repository<ServiceEntity>;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        ServicesService,
        {
          provide: getRepositoryToken(ServiceEntity),
          useValue: {
            find: jest.fn(),
            findOneBy: jest.fn(),
            save: jest.fn(),
            update: jest.fn(),
            delete: jest.fn(),
          },
        },
      ],
    }).compile();

    service = moduleRef.get(ServicesService);
    repo = moduleRef.get(getRepositoryToken(ServiceEntity));
  });

  it('listForFront() doit retourner les services actifs', async () => {
    (repo.find as any).mockResolvedValue([{ id: 1, isActive: true }]);
    await expect(service.listForFront()).resolves.toEqual([{ id: 1, isActive: true }]);
  });
});

Note : l’objectif ici est de valider la logique métier (paramètres, exceptions, orchestration), pas TypeORM lui-même.

Conclusion

Vous avez vu comment le pattern Repository structure l’accès aux données dans NestJS avec TypeORM :

  • déclaration des entités via TypeOrmModule.forFeature([Entity]),
  • injection propre avec @InjectRepository(Entity),
  • CRUD via find, findOne, findOneBy, save, update, delete,
  • recherche avancée avec options (where, order, take, skip, relations),
  • requêtes complexes avec QueryBuilder,
  • transactions robustes avec QueryRunner.

Pour approfondir, vous pouvez : (1) standardiser vos DTOs et la validation (pipes), (2) optimiser les requêtes (index, sélection de colonnes), (3) ajouter des tests d’intégration avec une base de test, et (4) documenter vos endpoints (Swagger) tout en gardant la couche repository/service propre.

Documentation officielle : NestJS - Database et TypeORM.

Commentaires

Soyez le premier à laisser un commentaire !

Laisser un commentaire

Les champs obligatoires sont indiqués avec *