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.
| Niveau | Forme | Couplage ORM | Testable sans DB | Coût | Quand |
|---|---|---|---|---|---|
| 0 | ORM direct dans le service | total | non (ou mock noisy) | nul | CRUD plat, POC, script |
| 1 | Service + Prisma, entités plain | total | non | nul | 80% des apps métier raisonnables |
| 2 | Repo "thin" typé (CRUD, retours = types ORM) | élevé | partiel | faible | mutualiser des requêtes, pas du DDD |
| 3 | Repo = port domaine (interface + mapping toDomain/toRow) | nul côté domaine | oui (InMemory) | moyen | aggregate roots, domaine long-vécu |
| 4 | Repo + Specification + Unit of Work | nul | oui | é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
| Gain | Coût correspondant |
|---|---|
| Testabilité sans DB (InMemory) | InMemory peut diverger de la sémantique SQL (voir pitfall #7) |
| Domaine isolé de l'infra | Mapping toDomain/toRow à écrire et maintenir |
| Swap d'ORM théorique | Quasi 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/tracing | Indirection : un bug se cherche sur 2 couches |
| Features ORM riches encapsulées | …ou perdues (_count, $facet, loadRelationCountAndMap) |
🛠️ Code minimal
Interface domaine + impl Prisma
// 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');// 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 }; }
}// 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)
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
// 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
- 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). - Repo + service anémique — repo expose CRUD typed, service contient les use-cases. Plus pragmatique pour la majorité des projets.
- Specification — pour des règles de filtrage composables et réutilisables. Au-dessus du repo qui expose
findBySpec(spec)traduisant en where ORM. - 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".
- 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. - InMemory repo pour les tests — implémenter l'interface en mémoire, swap dans
Test.createTestingModule. Évite le mock noisy. - 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.
- Idempotent save —
save(entity)checksentity.id; si présent, update ; sinon insert. Toujours typer le retour pour que l'id soit présent après. - DataLoader pattern — pour batching/caching des
findByIdà l'intérieur d'une requête (GraphQL). Le repo exposefindByIds(ids[])que le DataLoader appelle. - 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
| Version | Notes |
|---|---|
| Nest 7 | DI par symbol/token déjà standard. |
| Nest 8 | InjectionToken plus typé. |
| Nest 9 | Pas de changement spécifique repo. |
| Nest 10 | @nestjs/cqrs 10+ propose des patterns "read model" intégrés. |
| Nest 11 | Compat 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
- Repo generic qui leak l'ORM —
repo.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". - Repo trop large — 20 méthodes
findByX,findByXAndY, etc. Symptôme : explosion combinatoire. Préférer un Specification pattern ou exposerfindBy(criteria). - 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. - Repo qui fait du métier — un
repo.activate(id)qui changestatuspuis sauvegarde fait du métier dans la couche infra. Garder dans service / entité. - Specification trop abstraite — pour 2-3 conditions, c'est over-engineered. Réserver aux domaines avec 10+ règles composables.
- Repo + transaction propagation cassée — le repo capture une instance Prisma globale et ignore les
tx. Solution : injecter via factory en lisantALS(voir04-transactions.md). - 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.
- 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.
- 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. - 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.
- N+1 réintroduit par l'abstraction — le service appelle
repo.findById(id)puis pour chaque userrepo.findPostsByUser(u.id). L'ORM brut aurait pu faire un JOIN. Solution : exposerfindUsersWithPosts(ids)au niveau repo. - 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
// 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);
});// 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.
// 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.
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.
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.
// 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[];
}// 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;
}
}// 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));
});
}
}// 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
@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é
// 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)
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.
// 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 queUSERS_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).
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
tenantIddepuis l'AsyncLocalStorageet l'ajoute à chaquewhere. Impossible d'oublier le filtre côté appelant. - Row-Level Security Postgres :
SET app.current_tenant = $1en 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).
// 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)
| Mode | Symptôme | Cause racine | Mitigation |
|---|---|---|---|
| N+1 caché | latence qui explose avec la taille du tenant | abstraction masque l'absence de JOIN | méthode batch findByIds/findUsersWithPosts, DataLoader, test compteur de requêtes |
| Mapping coûteux | CPU élevé sur les listes, GC pressure | toDomain() re-instancie une entité riche par row | read-model DTO plat pour les listes (CQRS), toDomain réservé aux writes |
| Tx ignorée | writes partiels, deadlocks intermittents | repo capture le client global, ignore le tx | repo construit avec l'EntityManager de la tx (UoW) ou lecture ALS |
| Fail open multi-tenant | fuite cross-tenant | findById sans filtre tenant | tenant via ALS/RLS, require() qui throw |
| InMemory divergent | tests verts, bug prod | sémantique filtre/sort ≠ SQL (collation, nulls) | contract test partagé + testcontainers |
| Cache stale | données obsolètes après write | write-through au lieu d'invalidation | DEL sur mutation, TTL court, version dans la clé |
| Connexion saturée | timeouts sous charge | repo scope REQUEST → instance par requête mais pool partagé OK ; ou transactions longues qui retiennent une connexion | repo 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/sdken 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.
// 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).
// 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;
}
}// 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 dupartialTextpermet aussi, si tu veux, un resume (Anthropic ne reprend pas un stream, mais tu peux relancer en pré-remplissant le contexte avec le partiel). LecostUsdpersisté 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.
// 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.
@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.
- Extrais une interface
UsersRepository(port domaine) + un tokenUSERS_REPOSITORY. - Écris
PrismaUsersRepository(adapter) avec un mappingtoDomain/toRowexplicite — aucune type Prisma ne doit fuir dans la signature publique. - Écris
InMemoryUsersRepositoryet réécris le test pour qu'il tourne sans Docker, en < 50 ms. - Vérifie via
Test.createTestingModuleque le swap se fait sur une seule ligne de DI.
Critère de réussite — grep -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.
- Implémente une classe
Specification<User>abstraite avecand/or/notettoQuery(). - Remplace les 9 méthodes par une seule
findBySpec(spec: Specification<User>). - Écris
ActiveSpec,VerifiedSpec,TenantSpec(tenantId)et composenew ActiveSpec().and(new VerifiedSpec()).and(new TenantSpec(t)). - 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.) - 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é.
- Implémente
UnitOfWork.run(work => ...)qui ouvre une transaction TypeORM/Prisma et fournit unTxContext { contracts, events }. - Le repo passé dans le
ctxdoit utiliser le mêmeEntityManager/txque la transaction (pas l'instance globale). - Les événements collectés dans
ctx.eventsne sont publiés sur l'EventBusqu'après commit réussi. - Ajoute l'optimistic locking :
savefait unUPDATE ... WHERE id = :id AND version = :vet jetteConcurrencyErrorsiaffected === 0. - Test à écrire : simule deux
execute()concurrents sur le même contrat — exactement un doit réussir, l'autre doit leverConcurrencyError, 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).
- Écris une suite
describe.eachparamétrée par[name, factory]qui s'exécute contre les 3 adapters (Prisma/TypeORM via testcontainers). - Couvre au minimum :
findByIdinconnu →null, unicité email case-insensitive, ordre stable defindActiveByTenant, idempotence desave. - 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). - 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.
- 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).
- Répare-le par ALS : introduis un
TenantContext(AsyncLocalStorage) rempli par un middleware/interceptor, et fais liretenantIdau repo.findByIddevientfindFirst({ where: { id, tenantId } }), avecrequire()qui throw si le tenant est absent (fail closed, jamais fail open). - Le test IDOR doit maintenant échouer côté attaquant (404, pas la commande).
- Bonus production-grade : migre la garde vers Postgres Row-Level Security (
SET app.current_tenant, policyUSING (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.
- Implémente
GenerationRepository.createIfAbsentadossé à une contrainte d'unicité surgenerationId(pas unfindFirst+create, qui a une race TOCTOU). - Implémente le
GenerateProcessor: il ne relance le LLM que sigen.status !== 'done', persiste un seul write final (pas un par token), et stockecostUsd+usage. - Casse-le : lance 2 workers concurrents sur le même
generationIdavec un faux client LLM lent. Sans la contrainte d'unicité, prouve qu'on facture deux générations (doublecomplete). - Répare-le : avec
createIfAbsent(P2002 → retourne l'existant), prouve qu'exactement une génération est facturée, l'autre worker no-op. - Annulation : ajoute un
AbortController; à la déconnexion client, le stream est coupé ETgen.cancel()est persisté. Prouve qu'aucun aggregate ne reste bloqué enstreaming(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/
DataSourceglobale et ignore letx. Deux solutions propres : (1) Unit of Work qui construit le repo avec l'EntityManagerde la transaction et le passe via unTxContext; (2) AsyncLocalStorage — le repo lit letxcourant depuis l'ALS, transparence totale pour l'appelant. Voir04-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 enfindByXAndYAndZ. Mais c'est over-engineered en dessous de ~5 règles composables — pour 2-3 conditions, un objetcriteriaplat suffit. Le point dur : la spec doit retourner un objet opaque traduit par l'adapter, sinon elle leak lewhereORM.
« Comment tu garantis que tes adapters sont interchangeables ? »
Un contract test unique exécuté via
describe.eachcontre 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
findByIdpuis boucle desfindPostsByUser(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.countsans polluer le domaine, câblé enuseFactorypour pouvoir le désactiver en test. Le cache-aside vit aussi là (clé = identité d'aggregate, invalidation sur write —DEL, jamaisSET, 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 unAsyncLocalStorage(rempli par interceptor) et ajouté à chaquewheredans le repo, avec unrequire()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 surcreateIfAbsentadossé à 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é enforRootAsync(retries SDK, mockable), jamaisnew 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
- Domain-Driven Design — Eric Evans
- Repository pattern — Martin Fowler
- Specification pattern — Wikipedia
- @nestjs/cqrs
- DataLoader — batching et caching à l'échelle d'une requête.
- Voir
01-typeorm.md,02-prisma.mdpour les APIs ORM,04-transactions.mdpour la propagation tx dans un repo.