Skip to content

Repository Patterns

TL;DR — Le Repository pattern (Evans/Fowler) abstrait l'accès aux données derrière une interface domaine (UsersRepository.findActiveByTenant(tid)). Avantages : testabilité, séparation domaine/persistence, possibilité de switcher d'ORM. Coûts : indirection, duplication, perte de richesse de l'ORM. La règle senior : abstraire la persistence quand le domaine est complexe et long-vécu, ne pas abstraire quand le projet est court ou tu utilises Prisma/Drizzle qui font déjà bien le job.

🧠 Mental model

                       ┌───── Domain layer ──────┐
   Controller ────►   Service (use-case)         │
                          │                       │
                          ▼                       │
                  UsersRepository (interface)     │
                          ▲                       │
                          │                       │
   ─────────────────────────────────────────────  │
   Infra layer        TypeOrmUsersRepository      │
                      PrismaUsersRepository       │
                      InMemoryUsersRepository     │
                       (impls swap-ables)         │
                       └───────────────────────────┘

Analogie — Le Repository = un store dont tu vois la vitrine, pas l'arrière-boutique. Tu commandes par catégorie (findActiveUsers, findOrdersByCustomer), tu n'écris pas du SQL/Mongo. La vitrine parle "domaine" (entités, value objects), l'arrière-boutique parle "ORM" (rows, documents, query builder).

Le piège mental venant de PHP/TS

Si tu viens de Laravel (User::where(...)->get() — Active Record) ou de TypeORM @Entity actif, ton réflexe est que l'entité sait se persister elle-même. Le Repository pattern (Data Mapper) casse ça volontairement : l'entité est un objet de domaine pur, ignorant de la DB (pas de décorateur ORM dessus), et un objet séparé (le repo) fait la traduction. C'est plus de code, mais l'entité devient testable sans DB et le domaine ne dépend plus de l'infra. Le tableau ci-dessous est le modèle de décision senior — la plupart des débats "faut-il un repo ?" se résolvent en le lisant.

Spectre de découplage (le vrai axe de décision)

Il n'y a pas "repo ou pas repo", il y a un spectre du couplage le plus serré au plus lâche. Le coût monte avec le découplage ; choisis le point le plus à gauche qui satisfait tes contraintes.

NiveauFormeCouplage ORMTestable sans DBCoûtQuand
0ORM direct dans le servicetotalnon (ou mock noisy)nulCRUD plat, POC, script
1Service + Prisma, entités plaintotalnonnul80% des apps métier raisonnables
2Repo "thin" typé (CRUD, retours = types ORM)élevépartielfaiblemutualiser des requêtes, pas du DDD
3Repo = port domaine (interface + mapping toDomain/toRow)nul côté domaineoui (InMemory)moyenaggregate roots, domaine long-vécu
4Repo + Specification + Unit of Worknulouiélevéinvariants forts, transactions multi-aggregate, event sourcing

Règle de l'expérimenté — tu ne "fais pas du repository" sur toute l'app. Tu identifies les 2-3 aggregate roots critiques (ceux avec invariants métier et longévité) → niveau 3/4. Le reste reste au niveau 1. Le mono-niveau (tout en repo OU rien en repo) est presque toujours une erreur de dogme.

Coûts vs gains — la balance qu'on te demande en entretien

GainCoût correspondant
Testabilité sans DB (InMemory)InMemory peut diverger de la sémantique SQL (voir pitfall #7)
Domaine isolé de l'infraMapping toDomain/toRow à écrire et maintenir
Swap d'ORM théoriqueQuasi jamais exercé en prod (cf. pitfall #12)
API métier lisible (findActiveByTenant)Explosion combinatoire des findByX si pas de Specification
Point d'injection pour cache/outbox/tracingIndirection : un bug se cherche sur 2 couches
Features ORM riches encapsulées…ou perdues (_count, $facet, loadRelationCountAndMap)

🛠️ Code minimal

Interface domaine + impl Prisma

ts
// domain/users.repository.ts
export interface UsersRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  findActiveByTenant(tenantId: string): Promise<User[]>;
  save(user: User): Promise<User>;
  delete(id: string): Promise<void>;
}

export const USERS_REPOSITORY = Symbol('UsersRepository');
ts
// infra/prisma-users.repository.ts
@Injectable()
export class PrismaUsersRepository implements UsersRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findById(id: string) {
    const row = await this.prisma.user.findUnique({ where: { id } });
    return row ? this.toDomain(row) : null;
  }
  async findByEmail(email: string) {
    const row = await this.prisma.user.findUnique({ where: { email } });
    return row ? this.toDomain(row) : null;
  }
  async findActiveByTenant(tenantId: string) {
    const rows = await this.prisma.user.findMany({ where: { tenantId, deletedAt: null } });
    return rows.map((r) => this.toDomain(r));
  }
  async save(user: User) {
    const row = user.id
      ? await this.prisma.user.update({ where: { id: user.id }, data: this.toRow(user) })
      : await this.prisma.user.create({ data: this.toRow(user) });
    return this.toDomain(row);
  }
  async delete(id: string) {
    await this.prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
  }

  private toDomain(row: any): User { return new User(row.id, row.email, row.name, row.tenantId); }
  private toRow(u: User) { return { email: u.email, name: u.name, tenantId: u.tenantId }; }
}
ts
// module
@Module({
  providers: [
    PrismaService,
    { provide: USERS_REPOSITORY, useClass: PrismaUsersRepository },
  ],
  exports: [USERS_REPOSITORY],
})
export class UsersModule {}

// service domain
@Injectable()
export class UsersService {
  constructor(@Inject(USERS_REPOSITORY) private readonly users: UsersRepository) {}
  async activate(id: string) {
    const u = await this.users.findById(id);
    if (!u) throw new NotFoundException();
    u.activate();
    return this.users.save(u);
  }
}

Generic repository (à manier avec précaution)

ts
export abstract class GenericRepository<T extends { id: string }> {
  constructor(protected readonly model: any) {}
  findById(id: string): Promise<T | null> { return this.model.findUnique({ where: { id } }); }
  list(filter: Partial<T>): Promise<T[]> { return this.model.findMany({ where: filter }); }
  save(entity: T): Promise<T> {
    return entity.id
      ? this.model.update({ where: { id: entity.id }, data: entity })
      : this.model.create({ data: entity });
  }
  delete(id: string): Promise<void> { return this.model.delete({ where: { id } }); }
}

Specification pattern

ts
// Permet de composer des règles métier au-dessus du repo
export abstract class Specification<T> {
  abstract isSatisfiedBy(candidate: T): boolean;
  abstract toQuery(): any;  // traduit en where Prisma/TypeORM

