Le problème
Dans beaucoup de projets, le repository finit par devenir une abstraction vague autour de la base de données.
On y met un peu de tout :
- des requêtes CRUD
- du filtrage métier
- du mapping
- de la logique applicative
- des optimisations SQL
- parfois même des décisions métier
Au bout d’un moment, le repository ne représente plus une frontière claire. Il devient juste un endroit pratique où stocker du code lié aux données.
On reconnaît vite les symptômes :
- un repository expose 25 méthodes spécialisées
- les use cases dépendent de détails de persistence
- le domaine ressemble au schéma SQL
- les aggregates sont chargés partiellement sans règle claire
- la logique métier glisse dans les repositories “par commodité”
Le problème n’est pas d’avoir un repository. Le problème est de le traiter comme un simple “wrapper de base de données”.
L’idée simple
Un repository est d’abord une frontière du domaine.
Il sert à donner au cœur du système un moyen de :
- récupérer un aggregate utile
- sauvegarder un aggregate modifié
Sans exposer directement :
- le schéma SQL
- l’ORM
- les détails de jointure
- les contraintes du driver
- les objets techniques de persistence
Autrement dit :
- le domaine ou la couche application exprime le besoin
- le repository fournit un accès cohérent à l’aggregate
- la persistence reste un détail d’implémentation
Le point important est simple :
un repository ne représente pas une table. Il représente un accès utile au modèle métier.
Comment ça fonctionne
1. Le repository parle le langage du domaine
Un repository utile expose des opérations qui ont du sens côté métier ou côté application.
Exemples :
findByIdsavefindActiveSubscriptionByCustomerIdfindInvoiceReadyForPayment
Ce qu’on cherche à éviter, ce sont les repositories qui exposent surtout le langage de la base ou du moteur d’accès aux données.
Exemples plus douteux :
findAllWithJoinOnCustomerAndPlanupdateStatusAndFlushrunRawInvoiceAggregationQuery
Ce type de méthode n’est pas toujours illégitime, mais il révèle souvent que la frontière est mal tenue.
2. Le repository sert souvent à charger un aggregate
Dans beaucoup de modèles, le repository est le point d’accès à un aggregate via sa racine.
Exemple :
export interface OrderRepository {
findById(orderId: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
L’idée ici est simple :
- on charge un aggregate cohérent
- on laisse le métier le modifier
- on le sauvegarde ensuite
Le repository n’est pas là pour exposer chaque détail interne indépendamment.
3. Le mapping appartient au bord
Dans une architecture hexagonale, un repository concret doit absorber le décalage entre :
- le modèle du domaine
- le modèle de persistence
Par exemple :
- plusieurs tables SQL peuvent représenter un seul aggregate
- certaines colonnes ne correspondent pas directement aux objets métier
- des objets valeur doivent être reconstruits
- des champs techniques ne doivent pas remonter dans le domaine
Le mapping fait partie du prix normal d’une frontière saine.
Schéma
Lecture utile :
- l’application dépend d’un contrat
- le repository concret dépend de ce contrat
- la base reste derrière la frontière
- le domaine ne dépend pas directement de la persistence
Exemple concret
Prenons un cas simple : confirmer une commande.
Le use case a besoin de :
- charger la commande
- appliquer une décision métier
- sauvegarder le nouvel état
Contrat côté application ou domaine
export interface OrderRepository {
findById(orderId: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
Use case
export class ConfirmOrderService {
constructor(
private readonly orderRepository: OrderRepository
) {}
async execute(orderId: string): Promise<void> {
const order = await this.orderRepository.findById(orderId);
if (!order) {
throw new Error("Order not found");
}
order.confirm();
await this.orderRepository.save(order);
}
}
Ici, le use case n’a pas besoin de connaître :
- Prisma
- SQL
- les tables
- les joins
- les entités ORM
Il parle simplement en aggregate.
Implémentation concrète
export class PgOrderRepository implements OrderRepository {
async findById(orderId: string): Promise<Order | null> {
// Lecture SQL ou ORM
// Reconstruction de l'aggregate Order
return null;
}
async save(order: Order): Promise<void> {
// Mapping Order -> tables SQL
// Persistance
}
}
Le repository concret contient le détail technique. Le use case, lui, reste centré sur le scénario métier.
Ce qu’un repository n’est pas
Pour garder la frontière saine, il faut éviter trois confusions.
Ce n’est pas un service métier
Un repository ne devrait pas décider :
- si une commande est confirmable
- si une remise est valide
- si une facture peut être payée
Ces décisions appartiennent au domaine.
Ce n’est pas un fourre-tout de requêtes
Un repository qui expose des dizaines de méthodes très spécialisées devient vite une fuite de la base vers l’application.
Exemple :
export interface OrderRepository {
findById(orderId: string): Promise<Order | null>;
save(order: Order): Promise<void>;
findAllByStatus(status: string): Promise<Order[]>;
findWithCustomerAndLines(orderId: string): Promise<Order | null>;
updateStatus(orderId: string, status: string): Promise<void>;
deleteById(orderId: string): Promise<void>;
findOrdersCreatedBetween(start: Date, end: Date): Promise<Order[]>;
}
Ce type d’interface n’est pas toujours faux, mais il devient vite un mélange de besoins métier, de besoins admin et de détails d’accès aux données.
Ce n’est pas forcément l’abstraction idéale pour toute lecture
Pour certaines lectures simples, projections ou écrans de reporting, un repository orienté aggregate n’est pas toujours le bon outil.
Il faut éviter de forcer tout accès aux données à passer par le même modèle si ce n’est pas utile.
Le repository est surtout pertinent quand on travaille sur la cohérence d’un aggregate.
Repository et aggregate
Le lien entre repository et aggregate est important.
En pratique, un repository sert souvent à :
- récupérer un aggregate via sa racine
- le rendre disponible pour une décision métier
- persister la nouvelle version cohérente de cet aggregate
Cela aide à garder un modèle où :
- l’aggregate protège les invariants
- le repository protège la frontière de persistence
C’est une répartition saine.
Exemple avec value object
Imaginons une facture contenant un montant métier.
Domaine
export class Money {
constructor(
public readonly amount: number,
public readonly currency: string
) {
if (amount < 0) {
throw new Error("Amount cannot be negative");
}
}
}
export class Invoice {
constructor(
public readonly id: string,
private total: Money,
private status: "draft" | "issued" | "paid"
) {}
markAsPaid(): void {
if (this.status !== "issued") {
throw new Error("Only an issued invoice can be paid");
}
this.status = "paid";
}
}
Repository concret
export class PgInvoiceRepository {
async findById(invoiceId: string): Promise<Invoice | null> {
const row = await db.query(
"select id, total_amount, total_currency, status from invoices where id = $1",
[invoiceId]
);
if (!row) {
return null;
}
return new Invoice(
row.id,
new Money(row.total_amount, row.total_currency),
row.status
);
}
}
Ici, le repository reconstruit le modèle métier à partir de la persistence. Il joue bien son rôle de frontière.
Quand garder le repository simple
Un bon repository est souvent plus petit qu’on ne le pense.
Très souvent, les opérations de base suffisent :
findByIdsave
Parfois quelques méthodes supplémentaires font sens :
- chercher un aggregate par clé métier
- retrouver un aggregate dans un état précis
- vérifier l’existence d’un doublon métier
Il vaut mieux partir petit et ajouter seulement ce qui sert réellement aux scénarios du système.
Exemple de structure simple
src/
order/
domain/
order.ts
application/
confirm-order/
confirm-order.service.ts
order-repository.port.ts
infrastructure/
persistence/
pg-order-repository.ts
Le point important n’est pas le nom exact des fichiers. Le point important est que :
- le contrat vit près du cœur
- l’implémentation concrète vit au bord
- le domaine reste indépendant de la base
Points importants
- Un repository est une frontière du domaine, pas un simple wrapper CRUD.
- Il sert souvent à charger et sauvegarder des aggregates.
- Le contrat doit parler le langage du domaine ou du cas d’usage.
- Le mapping entre persistence et domaine appartient au bord.
- Un repository ne doit pas devenir un fourre-tout de logique métier ou de requêtes spécialisées.
- Toutes les lectures ne doivent pas forcément passer par un repository orienté aggregate.
Erreurs fréquentes
Faire un repository par table
C’est souvent le signe qu’on part du schéma SQL au lieu de partir du modèle métier.
Mettre la logique métier dans le repository
Par exemple :
- changer un statut métier directement en base
- appliquer une règle métier dans une requête
- valider des transitions d’état dans la couche persistence
Le repository doit persister, pas décider.
Exposer des types ORM au reste du système
Si le use case ou le domaine manipule directement des objets Prisma, TypeORM ou Sequelize, la frontière est déjà percée.
Faire un repository énorme
Un repository avec trop de méthodes révèle souvent :
- un aggregate mal défini
- des besoins de lecture mélangés
- une frontière peu claire
Vouloir tout faire passer par le même modèle
Pour du reporting, de la lecture optimisée ou des projections simples, il peut être plus sain d’utiliser d’autres mécanismes.
Conclusion
Un repository utile ne sert pas juste à lire et écrire dans une base. Il sert à protéger la frontière entre le modèle métier et la persistence.
Le réflexe utile à retenir est simple :
un repository doit exposer ce dont le métier a besoin, pas ce que la base de données rend facile.