Le Repository Pattern
Generics · Interfaces · Classes · Erreurs â tout converge
1. Combiner la semaine
Generics, interfaces, classes typĂ©es, enums, erreurs â en un mini-projet
2. Ăcrire Repository<T>
L'interface générique : save · findById · findAll · delete
3. Implémenter InMemoryRepository<T>
Avec une Map<string, T> comme stockage interne
4. Tester avec Task ET Contact
MĂȘme code, types diffĂ©rents â la puissance des generics
5. Comprendre la Clean Architecture
Changer le stockage sans toucher Ă la logique mĂ©tier â le service dĂ©pend du contrat
1. Recap de la semaine
Generics, contraintes, classes, enums, erreurs â tout converge
2. Le problĂšme
Sauvegarder/retrouver/supprimer â opĂ©rations toujours identiques
3. HasId + Repository<T>
La contrainte et l'interface générique
4. InMemoryRepository<T>
Implémentation complÚte avec Map
5. Tester avec Task & Contact
MĂȘme repository, types diffĂ©rents
6. "Et si on change de stockage ?"
Une classe change, le reste ne bouge pas
Recap de la semaine
Tout ce qu'on a appris converge ici
Chaque concept de la semaine est un ingrédient du pattern d'aujourd'hui.
đ§©
Generics
<T> pour écrire du code réutilisable
đ
Contraintes
T extends HasId pour garantir un contrat
đ
Interfaces
implements = respecter le contrat
đ·ïž
Enums
Nommer les statuts et catégories
â ïž
Erreurs custom
NotFoundError · messages clairs
đïž
Repository<T>
Tout ça ensemble = Clean Architecture
Le problĂšme
Les mĂȘmes opĂ©rations, encore et encore
Sans pattern, on duplique tout pour chaque entité.
đ Pour les tĂąches
// Stocker
let tasks: Task[] = []
function saveTask(t: Task) {...}
function findTaskById(id: string) {...}
function deleteTask(id: string) {...}
đ€ Pour les contacts
// MĂȘme code, autre type !
let contacts: Contact[] = []
function saveContact(c: Contact) {...}
function findContactById(id: string) {...}
function deleteContact(id: string) {...}
â ïž Le problĂšme
La logique est exactement la mĂȘme. Seul le type change. C'est exactement le cas d'usage des generics.
On définit un seul contrat pour toutes les entités.
Le Repository Pattern
Le contrat
save(entity: T): void
findById(id: string): T | undefined
findAll(): T[]
delete(id: string): void
Les implémentations
HasId & Repository<T>
La contrainte et l'interface générique
Pour stocker et retrouver un objet, il faut un identifiant. On l'impose avec une contrainte.
// Toute entité manipulée par le Repository DOIT avoir un id
interface HasId {
id: string;
}
// Exemples d'entités qui satisfont HasId
interface Task {
id: string; // â
satisfait HasId
title: string;
done: boolean;
}
interface Contact {
id: string; // â
satisfait HasId
name: string;
email: string;
}
// T extends HasId : TypeScript garantit que T a toujours .id
// Sans ça, on ne pourrait pas faire store.set(entity.id, entity) !
đĄ Pourquoi string et pas number ?
Un string est plus flexible : on peut utiliser des UUIDs, des slugs, des IDs préfixés ("task-1", "user-abc"...). C'est la convention moderne.
Le contrat générique : toute implémentation devra respecter ces 4 méthodes.
interface Repository<T extends HasId> {
save(entity: T): void;
findById(id: string): T | undefined;
findAll(): T[];
delete(id: string): void;
}
save(entity: T): void
Ajoute ou met à jour une entité. Si l'id existe déjà , on écrase.
findById(id): T | undefined
Retourne l'entité ou undefined si elle n'existe pas.
findAll(): T[]
Retourne toutes les entités stockées sous forme de tableau.
delete(id: string): void
Supprime l'entité avec cet id. Ne fait rien si elle n'existe pas.
Décomposer la signature Repository<T extends HasId> pas à pas.
interface Repository<T extends HasId>
T est un paramĂštre de type. La contrainte extends HasId garantit que T a toujours un champ id: string.
save(entity: T): void
Reçoit un objet de type T (ex: Task, Contact). TypeScript s'assure qu'on ne passe pas un mauvais type.
findById(id: string): T | undefined
Le type de retour est T | undefined. Si l'id n'existe pas, on retourne undefined plutĂŽt que de lancer une exception.
findAll(): T[]
Retourne un tableau typé. Si c'est un Repository<Task>, on obtient Task[]. Pas d'any !
InMemoryRepository<T>
Implémenter le contrat avec une Map
Map<K, V> est un dictionnaire clĂ©âvaleur. Quatre mĂ©thodes suffisent.
const store = new Map<string, Task>();
// .set(key, value) â ajouter ou Ă©craser
store.set("task-1", { id: "task-1", title: "Apprendre TS", done: false });
// .get(key) â lire (retourne undefined si absent)
const task = store.get("task-1"); // Task | undefined
// .has(key) â vĂ©rifier l'existence
store.has("task-1"); // true
store.has("task-99"); // false
// .delete(key) â supprimer
store.delete("task-1"); // true si existait, false sinon
// .values() â itĂ©rer sur les valeurs
const allTasks = [...store.values()]; // Task[]
đșïž Map vs Object
Map est prĂ©fĂ©rĂ© Ă {} pour les dictionnaires dynamiques : les clĂ©s peuvent ĂȘtre n'importe quel type, l'ordre est garanti, et les mĂ©thodes sont sĂ©mantiques.
class InMemoryRepository<T extends HasId> implements Repository<T> {
private store = new Map<string, T>();
save(entity: T): void {
this.store.set(entity.id, entity);
}
findById(id: string): T | undefined {
return this.store.get(id);
}
findAll(): T[] {
return [...this.store.values()];
}
delete(id: string): void {
this.store.delete(id);
}
}
â Pourquoi private store ?
La Map est un dĂ©tail d'implĂ©mentation. L'extĂ©rieur ne doit pas y accĂ©der directement â il doit passer par les mĂ©thodes du contrat.
save(entity: T): void â this.store.set(entity.id, entity)
GrĂące Ă T extends HasId, TypeScript sait que entity.id existe. Sans la contrainte : erreur de compilation.
findById(id): T | undefined â this.store.get(id)
Map.get() retourne dĂ©jĂ T | undefined â le type correspond exactement Ă la signature de l'interface.
findAll(): T[] â [...this.store.values()]
store.values() retourne un IterableIterator<T>. Le spread [...] le convertit en tableau T[].
delete(id): void â this.store.delete(id)
Si l'id n'existe pas, Map.delete() ne lance pas d'erreur â c'est le comportement souhaitĂ©.
Tester avec Task & Contact
MĂȘme code, types diffĂ©rents â la puissance des generics
// 1. Définir le type
interface Task {
id: string;
title: string;
done: boolean;
}
// 2. Instancier le repository avec le bon type
const taskRepo = new InMemoryRepository<Task>();
// 3. Utiliser le repository
taskRepo.save({ id: "1", title: "Apprendre TypeScript", done: false });
taskRepo.save({ id: "2", title: "Pratiquer les generics", done: true });
taskRepo.save({ id: "3", title: "Ăcrire des tests", done: false });
const task = taskRepo.findById("1");
console.log(task?.title); // "Apprendre TypeScript"
const allTasks = taskRepo.findAll();
console.log(allTasks.length); // 3
taskRepo.delete("2");
console.log(taskRepo.findAll().length); // 2
// 1. DĂ©finir le type â COMPLĂTEMENT diffĂ©rent de Task
interface Contact {
id: string;
name: string;
email: string;
phone?: string;
}
// 2. Instancier â MĂME classe, type diffĂ©rent
const contactRepo = new InMemoryRepository<Contact>();
// 3. Utiliser â MĂME API
contactRepo.save({ id: "c1", name: "Alice Martin", email: "[email protected]" });
contactRepo.save({ id: "c2", name: "Bob Durand", email: "[email protected]", phone: "0600000001" });
const contact = contactRepo.findById("c1");
console.log(contact?.name); // "Alice Martin"
const allContacts = contactRepo.findAll();
console.log(allContacts.length); // 2
đŻ Ce qu'on vient de faire
On a écrit InMemoryRepository<T> UNE seule fois. Elle fonctionne pour Task, Contact, et n'importe quel type futur qui a un id.
TypeScript empĂȘche les erreurs Ă la compilation.
â ERREUR Ă la compilation
const taskRepo =
new InMemoryRepository<Task>();
// â On ne peut pas sauvegarder
// un Contact dans taskRepo !
taskRepo.save({
id: "c1",
name: "Alice", // â pas dans Task
email: "[email protected]" // â pas dans Task
});
â CORRECT
const taskRepo =
new InMemoryRepository<Task>();
// â TypeScript vĂ©rifie la forme
taskRepo.save({
id: "1",
title: "Apprendre TS",
done: false
});
Le generic <Task> "verrouille" le repository sur le type Task. Toute tentative de sauvegarder un Contact est dĂ©tectĂ©e avant mĂȘme d'exĂ©cuter le code.
"Et si on change de stockage ?"
La raison d'ĂȘtre de l'abstraction
On crée une deuxiÚme implémentation. Le contrat ne change pas.
class LocalStorageRepository<T extends HasId>
implements Repository<T>
{
constructor(private readonly key: string) {}
save(entity: T): void {
const all = this.findAll();
const index = all.findIndex((e) => e.id === entity.id);
if (index >= 0) { all[index] = entity; } else { all.push(entity); }
localStorage.setItem(this.key, JSON.stringify(all));
}
findById(id: string): T | undefined {
return this.findAll().find((e) => e.id === id);
}
findAll(): T[] {
const raw = localStorage.getItem(this.key);
return raw ? JSON.parse(raw) : [];
}
delete(id: string): void {
const filtered = this.findAll().filter((e) => e.id !== id);
localStorage.setItem(this.key, JSON.stringify(filtered));
}
}
C'est le cĆur de la Clean Architecture.
â DĂ©pendre du dĂ©tail
class TaskService {
private repo =
// â couplĂ© Ă une implĂ©mentation
new InMemoryRepository<Task>()
}
// Changer le stockage = modifier TaskService
â DĂ©pendre du contrat
class TaskService {
constructor(
private repo: Repository<Task>
) {}
}
// â L'implĂ©mentation est injectĂ©e
// DĂ©veloppement â mĂ©moire (rapide)
const devService = new TaskService(new InMemoryRepository<Task>());
// Production â localStorage (persistant)
const prodService = new TaskService(new LocalStorageRepository<Task>("tasks"));
// Tests â mĂ©moire (isolĂ©, dĂ©terministe)
const testService = new TaskService(new InMemoryRepository<Task>());
TaskService
dépend de Repository<Task>
Repository<T>
le contrat (interface)
InMemoryRepository
LocalStorageRepository
ApiRepository
đïž Clean Architecture en une phrase
La logique métier (TaskService) dépend d'une abstraction (Repository<T>), jamais d'un détail d'implémentation. On peut swapper le stockage sans toucher à la logique.
â ïž "C'est trop abstrait, j'ai du mal Ă voir l'intĂ©rĂȘt"
Normal au dĂ©but ! L'intĂ©rĂȘt devient concret quand on dit "maintenant, passe de InMemory Ă LocalStorage" â le code existant ne change pas d'une ligne.
â ïž Confondre .values() et .entries()
store.values() = les valeurs T. store.entries() = paires [clé, valeur]. Pour findAll(), on veut uniquement les valeurs.
â ïž Oublier la contrainte extends HasId
Sans T extends HasId, TypeScript ne sait pas que T a un champ id. entity.id provoque une erreur de compilation.
â ïž Ajouter trop de mĂ©thodes Ă l'interface
save · findById · findAll · delete suffisent. Si vous ajoutez findByEmail(), vous devrez l'implémenter partout. Utilisez une sous-interface ou un service spécialisé.
// 1. Contrainte
interface HasId { id: string; }
// 2. Contrat générique
interface Repository<T extends HasId> {
save(entity: T): void;
findById(id: string): T | undefined;
findAll(): T[];
delete(id: string): void;
}
// 3. Implémentation
class InMemoryRepository<T extends HasId> implements Repository<T> {
private store = new Map<string, T>();
save(entity: T): void { this.store.set(entity.id, entity); }
findById(id: string) { return this.store.get(id); }
findAll(): T[] { return [...this.store.values()]; }
delete(id: string): void { this.store.delete(id); }
}
// 4. Utilisation â typĂ©, rĂ©utilisable, interchangeable
const tasks = new InMemoryRepository<Task>();
const contacts = new InMemoryRepository<Contact>();
đŻ ~20 lignes â generics, contraintes, interface, classe, Map â tout converge
Repository<T> est le contrat générique
save · findById · findAll · delete â toute implĂ©mentation doit respecter ces 4 mĂ©thodes.
InMemoryRepository<T> est UNE implémentation
On pourrait en avoir d'autres : LocalStorage, API, SQL. Toutes respectent le mĂȘme contrat.
Le service dépend du contrat, pas du détail
TaskService(repo: Repository<Task>) â l'implĂ©mentation est injectĂ©e, pas hardcodĂ©e.
implements = je respecte le contrat
Changer l'implémentation ne change pas la logique. C'est la promesse de la Clean Architecture.
T extends HasId â la contrainte qui rend tout possible
Sans elle, pas d'accĂšs Ă entity.id. Avec elle, TypeScript garantit le contrat Ă la compilation.