  and(other: Specification<T>): Specification<T> { return new AndSpec(this, other); }
  or(other: Specification<T>): Specification<T>  { return new OrSpec(this, other); }
}

export class ActiveUserSpec extends Specification<User> {
  isSatisfiedBy(u: User) { return u.deletedAt === null && u.emailVerified; }
  toQuery() { return { deletedAt: null, emailVerified: true }; }
}

export class TenantSpec extends Specification<User> {
  constructor(private readonly tenantId: string) { super(); }
  isSatisfiedBy(u: User) { return u.tenantId === this.tenantId; }
  toQuery() { return { tenantId: this.tenantId }; }
}

// Usage
const spec = new ActiveUserSpec().and(new TenantSpec(req.tenantId));
const users = await this.users.findBySpec(spec);

🎯 Patterns courants

  1. Repo + entité riche — DDD style. L'entité a des méthodes (u.activate(), u.changeEmail(e)), le repo persiste. Bien adapté pour des domaines complexes (banque, healthcare).
  2. Repo + service anémique — repo expose CRUD typed, service contient les use-cases. Plus pragmatique pour la majorité des projets.
  3. Specification — pour des règles de filtrage composables et réutilisables. Au-dessus du repo qui expose findBySpec(spec) traduisant en where ORM.
  4. Read model / CQRS — un repo pour les writes (transactionnel, riche), un autre pour les reads (queries optimisées, projections). Évite le piège du "repo qui fait tout".
  5. Unit of Work — un contexte unique pour toutes les mutations d'une use-case + commit final. Prisma/TypeORM le font déjà via $transaction — le UoW manuel n'apporte pas grand chose en Node.
  6. InMemory repo pour les tests — implémenter l'interface en mémoire, swap dans Test.createTestingModule. Évite le mock noisy.
  7. Aggregate roots — un repo par aggregate root (User + ses Profile + Addresses, sauvegardés ensemble). Évite de fragmenter en 5 repos pour une même unité de cohérence.
  8. Idempotent savesave(entity) checks entity.id ; si présent, update ; sinon insert. Toujours typer le retour pour que l'id soit présent après.
  9. DataLoader pattern — pour batching/caching des findById à l'intérieur d'une requête (GraphQL). Le repo expose findByIds(ids[]) que le DataLoader appelle.
  10. Outbox dans le repo — quand save(user) est appelé dans une transaction, le repo INSERT aussi dans la table outbox. Garantit cohérence event ↔ state.

🔄 Versions — Nest 7 / 8 / 9 / 10 / 11

VersionNotes
Nest 7DI par symbol/token déjà standard.
Nest 8InjectionToken plus typé.
Nest 9Pas de changement spécifique repo.
Nest 10@nestjs/cqrs 10+ propose des patterns "read model" intégrés.
Nest 11Compat full. AsyncLocalStorage facilite repo transactionnel transparent.

ORM — l'abstraction Repository est indépendante de la version ORM, mais le mapping (toDomain/toRow) doit suivre. Si le schéma ORM change, le repo absorbe — c'est le but.

⚠️ Pitfalls

  1. Repo generic qui leak l'ORMrepo.findMany({ where: { OR: [...] } }) expose la syntaxe Prisma. Le client (service) finit par savoir si c'est Prisma ou TypeORM. Anti-pattern : repo qui prend des "options ORM".
  2. Repo trop large — 20 méthodes findByX, findByXAndY, etc. Symptôme : explosion combinatoire. Préférer un Specification pattern ou exposer findBy(criteria).
  3. Mapping domaine ↔ DB coûteux — chaque toDomain() qui re-instancie ralentit les listes de masse. Pour les listes plates, exposer un read-model direct (DTO) sans passer par l'entité riche.
  4. Repo qui fait du métier — un repo.activate(id) qui change status puis sauvegarde fait du métier dans la couche infra. Garder dans service / entité.
  5. Specification trop abstraite — pour 2-3 conditions, c'est over-engineered. Réserver aux domaines avec 10+ règles composables.
  6. Repo + transaction propagation cassée — le repo capture une instance Prisma globale et ignore les tx. Solution : injecter via factory en lisant ALS (voir 04-transactions.md).
  7. InMemory repo qui diverge — la sémantique de filtre/sort dans l'in-memory ne matche pas la DB (collation, null ordering, etc.) ⇒ tests verts, bugs prod. À mitiger via testcontainers en parallèle.
  8. Pas d'index visible — quand le SQL est caché derrière le repo, on oublie de créer les indexes. Lister les access patterns dans le code du repo et croiser avec les migrations.
  9. Abstraction qui empêche les features ORM — TypeORM loadRelationCountAndMap, Prisma _count, Mongo $facet — autant de fonctionnalités spécifiques que le repo abstrait perd. Soit on dégrade, soit on ajoute des leak abstractions.
  10. Repo par entité au lieu d'aggregate — créer un repo par table sans réfléchir à l'aggregate ⇒ on perd les invariants ("user.addresses ne peut pas être vide"). Penser DDD aggregate root.
  11. N+1 réintroduit par l'abstraction — le service appelle repo.findById(id) puis pour chaque user repo.findPostsByUser(u.id). L'ORM brut aurait pu faire un JOIN. Solution : exposer findUsersWithPosts(ids) au niveau repo.
  12. Toujours vouloir "pouvoir switcher d'ORM" — argument souvent évoqué, rarement utilisé en pratique. Si le switch arrive 1 fois en 10 ans, le coût quotidien du repo dépasse le gain.

🧪 Testing

ts
// InMemory repo
export class InMemoryUsersRepository implements UsersRepository {
  private store = new Map<string, User>();
  async findById(id: string) { return this.store.get(id) ?? null; }
  async findByEmail(email: string) {
    return [...this.store.values()].find((u) => u.email === email) ?? null;
  }
  async findActiveByTenant(tid: string) {
    return [...this.store.values()].filter((u) => u.tenantId === tid && !u.deletedAt);
  }
  async save(u: User) {
    if (!u.id) (u as any).id = randomUUID();
    this.store.set(u.id, u);
    return u;
  }
  async delete(id: string) { this.store.delete(id); }
}

// Tests rapides
beforeEach(() => {
  mod = await Test.createTestingModule({
    providers: [UsersService, { provide: USERS_REPOSITORY, useClass: InMemoryUsersRepository }],
  }).compile();
});

it('activate marks user active', async () => {
  const repo = mod.get<InMemoryUsersRepository>(USERS_REPOSITORY);
  await repo.save(new User('1', '[email protected]', 'A', 't1', false));
  await mod.get(UsersService).activate('1');
  const u = await repo.findById('1');
  expect(u?.isActive).toBe(true);
});
ts
// Contract test — vérifier que toutes les impls respectent le contrat
const impls: [string, () => UsersRepository][] = [
  ['inMemory', () => new InMemoryUsersRepository()],
  ['prisma',   () => new PrismaUsersRepository(prismaTestClient)],
];

