Exercice Fondateur

Le Repository Pattern

Generics · Interfaces · Classes · Erreurs — tout converge

interface Repository<T extends HasId>

Objectifs de la leçon

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

Plan du cours

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

Module 1

Recap de la semaine

Tout ce qu'on a appris converge ici

La semaine en 5 blocs

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

Module 2

Le problĂšme

Les mĂȘmes opĂ©rations, encore et encore

On Ă©crit les mĂȘmes opĂ©rations pour chaque type

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.

La solution : abstraire les opérations

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

InMemoryRepository<T> — Map
LocalStorageRepository<T> — Browser
ApiRepository<T> — REST API
SQLRepository<T> — Base SQL

Module 3

HasId & Repository<T>

La contrainte et l'interface générique

Étape 1ïžâƒŁ — L'interface HasId

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.

Étape 2ïžâƒŁ — L'interface Repository<T>

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.

Lire l'interface comme un contrat

Décomposer la signature Repository<T extends HasId> pas à pas.

1ïžâƒŁ

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.

2ïžâƒŁ

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.

3ïžâƒŁ

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.

4ïžâƒŁ

findAll(): T[]

Retourne un tableau typé. Si c'est un Repository<Task>, on obtient Task[]. Pas d'any !

Module 4

InMemoryRepository<T>

Implémenter le contrat avec une Map

Rappel : les méthodes de 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.

InMemoryRepository<T> — Le code complet

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.

Décortiquer chaque méthode

đŸ’Ÿ

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Ă©.

Module 5

Tester avec Task & Contact

MĂȘme code, types diffĂ©rents — la puissance des generics

Repository de tñches — TaskRepository

// 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

Repository de contacts — ContactRepository

// 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.

La sécurité du typage en action

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.

Module 6

"Et si on change de stockage ?"

La raison d'ĂȘtre de l'abstraction

LocalStorageRepository<T> — autre implĂ©mentation

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));
  }
}

Le service dépend du contrat, pas du détail

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>());

Le moment "aha" : une classe change, le reste est intact

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.

PiĂšges courants

⚠ "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é.

Le code complet — tout ensemble

// 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

Points clés à retenir

📐

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.