Design patterns en TypeScript - 02 - Repository : abstraire l'acces aux donnees

Le pattern Repository en TypeScript. Abstraire la base de donnees derrière une interface pour des tests faciles et un domaine propre.

02 - Repository : abstraire l'acces aux donnees

Ce que tu vas apprendre

  • Pourquoi mettre du SQL dans les controllers est une mauvaise idee
  • Comment définir un repository comme une interface (un port)
  • Comment implementer un repository pour PostgreSQL et pour les tests

Prerequisites

Avoir lu l'introduction de la serie. Si tu as lu la serie sur l'architecture hexagonale, tu reconnaitras le concept de port sortant.


Le problème

Tu ouvres un controller et tu trouves ca :

typescriptapp.get("/orders/:id", async (req, res) => {
  const result = await db.query(
    "SELECT o.*, u.name as customer_name FROM orders o JOIN users u ON o.user_id = u.id WHERE o.id = $1",
    [req.params.id]
  );
  if (result.rows.length === 0) return res.status(404).json({ error: "Not found" });
  const order = result.rows[0];
  // ... 20 lignes de transformation
  res.json(order);
});

Du SQL dans le controller. La requête est collee a Express et a PostgreSQL. Pour tester la logique, tu dois monter une vraie base. Pour migrer vers un autre ORM, tu retouches chaque route. Pour réutiliser la requête dans un cron ? Copy-paste.

J'ai vu des projets avec 200+ routes contenant chacune du SQL brut. Migrer de MySQL a PostgreSQL a pris trois mois. Avec un repository, ca aurait pris une semaine.

La solution : le repository

Un repository est une interface qui ressemble a une collection. Tu lui demandes des objets, il te les donne. Tu ne sais pas comment il les stocke. Ca pourrait etre PostgreSQL, un fichier JSON, une API externe, ou un simple Map en mémoire.

typescriptinterface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
  findByStatus(status: OrderStatus): Promise<Order[]>;
  delete(id: string): Promise<void>;
}

Quatre méthodes. Pas de SQL, pas de mention de PostgreSQL, pas de db.query. C'est un contrat. En architecture hexagonale, c'est un port sortant (j'en parle dans l'article sur les ports).

L'implementation production : PostgreSQL

typescriptclass PgOrderRepository implements OrderRepository {
  constructor(private db: Pool) {}

  async save(order: Order): Promise<void> {
    await this.db.query(
      `INSERT INTO orders (id, customer_id, status, total, created_at)
       VALUES ($1, $2, $3, $4, $5)
       ON CONFLICT (id) DO UPDATE SET status = $3, total = $4`,
      [order.id, order.customerId, order.status, order.total, order.createdAt]
    );
  }

  async findById(id: string): Promise<Order | null> {
    const result = await this.db.query(
      "SELECT * FROM orders WHERE id = $1",
      [id]
    );
    if (result.rows.length === 0) return null;
    return this.toDomain(result.rows[0]);
  }

  async findByStatus(status: OrderStatus): Promise<Order[]> {
    const result = await this.db.query(
      "SELECT * FROM orders WHERE status = $1 ORDER BY created_at DESC",
      [status]
    );
    return result.rows.map(this.toDomain);
  }

  async delete(id: string): Promise<void> {
    await this.db.query("DELETE FROM orders WHERE id = $1", [id]);
  }

  private toDomain(row: any): Order {
    return {
      id: row.id,
      customerId: row.customer_id,
      status: row.status as OrderStatus,
      total: Number(row.total),
      createdAt: new Date(row.created_at),
    };
  }
}

Tout le SQL vit ici. La méthode toDomain convertit les rows de la base (snake_case) en objets du domaine (camelCase). Si tu changes de base, tu ecris un nouveau repository. Le reste du code ne bouge pas.

L'implementation test : en mémoire

typescriptclass InMemoryOrderRepository implements OrderRepository {
  private orders = new Map<string, Order>();

  async save(order: Order): Promise<void> {
    this.orders.set(order.id, { ...order });
  }

  async findById(id: string): Promise<Order | null> {
    return this.orders.get(id) ?? null;
  }

  async findByStatus(status: OrderStatus): Promise<Order[]> {
    return [...this.orders.values()]
      .filter(o => o.status === status)
      .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
  }

  async delete(id: string): Promise<void> {
    this.orders.delete(id);
  }
}

Pas de base de donnees. Pas de Docker. Pas de setup. Tes tests unitaires tournent en millisecondes :

typescriptimport { test, expect } from "bun:test";

test("findByStatus retourne les commandes triees par date", async () => {
  const repo = new InMemoryOrderRepository();
  await repo.save({ id: "1", customerId: "c1", status: "pending", total: 100, createdAt: new Date("2026-01-01") });
  await repo.save({ id: "2", customerId: "c1", status: "pending", total: 200, createdAt: new Date("2026-01-15") });
  await repo.save({ id: "3", customerId: "c2", status: "shipped", total: 50, createdAt: new Date("2026-01-10") });

  const pending = await repo.findByStatus("pending");
  expect(pending).toHaveLength(2);
  expect(pending[0].id).toBe("2"); // plus recent en premier
});

Exemple réel : les signatures dans paltemps.fr

Sur paltemps.fr, les signatures mail sont stockees dans un fichier JSON par compte. Pas de base de donnees pour ca, juste un fichier. Mais l'interface est la meme qu'un repository classique :

typescriptinterface SignatureRepository {
  getSignatures(accountId: string): Promise<Signature[]>;
  setSignature(accountId: string, signature: Signature): Promise<void>;
  deleteSignature(accountId: string, signatureId: string): Promise<void>;
}

L'implementation lit et écrit dans ~/.config/paltemps/signatures/{accountId}.json. C'est un file-based repository. Si demain on migre les signatures vers une base SQLite, on écrit un SqliteSignatureRepository et le service qui gere les signatures n'en sait rien.

Generic repository vs specific repository

Tu pourrais etre tente de créer un BaseRepository<T> générique :

typescriptinterface BaseRepository<T> {
  save(entity: T): Promise<void>;
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  delete(id: string): Promise<void>;
}

Mon avis : c'est une fausse bonne idee. Un OrderRepository a besoin de findByStatus. Un UserRepository a besoin de findByEmail. Un ProductRepository a besoin de findByCategory avec pagination. Le générique t'oblige a ajouter ces méthodes spécifiques en plus du générique, ce qui créé une abstraction qui fuit (leaky abstraction).

Des repositories spécifiques avec des méthodes qui ont du sens dans le domaine, c'est plus clair et plus testable. Le code métier appelle repo.findOverdueOrders() plutot que repo.findAll().filter(...). La requête SQL optimisee est dans le repository, pas dans le service.

Quand ne PAS utiliser un repository

Si ton projet a deux requêtes SQL et qu'il n'en aura jamais plus, un repository est du sur-engineering. Un db.query() direct dans le service fait le job.

La regle : si tu as un seul point d'acces aux donnees et pas de tests a écrire, passe-toi du repository. Des que tu as 2+ services qui accedent aux memes donnees, ou que tu veux des tests unitaires rapides, le repository devient rentable.


Résumé

  • Un repository est une interface qui fait semblant d'etre une collection
  • L'implementation production fait du vrai SQL, l'implementation test utilise une Map
  • Des repositories spécifiques par domaine valent mieux qu'un générique fourre-tout
  • Sans repository, changer de base de donnees touche tout le code

Article précédent : 01 - Factory

Article suivant : 03 - Strategy : changer de comportement a la volee

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.