describe.each(impls)('UsersRepository contract — %s', (_name, factory) => {
  let repo: UsersRepository;
  beforeEach(() => { repo = factory(); });
  it('findById returns null if unknown', async () => {
    expect(await repo.findById('unknown')).toBeNull();
  });
  it('save then findByEmail returns the user', async () => { /* ... */ });
});

Truc senior — écrire un contract test que toutes les impls doivent passer. Garantit que le swap n'introduit pas de régression.

🎬 Cas d'usage concrets

LegalTech — Cabinet juridique, abstraction stockage docs

Qui — Cabinet international qui stocke les pièces juridiques dans S3 pour l'archive froide, MinIO on-prem pour le travail courant (clients soumis à des contraintes de souveraineté), et un mock local en dev. Problème — Le code métier (CaseService, ContractService) ne doit JAMAIS connaître le provider. Un changement de fournisseur ou un déménagement on-prem ne doit toucher qu'une ligne de DI. Comment — Port DocumentStore côté domaine, 3 adapters côté infra, binding via useClass selon NODE_ENV et TENANT_REGION.

ts
// domain
export abstract class DocumentStore {
  abstract put(key: string, body: Buffer, meta?: Record<string, string>): Promise<string>;
  abstract get(key: string): Promise<Buffer>;
  abstract presign(key: string, ttl: number): Promise<string>;
}

// infra/s3-document.store.ts
@Injectable()
export class S3DocumentStore extends DocumentStore { /* ... AWS SDK ... */ }

// infra/minio-document.store.ts
@Injectable()
export class MinioDocumentStore extends DocumentStore { /* ... S3-compatible ... */ }

@Module({
  providers: [{
    provide: DocumentStore,
    useClass: process.env.STORAGE === 's3' ? S3DocumentStore : MinioDocumentStore,
  }],
  exports: [DocumentStore],
})
export class StorageModule {}

Gains — Migration AWS → on-prem en 1 PR de 5 lignes, tests unitaires CaseService 100% en mémoire.

FinTech — Ledger pattern

Qui — Plateforme française de paiement scaling 0 → 10 M comptes en 18 mois. Problème — Au début, un LedgerRepository Postgres suffit. Passé 5 M comptes, on shard par accountId % N et on déplace les writes vers Kafka + projection. Le code domaine ne doit pas changer. Comment — Port LedgerWriter qui expose post(entries: LedgerEntry[]), adapters PostgresLedgerWriter et KafkaLedgerWriter interchangeables.

ts
export abstract class LedgerWriter {
  abstract post(entries: LedgerEntry[]): Promise<void>;
}

@Injectable()
export class PostgresLedgerWriter extends LedgerWriter {
  constructor(@InjectDataSource() private ds: DataSource) { super(); }
  post(entries: LedgerEntry[]) {
    return this.ds.transaction((em) => em.insert(LedgerEntry, entries));
  }
}

@Injectable()
export class KafkaLedgerWriter extends LedgerWriter {
  constructor(private kafka: KafkaProducer) { super(); }
  async post(entries: LedgerEntry[]) {
    await this.kafka.sendBatch('ledger.entries', entries.map((e) => ({
      key: e.accountId, value: e,
    })));
  }
}

Gains — Migration progressive (10% trafic → 50% → 100%), rollback instantané, contract test partagé garantit la parité.

Immobilier — Property / Agent / Listing decoupling

