Chapitre 726 minIntermédiaire
Pattern Repository, DAO et injection de dépendances
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- distinguer un DAO d'un Repository ;
- faire dépendre un service d'une interface plutôt que d'une classe concrète ;
- injecter les dépendances par le constructeur, à la main ou avec un conteneur ;
- tester un service avec de faux dépôts, sans base de données.
4 exercices corrigés1 schéma
Le pattern Repository (comme le DAO, son proche cousin) regroupe dans une classe tout l'accès aux données d'une entité, derrière des méthodes au nom métier (trouverParId, existeChevauchement). L'injection de dépendances consiste à fournir à une classe les objets dont elle a besoin, au lieu de la laisser les créer elle-même. Ensemble, ils permettent de changer de base de données, ou de la remplacer par une fausse dans un test, sans toucher aux règles de gestion. Ce chapitre explique les deux notions et les met en œuvre en C#, en Node.js et en PHP.
DAO et Repository
Les deux patrons isolent le SQL dans des classes dédiées. Ils diffèrent surtout par leur point de vue.
| DAO (Data Access Object) | Repository | |
|---|---|---|
| Origine | Les applications Java d'entreprise, au début des années 2000 | La conception pilotée par le domaine (Domain-Driven Design, Eric Evans, 2003) |
| Point de vue | La table : une classe par table, des opérations CRUD | Le métier : une collection d'objets du domaine, qu'on interroge |
| Noms des méthodes | insert, update, delete, findById, findAll | ajouter, trouverParId, existeChevauchement, reservationsAVenir |
| Ce qu'il renvoie | Souvent des lignes ou des objets proches de la table | Des objets du domaine, complets |
En pratique, la frontière est floue : beaucoup de classes appelées Repository sont des DAO, et inversement. Au BTS, retiens que les deux isolent l'accès aux données, que le DAO est plus proche de la table et le Repository plus proche du métier. Dans ResaSalles, ReservationRepository.existeChevauchement() est typique d'un Repository : son nom dit une question métier, pas une opération sur une table.
Une interface : le contrat de l'accès aux données
En C#, on décrit ce que le service attend de l'accès aux données par une interface :
// Data/Interfaces.cs
using ResaSalles.Models;
namespace ResaSalles.Data;
// Le contrat de l'accès aux données : ce dont le service a besoin, sans dire comment c'est fait.
public interface ISalleRepository
{
List<Salle> Lister();
Salle? TrouverParId(int id);
}
public interface IReservationRepository
{
List<Reservation> ListerParSalle(int salleId);
bool ExisteChevauchement(int salleId, DateTime debut, DateTime fin);
int Ajouter(Reservation reservation);
}
La classe qui parle à MySQL implémente l'interface :
// Data/SalleRepository.cs
using MySqlConnector;
using ResaSalles.Models;
namespace ResaSalles.Data;
public class SalleRepository : ISalleRepository
{
private readonly string _chaineConnexion;
public SalleRepository(string chaineConnexion)
{
_chaineConnexion = chaineConnexion;
}
public List<Salle> Lister() => Lire("SELECT id, nom, capacite, visio FROM salle ORDER BY nom", null);
public Salle? TrouverParId(int id) =>
Lire("SELECT id, nom, capacite, visio FROM salle WHERE id = @id", id).FirstOrDefault();
private List<Salle> Lire(string sql, int? id)
{
var salles = new List<Salle>();
using var connexion = new MySqlConnection(_chaineConnexion);
connexion.Open();
using var commande = new MySqlCommand(sql, connexion);
if (id is not null) commande.Parameters.AddWithValue("@id", id);
using var lecteur = commande.ExecuteReader();
while (lecteur.Read())
{
salles.Add(new Salle
{
Id = lecteur.GetInt32("id"),
Nom = lecteur.GetString("nom"),
Capacite = lecteur.GetInt32("capacite"),
Visio = lecteur.GetBoolean("visio"),
});
}
return salles;
}
}
ReservationRepository implémente IReservationRepository de la même façon, avec les méthodes du ReservationDao du chapitre 5. Le service, lui, ne connaît que les interfaces : son constructeur demande un ISalleRepository et un IReservationRepository, pas un SalleRepository.
C'est le principe d'inversion des dépendances, le D de SOLID : les modules de haut niveau (les règles de gestion) ne doivent pas dépendre des modules de bas niveau (MySQL) ; les deux dépendent d'une abstraction (l'interface). On peut alors brancher n'importe quelle implémentation : MySQL, PostgreSQL, un appel à une API, ou une liste en mémoire pour les tests.
L'injection de dépendances
Une dépendance, c'est un objet dont une classe a besoin pour travailler : le service a besoin d'un Repository et d'une horloge. Il y a deux façons de les obtenir.
Sans injection, la classe crée elle-même ses dépendances :
public class ReservationService
{
private readonly ReservationRepository _reservations =
new ReservationRepository("Server=prod-mysql;Database=resasalles;Uid=app;Pwd=…");
// …
}
Le service est soudé à MySQL, à une chaîne de connexion précise et à l'horloge réelle. Impossible de le tester sans la vraie base, impossible de lui donner une autre source de données.
Avec injection, la classe déclare ce dont elle a besoin, et quelqu'un d'autre le lui fournit :
public ReservationService(ISalleRepository salles, IReservationRepository reservations, TimeProvider horloge)
{
_salles = salles;
_reservations = reservations;
_horloge = horloge;
}
Il existe trois façons d'injecter : par le constructeur (la plus courante, et la seule à retenir pour l'examen : l'objet est complet dès sa création), par une propriété (service.Horloge = …) et par un paramètre de méthode (Reserver(demande, horloge)).
Le référentiel du BTS SIO cite d'ailleurs les « techniques d'injection des dépendances » parmi les savoirs du bloc 2 SLAM, avec la programmation au sein d'un cadre applicatif (framework) : c'est une notion d'examen, pas un raffinement.
La racine de composition
Si personne ne fait new à l'intérieur des classes, il faut bien que quelqu'un crée les objets. Ce quelqu'un, c'est la racine de composition (composition root) : un seul endroit, au démarrage de l'application, qui crée toutes les pièces et les branche entre elles. Dans ResaSalles, c'est Program.cs en C#, app.js en Node.js, public/index.php en PHP.
En Node.js, l'injection se fait à la main, et c'est la pratique la plus répandue :
// src/app.js (extrait) : la racine de composition
export function creerApp({ pool, authentifier }) {
const reservationService = creerReservationService({
salleRepository: creerSalleRepository(pool),
reservationRepository: creerReservationRepository(pool),
});
const reservationController = creerReservationController(reservationService);
const app = express();
app.use(express.json());
app.use('/api/reservations', authentifier, creerReservationRoutes(reservationController));
// … gestionnaire d'erreurs
return app;
}
Même pool et authentifier sont injectés dans creerApp : server.js lui passe le vrai pool MySQL et le vrai middleware d'authentification, un test d'intégration peut lui passer une base de test et un faux utilisateur connecté.
Le conteneur d'injection de .NET
Quand les classes se multiplient, écrire tous les new à la main devient fastidieux. Un conteneur d'injection de dépendances (DI container) le fait pour toi : tu déclares quelle classe fournit quelle interface, et il construit les objets en lisant leurs constructeurs. .NET en fournit un, dans le paquet Microsoft.Extensions.DependencyInjection (déjà inclus dans ASP.NET Core).
// Program.cs (version avec conteneur)
using Microsoft.Extensions.DependencyInjection;
using ResaSalles.Controllers;
using ResaSalles.Data;
using ResaSalles.Services;
using ResaSalles.Views;
namespace ResaSalles;
internal static class Program
{
[STAThread]
private static void Main()
{
ApplicationConfiguration.Initialize();
string chaine = Environment.GetEnvironmentVariable("RESASALLES_DB")
?? throw new InvalidOperationException("Variable RESASALLES_DB absente");
// La racine de composition : on déclare qui fournit quoi, le conteneur assemble.
var services = new ServiceCollection();
services.AddSingleton(TimeProvider.System);
services.AddSingleton<ISalleRepository>(_ => new SalleRepository(chaine));
services.AddSingleton<IReservationRepository>(_ => new ReservationRepository(chaine));
services.AddTransient<ReservationService>();
services.AddTransient<ReservationController>();
services.AddTransient<FormReservations>();
using var fournisseur = services.BuildServiceProvider();
Application.Run(fournisseur.GetRequiredService<FormReservations>());
}
}
Quand on demande un FormReservations, le conteneur voit que son constructeur demande un ReservationController, qui demande deux Repository et un ReservationService, qui demande à son tour deux Repository et un TimeProvider. Il crée tout dans le bon ordre. Le formulaire reçoit ici seulement son contrôleur : public FormReservations(ReservationController controleur).
La durée de vie dit combien d'instances le conteneur crée :
| Méthode | Durée de vie | Usage typique |
|---|---|---|
AddSingleton | Une seule instance pour toute l'application | Configuration, horloge, Repository sans état |
AddScoped | Une instance par « portée » : par requête HTTP dans ASP.NET Core | Contexte de base de données d'Entity Framework, unité de travail |
AddTransient | Une nouvelle instance à chaque demande | Services légers, contrôleurs, formulaires |
Tester un service avec de faux dépôts
C'est le bénéfice le plus concret de l'injection : tester les règles de gestion sans base de données, en quelques millisecondes. En Node.js, avec le lanceur de tests intégré (node --test) :
// src/services/reservation.service.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { creerReservationService } from './reservation.service.js';
// De faux dépôts en mémoire : ni serveur, ni base, ni HTTP.
function creerService({ chevauchement = false } = {}) {
const ajoutees = [];
const service = creerReservationService({
salleRepository: { trouverParId: async (id) => (id === 2 ? { id: 2, nom: 'Aiguille', capacite: 4 } : null) },
reservationRepository: {
existeChevauchement: async () => chevauchement,
ajouter: async (r) => (ajoutees.push(r), { id: 1, ...r }),
},
maintenant: () => new Date('2026-12-01T08:00:00'),
});
return { service, ajoutees };
}
const demande = {
salleId: 2, employeId: 7, objet: 'Entretien annuel', participants: 2,
debut: new Date('2026-12-03T14:00:00'), fin: new Date('2026-12-03T15:00:00'),
};
test('une demande valide est enregistrée', async () => {
const { service, ajoutees } = creerService();
const r = await service.reserver(demande);
assert.equal(r.id, 1);
assert.equal(ajoutees.length, 1);
});
test('plus de participants que de places : refus 422', async () => {
const { service, ajoutees } = creerService();
await assert.rejects(service.reserver({ ...demande, participants: 5 }), { statut: 422 });
assert.equal(ajoutees.length, 0);
});
test('créneau déjà pris : refus 409', async () => {
const { service } = creerService({ chevauchement: true });
await assert.rejects(service.reserver(demande), { statut: 409 });
});
$ node --test
✔ une demande valide est enregistrée (0.43ms)
✔ plus de participants que de places : refus 422 (0.85ms)
✔ créneau déjà pris : refus 409 (0.08ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0
La sortie est abrégée : Node.js affiche aussi le nombre de tests ignorés et la durée totale. Sans argument, node --test cherche les fichiers de test du projet : ceux dont le nom se termine par .test.js (ou -test.js, _test.js) et ceux rangés dans un dossier test/.
Grâce à l'horloge injectée (maintenant), le test donne le même résultat aujourd'hui et dans dix ans : la date du 3 décembre 2026 est toujours « dans le futur » pour lui. Sans cette injection, dès le 3 décembre 2026 à 14 h, le premier et le troisième tests échoueraient, et le deuxième passerait pour une mauvaise raison (la demande serait refusée parce qu'elle est dans le passé, avec le même statut 422). Un test qui passe n'est pas forcément un test qui vérifie ce que dit son nom.
En C#, on écrit des classes qui implémentent les interfaces :
class FausseReservations : IReservationRepository
{
private readonly List<Reservation> _liste = [];
public List<Reservation> ListerParSalle(int salleId) => _liste.Where(r => r.SalleId == salleId).ToList();
public bool ExisteChevauchement(int salleId, DateTime debut, DateTime fin) =>
_liste.Any(r => r.SalleId == salleId && r.Debut < fin && r.Fin > debut);
public int Ajouter(Reservation reservation) { _liste.Add(reservation); return _liste.Count; }
}
class HorlogeFixe(DateTime maintenant) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new DateTimeOffset(maintenant).ToUniversalTime();
}
HorlogeFixe(DateTime maintenant) utilise un constructeur principal (C# 12 et suivants) : le paramètre est accessible dans toute la classe. Avec ces deux classes et une fausse ISalleRepository, on enchaîne six demandes sur la salle Aiguille (4 places), l'horloge étant fixée au 1er décembre 2026 à 8 h :
valide : enregistrée (id 1)
trop de monde : refus, La salle Aiguille accueille 4 personnes au plus.
chevauchement : refus, La salle est déjà réservée sur ce créneau.
bout à bout : enregistrée (id 2)
passé : refus, Impossible de réserver dans le passé.
5 h : refus, Une réservation dure 4 heures au plus.
Ces objets de remplacement portent des noms précis : un bouchon (stub) renvoie des réponses fixées d'avance, un faux (fake) a un vrai comportement simplifié (la liste en mémoire), un simulacre (mock) vérifie en plus qu'on l'a appelé comme prévu. Les tests unitaires, les frameworks xUnit et Jest et ces objets de remplacement sont le sujet du cours Tests (mock, stub et spy).
PHP : une interface et un constructeur
En PHP, le même principe s'écrit avec une interface et la promotion de propriété :
<?php
declare(strict_types=1);
interface ReservationRepository
{
public function existeChevauchement(int $salleId, string $debut, string $fin): bool;
public function ajouter(int $salleId, int $employeId, string $debut, string $fin, string $objet, int $participants): int;
}
final class PdoReservationRepository implements ReservationRepository
{
public function __construct(private PDO $pdo) {}
// … les méthodes du ReservationModel du chapitre 3
}
final class ReservationService
{
public function __construct(
private SalleRepository $salles,
private ReservationRepository $reservations,
) {}
// …
}
En PHP, une convention fréquente nomme l'interface d'après le concept (ReservationRepository) et l'implémentation d'après la technique (PdoReservationRepository). En C#, la convention est le préfixe I (IReservationRepository).
Les pièges
- Une interface pour tout. Créer
IReservationService,IReservationController,IFormReservationsalors qu'il n'y aura jamais qu'une implémentation n'apporte rien. On crée une interface là où l'on a besoin de remplacer l'implémentation : l'accès aux données, l'horloge, l'envoi d'e-mails, un service externe. - La connexion globale. Une classe
Databaseavec une connexion statique, appelée depuis n'importe où (Database::getInstance()->query(...)), est une dépendance cachée : on ne la voit pas dans le constructeur, et on ne peut pas la remplacer. C'est le défaut classique du patron Singleton mal employé. - Le localisateur de services. Passer le conteneur lui-même aux classes pour qu'elles s'y servent (
fournisseur.GetService<IReservationRepository>()au milieu d'une méthode) cache à nouveau les dépendances. Seule la racine de composition parle au conteneur. - Trop de dépendances. Un constructeur qui demande huit objets signale une classe qui fait trop de choses : découpe-la.
Exercices
Exercice 1 sur 4 Repérer les dépendances cachées Réussi À revoir
Dans cette classe C#, relève les dépendances que la classe crée elle-même, puis réécris le constructeur pour les injecter.
public class RappelService
{
public void EnvoyerRappels()
{
var repo = new ReservationRepository("Server=localhost;Database=resasalles;Uid=app;Pwd=x");
var smtp = new SmtpMailer("smtp.brevent.example", 587);
foreach (var r in repo.ReservationsDeDemain(DateTime.Today))
smtp.Envoyer(r.EmailEmploye, "Rappel", $"Demain : {r.Objet}");
}
}
Voir la réponseMasquer la réponse de l'exercice 1
Trois dépendances cachées : le Repository (et sa chaîne de connexion en dur), le service d'envoi d'e-mails, et l'horloge (DateTime.Today).
public interface IMailer { void Envoyer(string destinataire, string sujet, string corps); }
public class RappelService
{
private readonly IReservationRepository _reservations;
private readonly IMailer _mailer;
private readonly TimeProvider _horloge;
public RappelService(IReservationRepository reservations, IMailer mailer, TimeProvider horloge)
{
_reservations = reservations;
_mailer = mailer;
_horloge = horloge;
}
public void EnvoyerRappels()
{
DateTime aujourdhui = _horloge.GetLocalNow().Date;
foreach (var r in _reservations.ReservationsDeDemain(aujourdhui))
_mailer.Envoyer(r.EmailEmploye, "Rappel", $"Demain : {r.Objet}");
}
}
La méthode ReservationsDeDemain, qui renvoie des objets portant l'objet de la réunion et l'e-mail de l'employé, doit figurer dans l'interface IReservationRepository. Un test peut maintenant passer un faux IMailer qui enregistre les e-mails au lieu de les envoyer, et vérifier qu'il y en a autant que de réservations du lendemain.
Tu avais trouvé ?
Exercice 2 sur 4 DAO ou Repository ? Réussi À revoir
Classe chaque ensemble de méthodes : plutôt DAO ou plutôt Repository ?
insert(Salle),update(Salle),delete(int),findById(int),findAll().sallesLibres(DateTime debut, DateTime fin, int participants),ajouter(Salle),trouverParNom(string).executerRequete(string sql).
Voir la réponseMasquer la réponse de l'exercice 2
- DAO : les opérations CRUD d'une table, nommées d'après la technique.
- Repository :
sallesLibresexprime une question métier (« quelles salles sont libres sur ce créneau pour ce nombre de personnes ? »). - Ni l'un ni l'autre : c'est une simple enveloppe autour du pilote, qui laisse le SQL se répandre dans les couches supérieures. À éviter.
Tu avais trouvé ?
Exercice 3 sur 4 Choisir la durée de vie Réussi À revoir
Pour une API ASP.NET Core, choisis AddSingleton, AddScoped ou AddTransient pour : la configuration de l'application ; le contexte Entity Framework d'accès à la base ; un service de calcul de tarif sans état ; un cache en mémoire des salles partagé par toutes les requêtes.
Voir la réponseMasquer la réponse de l'exercice 3
- Configuration :
AddSingleton, elle ne change pas pendant l'exécution. - Contexte Entity Framework :
AddScoped, une instance par requête HTTP, qui suit les modifications de la requête et n'est pas partagée entre requêtes simultanées (il n'est pas prévu pour être utilisé par plusieurs fils d'exécution). - Service de calcul sans état :
AddTransient(ouAddSingleton, sans risque puisqu'il n'a pas d'état). - Cache partagé :
AddSingleton, à condition qu'il soit sûr en accès concurrent (ConcurrentDictionarypar exemple). Attention : un singleton ne doit pas dépendre d'un serviceScoped.
Tu avais trouvé ?
Exercice 4 sur 4 Écrire un test avec un faux Réussi À revoir
En t'inspirant du test Node.js du chapitre, écris un test qui vérifie qu'une réservation de 9 h à 14 h est refusée avec le statut 422, et que rien n'est ajouté.
Voir la réponseMasquer la réponse de l'exercice 4
test('plus de 4 heures : refus 422', async () => {
const { service, ajoutees } = creerService();
await assert.rejects(
service.reserver({ ...demande, debut: new Date('2026-12-03T09:00:00'), fin: new Date('2026-12-03T14:00:00') }),
{ statut: 422, message: 'Une réservation dure 4 heures au plus' },
);
assert.equal(ajoutees.length, 0);
});
9 h à 14 h, c'est 300 minutes, plus que les 240 permises. assert.rejects vérifie que la promesse est rejetée et que l'erreur a les propriétés indiquées. La deuxième assertion prouve que le service n'a pas appelé ajouter : la règle est vérifiée avant l'écriture.
Tu avais trouvé ?