Qui — Agrégateur immobilier qui consomme des flux de 20 portails (SeLoger, LeBonCoin, Bien'ici, etc.). Problème — Chaque portail a son format. On veut un domaine pur Property et des Repository qui normalisent les flux à l'ingestion. Comment — Repository abstrait PropertySource avec pull(since: Date): Stream<RawProperty> + mapper par source, et un PropertyRepository interne unique.

ts
export abstract class PropertySource {
  abstract pull(since: Date): AsyncIterable<RawProperty>;
}

@Injectable()
export class SeLogerSource extends PropertySource {
  async *pull(since: Date) {
    const xml = await this.http.get('https://seloger.com/feed.xml');
    for (const item of parseXml(xml)) yield this.toRaw(item);
  }
}

@Injectable()
export class PropertyIngestor {
  constructor(
    @Inject(PROPERTY_SOURCES) private sources: PropertySource[],
    private repo: PropertyRepository,
  ) {}
  async ingest(since: Date) {
    for (const source of this.sources) {
      for await (const raw of source.pull(since)) {
        await this.repo.upsert(this.normalize(raw));
      }
    }
  }
}

Gains — Ajouter un portail = 1 classe + 1 ligne dans le module, le reste du code métier ignore la source.

🛠️ Exemple end-to-end

Contexte — Le cabinet juridique veut un module ContractService totalement découplé du stockage et de la persistance. On utilise Repository pattern + Unit of Work + Specification pattern, le tout testable sans Docker.

ts
// src/contract/domain/contract.entity.ts
export class Contract {
  constructor(
    public readonly id: ContractId,
    public clientId: string,
    public status: 'draft' | 'signed' | 'archived',
    public documentKey: string,
    public signers: Signer[],
    public version: number,
  ) {}

  addSigner(signer: Signer): void {
    if (this.status !== 'draft') throw new DomainError('CONTRACT_LOCKED');
    if (this.signers.some((s) => s.email === signer.email)) {
      throw new DomainError('DUPLICATE_SIGNER');
    }
    this.signers.push(signer);
  }

  markSigned(): void {
    if (this.signers.some((s) => !s.signedAt)) {
      throw new DomainError('SIGNATURES_INCOMPLETE');
    }
    this.status = 'signed';
  }
}

// src/contract/domain/contract.repository.ts
export abstract class ContractRepository {
  abstract findById(id: ContractId): Promise<Contract | null>;
  abstract findBy(spec: ContractSpec): Promise<Contract[]>;
  abstract save(contract: Contract): Promise<void>;
}

// src/contract/domain/contract.spec.ts
export class ContractSpec {
  constructor(private criteria: {
    clientId?: string;
    status?: Contract['status'];
    olderThan?: Date;
  }) {}
  toSql(): { where: string; params: unknown[] } {
    const parts: string[] = []; const params: unknown[] = [];
    if (this.criteria.clientId) { parts.push(`client_id = $${params.length + 1}`); params.push(this.criteria.clientId); }
    if (this.criteria.status)   { parts.push(`status = $${params.length + 1}`);    params.push(this.criteria.status); }
    if (this.criteria.olderThan){ parts.push(`created_at < $${params.length + 1}`); params.push(this.criteria.olderThan); }
    return { where: parts.length ? parts.join(' AND ') : '1=1', params };
  }
}

// src/contract/domain/document.store.ts
export abstract class DocumentStore {
  abstract put(key: string, body: Buffer): Promise<string>;
  abstract get(key: string): Promise<Buffer>;
}

// src/contract/domain/unit-of-work.ts
export abstract class UnitOfWork {
  abstract run<T>(work: (ctx: TxContext) => Promise<T>): Promise<T>;
}
export interface TxContext {
  contracts: ContractRepository;
  events: DomainEvent[];
}
ts
// src/contract/infra/typeorm-contract.repository.ts
@Injectable()
export class TypeOrmContractRepository extends ContractRepository {
  constructor(@InjectEntityManager() private em: EntityManager) { super(); }

  async findById(id: ContractId): Promise<Contract | null> {
    const row = await this.em.findOne(ContractRow, {
      where: { id: id.value }, relations: { signers: true },
    });
    return row ? this.toDomain(row) : null;
  }

  async findBy(spec: ContractSpec): Promise<Contract[]> {
    const { where, params } = spec.toSql();
    const rows = await this.em.query(
      `SELECT * FROM contract WHERE ${where}`, params,
    );
    return rows.map((r) => this.toDomain(r));
  }

  async save(contract: Contract): Promise<void> {
    const row = this.em.create(ContractRow, {
      id: contract.id.value,
      clientId: contract.clientId,
      status: contract.status,
      documentKey: contract.documentKey,
      version: contract.version + 1,
      signers: contract.signers.map((s) => this.em.create(SignerRow, s)),
    });
    const res = await this.em.createQueryBuilder()
      .update(ContractRow)
      .set(row)
      .where('id = :id AND version = :v',
             { id: contract.id.value, v: contract.version })
      .execute();
    if (res.affected === 0) throw new ConcurrencyError(contract.id);
  }

  private toDomain(r: ContractRow): Contract { /* ... mapping ... */ return new Contract(/* */); }
}

// src/contract/infra/typeorm-uow.ts
@Injectable()
export class TypeOrmUnitOfWork extends UnitOfWork {
  constructor(@InjectDataSource() private ds: DataSource, private events: EventBus) { super(); }

  async run<T>(work: (ctx: TxContext) => Promise<T>): Promise<T> {
    const collected: DomainEvent[] = [];
    const result = await this.ds.transaction(async (em) => {
      const ctx: TxContext = {
        contracts: new TypeOrmContractRepository(em),
        events: collected,
      };
      return work(ctx);
    });
    for (const evt of collected) await this.events.publish(evt);
    return result;
  }
}
ts
// src/contract/application/sign-contract.usecase.ts
@Injectable()
export class SignContractUseCase {
  constructor(private uow: UnitOfWork, private docs: DocumentStore) {}

  async execute(input: SignContractInput): Promise<void> {
    const pdf = await this.docs.get(input.documentKey);
    const hash = sha256(pdf);

    await this.uow.run(async (ctx) => {
      const contract = await ctx.contracts.findById(new ContractId(input.contractId));
      if (!contract) throw new NotFoundException();

      const signer = contract.signers.find((s) => s.id === input.signerId);
      if (!signer) throw new ForbiddenException();
      signer.signedAt = new Date();
      signer.hash = hash;

      const allSigned = contract.signers.every((s) => s.signedAt);
      if (allSigned) contract.markSigned();

      await ctx.contracts.save(contract);
      ctx.events.push(new ContractSignedEvent(contract.id.value, signer.id));
    });
  }
}
ts
// src/contract/contract.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([ContractRow, SignerRow])],
  providers: [
    { provide: ContractRepository, useClass: TypeOrmContractRepository },
    { provide: UnitOfWork,         useClass: TypeOrmUnitOfWork },
    { provide: DocumentStore,
      useClass: process.env.STORAGE === 's3' ? S3DocumentStore : MinioDocumentStore },
    SignContractUseCase,
  ],
})
export class ContractModule {}

L'use case SignContractUseCase n'importe AUCUNE classe TypeORM, AUCUN SDK S3 : il pourrait tourner en mémoire pure avec des fakes (InMemoryContractRepository, FakeDocumentStore). Le UnitOfWork collecte les événements et ne les publie qu'après commit, garantissant la cohérence "pas d'événement sans état persisté".

🔁 Quand utiliser / éviter

Utiliser Repository pattern :

  • Domaine complexe (entités riches, invariants métier forts).
  • Projet long-vécu (5+ ans).
  • Besoin de tester le service sans DB ni mock noisy.
  • Possibilité (théorique) de switcher d'ORM/store.

Éviter Repository pattern :

  • CRUD plat — Prisma offre déjà l'abstraction. Ajouter un repo c'est dupliquer.
  • POC / projet court.
  • Quand tu utilises des features ORM avancées (relations complexes, agrégats) que le repo cache et tu te retrouves à exposer 80% de l'API ORM.
  • Quand l'équipe n'est pas au fait du pattern et finit par mettre du métier dans le repo.

Compromis pragmatique :

  • Couche service qui utilise Prisma directement pour 80% des cas.
  • Repo pour 20% des cas critiques (aggregate roots DDD, lectures avec mapping complexe).
  • Specification pattern uniquement si 5+ règles de filtrage composables.

🧰 Exemples avancés

Repo transactional via ALS

ts
@Injectable()
export class PrismaTransactionalRepository<T> {
  constructor(
    protected readonly prisma: PrismaClient,
    protected readonly txCtx: TxContext,
    protected readonly modelName: keyof PrismaClient,
  ) {}

  protected get model(): any {
    const tx = this.txCtx.get();
    return tx ? (tx as any)[this.modelName] : (this.prisma as any)[this.modelName];
  }
}

@Injectable()
export class PrismaUsersRepository extends PrismaTransactionalRepository<User> implements UsersRepository {
  constructor(prisma: PrismaClient, txCtx: TxContext) { super(prisma, txCtx, 'user'); }
  findById(id: string) { return this.model.findUnique({ where: { id } }); }
  save(u: User)        { return u.id ? this.model.update({ where: { id: u.id }, data: u }) : this.model.create({ data: u }); }
  // ...
}

CQRS — Read model dédié

ts
// Write side : repo riche, entité, invariants
@Injectable()
export class UsersWriteRepository {
  async save(user: User) { /* ... */ }
  async findById(id: string): Promise<User> { /* ... */ }
}

// Read side : projections optimisées, plain DTO
@Injectable()
export class UsersReadRepository {
  constructor(private readonly prisma: PrismaService) {}

  async list(filter: { tenantId: string; q?: string; cursor?: string }) {
    return this.prisma.user.findMany({
      where: {
        tenantId: filter.tenantId,
        ...(filter.q ? { OR: [{ email: { contains: filter.q } }, { name: { contains: filter.q } }] } : {}),
      },
      select: { id: true, email: true, name: true, _count: { select: { posts: true } } },
      take: 20,
      ...(filter.cursor ? { cursor: { id: filter.cursor }, skip: 1 } : {}),
      orderBy: { id: 'asc' },
    });
  }

  async stats(tenantId: string) {
    return this.prisma.$queryRaw`
      SELECT DATE_TRUNC('day', created_at) AS day, COUNT(*)::int AS signups
      FROM users WHERE tenant_id = ${tenantId}
      GROUP BY day ORDER BY day DESC LIMIT 30
    `;
  }
}

Generic repo typesafe (Prisma)

ts
type Delegate = {
  findUnique: (args: any) => Promise<any>;
  findMany: (args: any) => Promise<any[]>;
  create: (args: any) => Promise<any>;
  update: (args: any) => Promise<any>;
  delete: (args: any) => Promise<any>;
};

export abstract class GenericPrismaRepository<T extends { id: string }> {
  constructor(protected readonly delegate: Delegate) {}
  findById(id: string): Promise<T | null> { return this.delegate.findUnique({ where: { id } }); }
  list(where: any = {}): Promise<T[]> { return this.delegate.findMany({ where }); }
  async save(entity: T): Promise<T> {
    return entity.id
      ? this.delegate.update({ where: { id: entity.id }, data: entity })
      : this.delegate.create({ data: entity });
  }
}

// Usage
@Injectable()
export class UsersRepo extends GenericPrismaRepository<User> {
  constructor(prisma: PrismaService) { super(prisma.user); }
}

🏭 Préoccupations de production

Un repo n'est pas qu'un objet de design — c'est la frontière naturelle où instrumenter, cacher, sécuriser et observer l'accès aux données. C'est précisément l'avantage qu'on perd en faisant de l'ORM partout.

Observabilité — le repo est le bon endroit pour tracer

Mets l'instrumentation dans l'adapter, pas dans le service (qui ne doit rien savoir de l'I/O). Un décorateur ou un proxy OpenTelemetry par méthode capte latence, erreurs et nombre de rows sans polluer le domaine.

ts
// infra/instrumented-users.repository.ts
@Injectable()
export class InstrumentedUsersRepository implements UsersRepository {
  constructor(
    private readonly inner: PrismaUsersRepository,
    private readonly tracer: Tracer,           // @opentelemetry/api
    private readonly metrics: MetricsService,
  ) {}

  async findActiveByTenant(tenantId: string): Promise<User[]> {
    return this.tracer.startActiveSpan('UsersRepository.findActiveByTenant', async (span) => {
      span.setAttribute('tenant.id', tenantId);
      const start = performance.now();
      try {
        const rows = await this.inner.findActiveByTenant(tenantId);
        span.setAttribute('result.count', rows.length);   // détecte les listes anormalement grosses
        return rows;
      } catch (e) {
        span.recordException(e as Error);
        span.setStatus({ code: SpanStatusCode.ERROR });
        throw e;
      } finally {
        this.metrics.histogram('repo.query.ms', performance.now() - start, {
          repo: 'users', method: 'findActiveByTenant',
        });
        span.end();
      }
    });
  }
  // ... délègue le reste à this.inner
}

Décorateur DI — câble-le en useFactory : { provide: USERS_REPOSITORY, useFactory: (real, tracer, m) => new InstrumentedUsersRepository(real, tracer, m), inject: [PrismaUsersRepository, Tracer, MetricsService] }. Le service ne voit que USERS_REPOSITORY : tu peux activer/désactiver l'instrumentation en prod/test sur une ligne.

Cache au niveau repo (cache-aside)

Le repo est l'endroit canonique pour un cache-aside (findById → Redis → DB). Clé de cache = identité de l'aggregate, invalidation sur save/delete. Ne jamais cacher au niveau service (tu cacherais une projection métier non versionnée) ni au niveau ORM (tu n'as pas la sémantique d'aggregate).

ts
async findById(id: string): Promise<User | null> {
  const cached = await this.cache.get(`user:${id}`);
  if (cached) return this.toDomain(JSON.parse(cached));
  const user = await this.inner.findById(id);
  if (user) await this.cache.set(`user:${id}`, JSON.stringify(this.toRow(user)), 'EX', 60);
  return user;
}
async save(user: User): Promise<User> {
  const saved = await this.inner.save(user);
  await this.cache.del(`user:${saved.id}`);   // invalidation, jamais "update du cache" (race)
  return saved;
}

Piège classique : write-through naïf (mettre à jour le cache au lieu de l'invalider) crée une race entre deux writes concurrents. Toujours DEL, jamais SET après mutation.

Sécurité — le tenant est une garde, pas un paramètre optionnel

Dans une app multi-tenant, la plus grosse faille est un findById(id) qui ne filtre pas par tenant : un attaquant énumère les UUID d'un autre tenant (IDOR). Le repo est la dernière barrière. Deux stratégies :

  • Tenant injecté par requête (ALS / scope REQUEST) : le repo lit tenantId depuis l'AsyncLocalStorage et l'ajoute à chaque where. Impossible d'oublier le filtre côté appelant.
  • Row-Level Security Postgres : SET app.current_tenant = $1 en début de transaction, policies RLS dans la DB. Le repo ne porte plus la garde, la DB l'impose. Le plus robuste pour la conformité (SOC2/RGPD).
ts
// repo tenant-aware via ALS — le service ne passe JAMAIS le tenantId
async findById(id: string): Promise<User | null> {
  const tenantId = this.tenantCtx.require();         // throw si absent → fail closed
  const row = await this.prisma.user.findFirst({ where: { id, tenantId } });
  return row ? this.toDomain(row) : null;
}

Modes de défaillance (failure modes)

ModeSymptômeCause racineMitigation
N+1 cachélatence qui explose avec la taille du tenantabstraction masque l'absence de JOINméthode batch findByIds/findUsersWithPosts, DataLoader, test compteur de requêtes
Mapping coûteuxCPU élevé sur les listes, GC pressuretoDomain() re-instancie une entité riche par rowread-model DTO plat pour les listes (CQRS), toDomain réservé aux writes
Tx ignoréewrites partiels, deadlocks intermittentsrepo capture le client global, ignore le txrepo construit avec l'EntityManager de la tx (UoW) ou lecture ALS
Fail open multi-tenantfuite cross-tenantfindById sans filtre tenanttenant via ALS/RLS, require() qui throw
InMemory divergenttests verts, bug prodsémantique filtre/sort ≠ SQL (collation, nulls)contract test partagé + testcontainers
Cache staledonnées obsolètes après writewrite-through au lieu d'invalidationDEL sur mutation, TTL court, version dans la clé
Connexion saturéetimeouts sous chargerepo scope REQUEST → instance par requête mais pool partagé OK ; ou transactions longues qui retiennent une connexionrepo singleton, transactions courtes, timeout statement_timeout

🤖 Servir des agents IA : le repo comme frontière de persistance

Quand NestJS sert ou orchestre des agents IA, le repository pattern devient central : une conversation LLM, une trace d'outils, un job de génération sont des aggregates avec des invariants (un message assistant ne peut pas précéder le user, une génération a un état machine pending → streaming → done|error|cancelled). C'est exactement le cas "domaine complexe, long-vécu" où le repo gagne.

Côté modèles, la gamme phare : claude-opus-4-8 (raisonnement/agents complexes), claude-sonnet-4-6 (équilibre prod), claude-haiku-4-5 (latence/coût). Utilise le SDK @anthropic-ai/sdk en streaming avec ses retries intégrés — voir la section "client DI'd" plus bas.

L'aggregate "Generation" et son repo

Le piège junior : écrire les tokens streamés directement en base à chaque chunk. À l'échelle, c'est un write par token → la DB explose. L'aggregate encapsule l'état ; le repo persiste les transitions, pas chaque delta.

ts
// domain/generation.entity.ts — entité PURE, aucun import SDK/ORM
export type GenStatus = 'pending' | 'streaming' | 'done' | 'error' | 'cancelled';

export class Generation {
  constructor(
    public readonly id: string,          // = idempotency key (generationId)
    public readonly conversationId: string,
    public status: GenStatus,
    public model: string,
    public promptTokens: number,
    public completionTokens: number,
    public costUsd: number,
    private buffer: string,              // texte partiel accumulé
  ) {}

  appendDelta(delta: string): void {
    if (this.status !== 'streaming' && this.status !== 'pending') {
      throw new DomainError('GEN_NOT_STREAMING');
    }
    this.status = 'streaming';
    this.buffer += delta;
  }
  complete(usage: { input: number; output: number }, costUsd: number): void {
    if (this.status === 'cancelled') return;            // cancel a déjà gagné la course
    this.status = 'done';
    this.promptTokens = usage.input;
    this.completionTokens = usage.output;
    this.costUsd = costUsd;
  }
  cancel(): void {
    if (this.status === 'done' || this.status === 'error') return;  // idempotent
    this.status = 'cancelled';
  }
  get partialText(): string { return this.buffer; }
}

// domain/generation.repository.ts
export abstract class GenerationRepository {
  abstract findById(id: string): Promise<Generation | null>;
  abstract save(gen: Generation): Promise<void>;
  // upsert idempotent : créer si absent, sinon retourner l'existant (retry-safe)
  abstract createIfAbsent(gen: Generation): Promise<{ created: boolean; gen: Generation }>;
}

Idempotence et partial-output : pourquoi ça passe par le repo

Un job IA (BullMQ) doit être idempotent : un retry après un timeout réseau ne doit pas relancer une génération facturée. La clé est l'id de l'aggregate = generationId. Le repo expose createIfAbsent qui s'appuie sur une contrainte d'unicité DB (la seule garantie fiable sous concurrence).

ts
// infra/prisma-generation.repository.ts
async createIfAbsent(gen: Generation) {
  try {
    const row = await this.prisma.generation.create({ data: this.toRow(gen) });
    return { created: true, gen: this.toDomain(row) };
  } catch (e) {
    if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
      // violation d'unicité → un autre worker a déjà pris ce generationId
      const existing = await this.findById(gen.id);
      return { created: false, gen: existing! };
    }
    throw e;
  }
}
ts
// jobs/generate.processor.ts — worker BullMQ
@Processor('ai-generation')
export class GenerateProcessor extends WorkerHost {
  constructor(
    private readonly repo: GenerationRepository,
    @Inject(LLM_CLIENT) private readonly llm: Anthropic,    // injecté, voir plus bas
    private readonly cost: CostGuard,
  ) { super(); }

  async process(job: Job<{ generationId: string; conversationId: string; prompt: string }>) {
    const { created, gen } = await this.repo.createIfAbsent(
      new Generation(job.data.generationId, job.data.conversationId, 'pending',
                     'claude-sonnet-4-6', 0, 0, 0, ''),
    );
    if (!created && gen.status === 'done') return gen.partialText;   // retry d'un job déjà fini

    await this.cost.assertWithinBudget(job.data.conversationId);     // cost-guard à l'edge

    const stream = await this.llm.messages.stream({
      model: gen.model,
      max_tokens: 4096,
      messages: [{ role: 'user', content: job.data.prompt }],
    });
    for await (const event of stream) {
      if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
        gen.appendDelta(event.delta.text);
      }
    }
    const final = await stream.finalMessage();
    gen.complete(
      { input: final.usage.input_tokens, output: final.usage.output_tokens },
      this.cost.priceOf(gen.model, final.usage),
    );
    await this.repo.save(gen);     // 1 write final, pas 1 par token
    return gen.partialText;
  }
}

Coût-aware retry — un retry BullMQ ne doit relancer le LLM que si gen.status !== 'done'. La persistance du partialText permet aussi, si tu veux, un resume (Anthropic ne reprend pas un stream, mais tu peux relancer en pré-remplissant le contexte avec le partiel). Le costUsd persisté alimente le cost-guard cumulatif par conversation/tenant.

Client LLM injecté (jamais new Anthropic() dans un champ)

Le new Anthropic() en propriété casse la testabilité (le repo/processor est intestable sans réseau), empêche le mock, et duplique la config (clé, timeout, retries) partout. Câble-le en forRootAsync comme n'importe quelle dépendance.

ts
// llm/llm.module.ts
export const LLM_CLIENT = Symbol('LLM_CLIENT');

@Module({})
export class LlmModule {
  static forRootAsync(): DynamicModule {
    return {
      module: LlmModule,
      global: true,
      providers: [{
        provide: LLM_CLIENT,
        inject: [ConfigService],
        useFactory: (config: ConfigService) => new Anthropic({
          apiKey: config.getOrThrow('ANTHROPIC_API_KEY'),
          maxRetries: 3,            // retries SDK intégrés (backoff exponentiel)
          timeout: 60_000,
        }),
      }],
      exports: [LLM_CLIENT],
    };
  }
}

En test, tu fournis { provide: LLM_CLIENT, useValue: fakeStreamingClient } — le processor tourne sans réseau, et ton GenerationRepository reste un InMemoryGenerationRepository. Le contract test (plus bas) vaut aussi pour le repo de génération.

AbortController : annuler côté serveur quand le client se déconnecte

Quand l'utilisateur clique "Stop" (ou ferme l'onglet), l'AbortController doit (a) couper le stream SDK, (b) marquer l'aggregate cancelled via le repo — sinon tu factures une génération que personne ne lit.

ts
@Sse('chat/:id/stream')
stream(@Param('id') id: string, @Req() req: Request): Observable<MessageEvent> {
  const ac = new AbortController();
  req.on('close', () => ac.signal.aborted || ac.abort());   // déconnexion client → cancel serveur

  return new Observable((subscriber) => {
    (async () => {
      const gen = await this.repo.findById(id) ?? /* createIfAbsent */;
      try {
        const stream = await this.llm.messages.stream(
          { model: gen.model, max_tokens: 4096, messages: [...] },
          { signal: ac.signal },                            // propage l'abort au SDK
        );
        for await (const ev of stream) {
          if (ev.type === 'content_block_delta' && ev.delta.type === 'text_delta') {
            gen.appendDelta(ev.delta.text);
            subscriber.next({ data: { delta: ev.delta.text } } as MessageEvent);
          }
        }
        gen.complete(/* usage */, /* cost */);
      } catch (e) {
        if (ac.signal.aborted) gen.cancel(); else gen.status = 'error';
      } finally {
        await this.repo.save(gen);                          // persiste l'état terminal final
        subscriber.complete();
      }
    })();
    return () => ac.abort();                                // unsubscribe → abort
  });
}

Le repo encapsule la règle "on persiste toujours un état terminal" (done/error/cancelled), jamais un aggregate bloqué en streaming. C'est l'invariant qu'un try/finally côté contrôleur seul ne garantirait pas proprement à travers plusieurs entrées (SSE, WS, BullMQ).

🏋️ Exercices

Progression du concret vers l'architecture. Code réel attendu, pas du pseudo.

Exercice 1 — Du couplage Prisma vers un port (échauffement)

On te donne un UsersService qui appelle PrismaService directement dans 6 méthodes (findUnique, findMany, create, update...). Le test unitaire actuel nécessite un vrai Postgres.

  1. Extrais une interface UsersRepository (port domaine) + un token USERS_REPOSITORY.
  2. Écris PrismaUsersRepository (adapter) avec un mapping toDomain/toRow explicite — aucune type Prisma ne doit fuir dans la signature publique.
  3. Écris InMemoryUsersRepository et réécris le test pour qu'il tourne sans Docker, en < 50 ms.
  4. Vérifie via Test.createTestingModule que le swap se fait sur une seule ligne de DI.

Critère de réussitegrep -r "prisma" src/users/application retourne 0 résultat. Le service ne connaît que l'interface.

Exercice 2 — Specification composable traduite en where ORM (intermédiaire)

Le repo expose aujourd'hui 9 méthodes : findActive, findActiveByTenant, findVerifiedByTenant, findActiveVerifiedByTenant... explosion combinatoire.

  1. Implémente une classe Specification<User> abstraite avec and/or/not et toQuery().
  2. Remplace les 9 méthodes par une seule findBySpec(spec: Specification<User>).
  3. Écris ActiveSpec, VerifiedSpec, TenantSpec(tenantId) et compose new ActiveSpec().and(new VerifiedSpec()).and(new TenantSpec(t)).
  4. Piège à résoudre : toQuery() produit du { AND: [...] } Prisma — comment éviter que le service voie cette syntaxe ? (Indice : la spec retourne un objet opaque, seul l'adapter sait le traduire.)
  5. Bonus : écris un toSql() alternatif (params positionnels $1, $2) pour prouver que la même spec marche sur TypeORM raw.

Critère de réussite — ajouter une 4ème règle de filtrage = 1 nouvelle classe Spec, 0 nouvelle méthode sur le repo.

Exercice 3 — Unit of Work + outbox transactionnel (avancé)

Un SignContractUseCase doit : (a) sauvegarder le contrat, (b) insérer un événement ContractSignedEvent dans une table outbox, (c) publier l'événement — le tout atomiquement, jamais d'événement sans état persisté.

  1. Implémente UnitOfWork.run(work => ...) qui ouvre une transaction TypeORM/Prisma et fournit un TxContext { contracts, events }.
  2. Le repo passé dans le ctx doit utiliser le même EntityManager/tx que la transaction (pas l'instance globale).
  3. Les événements collectés dans ctx.events ne sont publiés sur l'EventBus qu'après commit réussi.
  4. Ajoute l'optimistic locking : save fait un UPDATE ... WHERE id = :id AND version = :v et jette ConcurrencyError si affected === 0.
  5. Test à écrire : simule deux execute() concurrents sur le même contrat — exactement un doit réussir, l'autre doit lever ConcurrencyError, et aucun événement orphelin ne doit rester dans l'outbox.

Critère de réussite — un throw injecté après contracts.save() mais avant la fin de la transaction laisse 0 ligne en base ET 0 événement publié.

Exercice 4 — Contract test partagé entre adapters (architecte)

Tu as 3 implémentations de UsersRepository : InMemory, Prisma, TypeORM. Elles divergent subtilement (null ordering, collation, casse de l'email).

  1. Écris une suite describe.each paramétrée par [name, factory] qui s'exécute contre les 3 adapters (Prisma/TypeORM via testcontainers).
  2. Couvre au minimum : findById inconnu → null, unicité email case-insensitive, ordre stable de findActiveByTenant, idempotence de save.
  3. Trouve et corrige au moins une divergence réelle (ex : l'InMemory compare l'email en sensible à la casse alors que Postgres a un index citext).
  4. Câble la suite en CI : les 3 adapters doivent passer le même fichier de test.

Critère de réussite — le swap d'adapter en prod est prouvé sûr par un test rouge AVANT le déploiement, pas par un incident.

Exercice 5 — Casse-le puis répare-le : le repo qui fuit cross-tenant (sécurité)

On te donne un OrdersRepository multi-tenant où findById(id) fait prisma.order.findUnique({ where: { id } })sans filtre tenant. Le service le passe à un endpoint GET /orders/:id.

  1. Casse-le : écris un test qui prouve l'IDOR — le tenant B lit une commande du tenant A en devinant son UUID. Le test doit être vert (la faille existe).
  2. Répare-le par ALS : introduis un TenantContext (AsyncLocalStorage) rempli par un middleware/interceptor, et fais lire tenantId au repo. findById devient findFirst({ where: { id, tenantId } }), avec require() qui throw si le tenant est absent (fail closed, jamais fail open).
  3. Le test IDOR doit maintenant échouer côté attaquant (404, pas la commande).
  4. Bonus production-grade : migre la garde vers Postgres Row-Level Security (SET app.current_tenant, policy USING (tenant_id = current_setting('app.current_tenant')::uuid)). Prouve qu'une requête brute hors repo est aussi bloquée par la DB.

Critère de réussite — supprimer le filtre tenant dans le code du repo NE rouvre PAS la faille (RLS l'attrape en defense-in-depth). Le repo et la DB sont deux barrières indépendantes.

Exercice 6 — Repo d'aggregate IA idempotent sous concurrence (staff / AI)

Tu sers des générations LLM via BullMQ. Un timeout réseau déclenche un retry du job pendant que le premier worker stream encore.

  1. Implémente GenerationRepository.createIfAbsent adossé à une contrainte d'unicité sur generationId (pas un findFirst + create, qui a une race TOCTOU).
  2. Implémente le GenerateProcessor : il ne relance le LLM que si gen.status !== 'done', persiste un seul write final (pas un par token), et stocke costUsd + usage.
  3. Casse-le : lance 2 workers concurrents sur le même generationId avec un faux client LLM lent. Sans la contrainte d'unicité, prouve qu'on facture deux générations (double complete).
  4. Répare-le : avec createIfAbsent (P2002 → retourne l'existant), prouve qu'exactement une génération est facturée, l'autre worker no-op.
  5. Annulation : ajoute un AbortController ; à la déconnexion client, le stream est coupé ET gen.cancel() est persisté. Prouve qu'aucun aggregate ne reste bloqué en streaming (invariant "toujours un état terminal").

Critère de réussite — sous N retries concurrents, SELECT count(*) FROM generation WHERE conversation_id = X ne croît jamais au-delà des générations réellement demandées, et SUM(cost_usd) ne double jamais.

🎤 En entretien

Questions typiques niveau senior/staff et la façon de répondre sans tomber dans le dogme.

« C'est quoi le Repository pattern, et pourquoi ? »

Une abstraction qui place une interface domaine entre le use-case et la persistance. Le service parle entités/value objects (findActiveByTenant), l'adapter parle ORM (rows, query builder). Gains : testabilité sans DB, isolation du domaine, swap d'ORM théorique. Le réflexe senior c'est d'enchaîner sur le coût : indirection, duplication, perte des features ORM riches.

« Quand NE PAS l'utiliser ? »

CRUD plat où Prisma/Drizzle abstrait déjà très bien — y ajouter un repo c'est dupliquer sans valeur. POC, projet court, ou équipe qui finit par mettre du métier dans le repo. L'argument « pouvoir switcher d'ORM » est presque toujours invoqué et presque jamais utilisé : si le switch arrive 1 fois en 10 ans, le coût quotidien dépasse le gain. Le bon dosage : Prisma direct pour 80% des cas, repo pour les 20% critiques (aggregate roots, mappings complexes).

« Repository générique : bonne ou mauvaise idée ? »

Pratique pour le CRUD répétitif mais dangereux : il pousse à exposer la syntaxe ORM (findMany({ where })), ce qui leak l'implémentation et tue le bénéfice de l'abstraction. Acceptable comme base technique interne (GenericPrismaRepository<T>), jamais comme API publique du domaine. Si tu te retrouves à passer des options ORM au repo, l'abstraction a échoué.

« Comment tu propages une transaction à travers un repo ? »

Le piège classique : le repo capture une instance Prisma/DataSource globale et ignore le tx. Deux solutions propres : (1) Unit of Work qui construit le repo avec l'EntityManager de la transaction et le passe via un TxContext ; (2) AsyncLocalStorage — le repo lit le tx courant depuis l'ALS, transparence totale pour l'appelant. Voir 04-transactions.md.

« Specification pattern, tu t'en sers ? »

Pour composer des règles de filtrage réutilisables (active.and(verified).and(tenant)) au lieu d'exploser le repo en findByXAndYAndZ. Mais c'est over-engineered en dessous de ~5 règles composables — pour 2-3 conditions, un objet criteria plat suffit. Le point dur : la spec doit retourner un objet opaque traduit par l'adapter, sinon elle leak le where ORM.

« Comment tu garantis que tes adapters sont interchangeables ? »

Un contract test unique exécuté via describe.each contre tous les adapters (InMemory + Prisma + TypeORM en testcontainers). Il attrape les divergences sémantiques sournoises : null ordering, collation, casse de l'email — celles qui donnent des tests verts en InMemory et des bugs en prod. C'est la preuve que le swap est sûr.

« Repo qui réintroduit du N+1 — comment tu le détectes ? »

Symptôme : le service fait findById puis boucle des findPostsByUser(id). L'abstraction a caché le JOIN que l'ORM brut aurait fait. On l'attrape avec un compteur de requêtes en test (prisma.$on('query')) ou un log de queries, et on corrige en exposant une méthode batch au niveau repo (findUsersWithPosts(ids) ou un DataLoader pour GraphQL).

« Où mettrais-tu cache / tracing / métriques d'accès aux données ? »

Dans l'adapter (le repo), jamais dans le service. Le repo est la frontière I/O : un décorateur OpenTelemetry par méthode capte latence/erreurs/result.count sans polluer le domaine, câblé en useFactory pour pouvoir le désactiver en test. Le cache-aside vit aussi là (clé = identité d'aggregate, invalidation sur writeDEL, jamais SET, sinon race entre deux writes concurrents).

« Un repo multi-tenant : comment tu évites une fuite cross-tenant ? »

Le findById(id) sans filtre tenant est la faille IDOR classique. Le tenant n'est pas un paramètre optionnel de l'appelant : il est lu depuis un AsyncLocalStorage (rempli par interceptor) et ajouté à chaque where dans le repo, avec un require() qui throw si absent (fail closed). En defense-in-depth, je double avec Postgres RLS (SET app.current_tenant + policy) : même une requête brute hors repo est bloquée par la DB.

« Tu sers des générations LLM — pourquoi un repo et pas du Prisma direct ? »

Parce qu'une génération est un aggregate avec une state machine (pending → streaming → done|error|cancelled) et des invariants : on persiste un seul write final (pas un par token), on garantit toujours un état terminal, et l'idempotence du job (clé = generationId) repose sur createIfAbsent adossé à une contrainte d'unicité DB — la seule garantie fiable sous retries concurrents. Le repo encapsule ces règles ; le contrôleur SSE et le worker BullMQ partagent le même invariant. Le client Anthropic est injecté en forRootAsync (retries SDK, mockable), jamais new Anthropic() dans un champ.

Pièges où on attend que tu rebondisses — « le repo doit cacher TOUT l'ORM » (faux, parfois une leak abstraction assumée vaut mieux que perdre _count/$facet) ; « un repo par table » (non : un repo par aggregate root, sinon tu perds les invariants) ; « cacher au niveau service » (non : le service cacherait une projection métier non versionnée — le cache vit au repo, clé = identité d'aggregate).

🔗 Liens

Bibliothèque tech perso — Achref