Séparer un controller monolithique en couches testables
Bootcode IWA-S04 — Semaine 15, Jour 5
L'étudiant doit savoir :
1. Identifier les responsabilités mélangées
Prendre un code spaghetti et repérer validation, logique, SQL
2. Extraire un repository
Avec son interface et son implémentation InMemory
3. Extraire les use cases
Logique métier pure, sans dépendance HTTP ou SQL
4. Simplifier le controller
Qu'il ne fasse que du HTTP : recevoir et répondre
5. Écrire des tests unitaires pour chaque use case
Chaque couche est testable indépendamment
Le code spaghetti : un controller qui fait TOUT
Validation, logique métier et SQL mélangés — le chaos
Étape 1 : extraire le repository
Interface + InMemory + PostgreSQL — la dépendance la plus lourde
Étape 2 : extraire le use case
Logique métier pure, sans HTTP ni SQL
Étape 3 : simplifier le controller
HTTP seulement : recevoir la requête, répondre
Écrire les tests pour chaque couche isolée
Use case, repository, controller — chacun son test
Résumé de la semaine
Tests + SOLID + refactoring — le bilan
Module 1
Un controller qui fait TOUT — validation, logique, SQL
Un controller qui fait tout : validation, logique métier, et SQL mélangés
class InscriptionController {
public store = async (req: Request): Promise<Response> => {
// 1️⃣ Validation
const nom = req.body.nom;
if (nom === '' || nom === null) {
return new Response('Nom requis', { status: 400 });
}
const email = req.body.email;
if (!email.includes('@')) {
return new Response('Email invalide', { status: 400 });
}
// 2️⃣ Logique métier : vérifier si l'email existe déjà
const pool = new Pool({ connectionString: 'postgresql://localhost/bootcode' });
const result = await pool.query('SELECT * FROM etudiants WHERE email = $1', [email]);
if (result.rowCount > 0) {
return new Response('Email déjà utilisé', { status: 409 });
}
👇 Et ce n'est pas fini...
// 3️⃣ Logique métier : créer l'étudiant
const id = crypto.randomUUID();
const nomCapitalise = nom.charAt(0).toUpperCase() + nom.slice(1).toLowerCase();
if (nomCapitalise.length < 2) {
return new Response('Nom trop court', { status: 400 });
}
// 4️⃣ SQL : insérer en base
await pool.query(
'INSERT INTO etudiants (id, nom, email) VALUES ($1, $2, $3)',
[id, nomCapitalise, email]
);
// 5️⃣ SQL : logger l'inscription
await pool.query('INSERT INTO logs (action, entity_id) VALUES ($1, $2)', ['inscription', id]);
// 6️⃣ Réponse HTTP
return new Response(JSON.stringify({
id: id, nom: nomCapitalise, email: email
}), { status: 201 });
}
}
😱 6 responsabilités dans UNE seule méthode !
Validation + 2 requêtes SQL + logique métier + formatage + réponse HTTP
❌ Impossible à tester
Pour tester la logique, il faut une vraie base PostgreSQL. Lent et fragile.
❌ Responsabilités mélangées
Le controller fait du HTTP + de la logique + du SQL. Une seule classe, 3 métiers.
❌ Difficile à modifier
Changer la logique métier = toucher au SQL et au HTTP en même temps.
❌ Violation de SRP
Single Responsibility Principle : une classe = une raison de changer.
✅ La solution : séparer en couches
Controller (HTTP) → Use Case (logique) → Repository (SQL). Chaque couche testable.
Module 2 — Étape 1
Interface + InMemory + PostgreSQL — la dépendance la plus lourde
L'interface est le contrat : elle dit CE QUE le repository fait, pas COMMENT.
interface EtudiantRepository {
// Trouver un étudiant par son email
findByEmail(email: string): Promise<Etudiant | null>;
// Sauvegarder un nouvel étudiant
save(etudiant: Etudiant): Promise<void>;
// Logger une action
logAction(action: string, entityId: string): Promise<void>;
}
💡 Pourquoi une interface ? On peut injecter un InMemory pour les tests, un PostgreSQL pour la prod. Le controller ne sait pas laquelle.
Un repository en mémoire : rapide, pas de base, parfait pour les tests unitaires.
class InMemoryEtudiantRepository implements EtudiantRepository {
private etudiants: Etudiant[] = [];
private logs: { action: string, id: string }[] = [];
async findByEmail(email: string): Promise<Etudiant | null> {
return this.etudiants.find(etd => etd.email === email) ?? null;
}
async save(etd: Etudiant): Promise<void> {
this.etudiants.push(etd);
}
async logAction(action: string, id: string): Promise<void> {
this.logs.push({ action, id });
}
}
⚡ Aucune base de données ! Les tests sont instantanés. On peut vérifier l'état directement via les tableaux.
La même interface, mais avec de vraies requêtes SQL. Utilisée en production.
class PostgreSqlEtudiantRepository implements EtudiantRepository {
constructor(private readonly pool: Pool) {}
async findByEmail(email: string): Promise<Etudiant | null> {
const result = await this.pool.query('SELECT * FROM etudiants WHERE email = $1', [email]);
const row = result.rows[0];
return row ? Etudiant.fromRow(row) : null;
}
async save(etd: Etudiant): Promise<void> {
await this.pool.query(
'INSERT INTO etudiants (id, nom, email) VALUES ($1, $2, $3)',
[etd.id, etd.nom, etd.email]
);
}
async logAction(action: string, entityId: string): Promise<void> {
await this.pool.query('INSERT INTO logs (action, entity_id) VALUES ($1, $2)', [action, entityId]);
}
}
🔄 Même interface, deux implémentations. Le controller ne change pas quand on passe de InMemory à PostgreSQL.
Le SQL a disparu ! Mais il reste de la validation et de la logique métier.
class InscriptionController {
constructor(private readonly repo: EtudiantRepository) {}
public store = async (req: Request): Promise<Response> => {
// Validation (toujours là...)
const nom = req.body.nom;
if (nom === '') return new Response('Nom requis', { status: 400 });
// Logique métier (toujours là...)
if (await this.repo.findByEmail(req.body.email)) {
return new Response('Email déjà utilisé', { status: 409 });
}
const etd = new Etudiant(
crypto.randomUUID(),
nom.charAt(0).toUpperCase() + nom.slice(1),
req.body.email
);
await this.repo.save(etd);
await this.repo.logAction('inscription', etd.id);
return new Response(JSON.stringify(etd), { status: 201 });
}
}
✅ Plus de SQL ! ❌ Mais il reste de la validation et de la logique métier → Étape 2.
Module 3 — Étape 2
Logique métier pure — sans HTTP, sans SQL
Le use case orchestre la logique : il reçoit des données, applique les règles, utilise le repository.
class InscrireEtudiantUseCase {
constructor(private readonly repo: EtudiantRepository) {}
async execute(nom: string, email: string): Promise<Etudiant> {
// Règle : le nom doit faire au moins 2 caractères
if (nom.length < 2) {
throw new NomTropCourtException();
}
// Règle : l'email ne doit pas déjà exister
if (await this.repo.findByEmail(email) !== null) {
throw new EmailDejaUtiliseException();
}
// Créer l'étudiant avec le nom capitalisé
const etd = new Etudiant(
crypto.randomUUID(),
nom.charAt(0).toUpperCase() + nom.slice(1).toLowerCase(),
);
await this.repo.save(etd);
await this.repo.logAction('inscription', etd.id);
return etd;
}
}
💡 Aucune référence à HTTP ou SQL ! Le use case ne connaît que des objets et le repository. 100% testable.
La logique métier a disparu ! Mais il reste de la validation et la gestion d'erreurs.
class InscriptionController {
constructor(
private readonly useCase: InscrireEtudiantUseCase
) {}
public store = async (req: Request): Promise<Response> => {
// Validation (encore là...)
const nom = req.body.nom;
const email = req.body.email;
if (nom === '' || !email.includes('@')) {
return new Response('Données invalides', { status: 400 });
}
// Exécuter le use case + gérer les erreurs
try {
const etd = await this.useCase.execute(nom, email);
return new Response(JSON.stringify(etd), { status: 201 });
} catch (e) {
if (e instanceof NomTropCourtException) {
return new Response('Nom trop court', { status: 400 });
}
if (e instanceof EmailDejaUtiliseException) {
return new Response('Email déjà utilisé', { status: 409 });
}
throw e;
}
}
}
✅ Plus de logique métier ! ❌ Mais la validation et le try/catch encombrent → Étape 3.
Module 4 — Étape 3
HTTP seulement : recevoir la requête, répondre
La validation passe dans une Form Request. Le try/catch dans un handler. Le controller devient mince.
// La validation est extraite dans un DTO de validation
class InscriptionRequest {
constructor(
public readonly nom: string,
public readonly email: string
) {}
static validate(data: any): InscriptionRequest {
if (!data.nom || data.nom.length < 2) {
throw new ValidationError('Nom requis (min 2 caractères)');
}
if (!data.email?.includes('@')) {
throw new ValidationError('Email invalide');
}
return new InscriptionRequest(data.nom, data.email);
}
}
// Le controller ne fait QUE du HTTP
class InscriptionController {
constructor(private readonly useCase: InscrireEtudiantUseCase) {}
public store = async (req: Request): Promise<Response> => {
const request = InscriptionRequest.validate(req.body);
const etd = await this.useCase.execute(request.nom, request.email);
return new Response(JSON.stringify(etd), { status: 201 });
}
}
✅ 5 lignes dans le controller ! Il reçoit la requête validée, appelle le use case, renvoie la réponse. C'est tout.
❌ Avant — Spaghetti
• 1 controller de 25 lignes
• Validation inline
• Logique métier inline
• 2 requêtes SQL inline
• PDO créé dans la méthode
• Formatage inline
• 0 test possible
✅ Après — Couches
• Controller : 5 lignes (HTTP)
• FormRequest : validation
• UseCase : logique métier pure
• Interface + 2 implémentations
• InMemory pour les tests
• PostgreSQL pour la prod
• 3 couches testables
📐 Le code refactorisé est plus long (plus de fichiers) mais chaque partie est testable indépendamment.
Module 5
Une couche isolée = un test rapide et fiable
On injecte le InMemoryRepository. Aucune base de données. Le test est rapide.
it('inscrit un étudiant avec un nom valide', async () => {
const repo = new InMemoryEtudiantRepository();
const useCase = new InscrireEtudiantUseCase(repo);
const etd = await useCase.execute('alice', '[email protected]');
expect(etd.nom).toBe('Alice');
expect(etd.email).toBe('[email protected]');
expect(repo.all()).toHaveLength(1);
});
it('rejette un nom trop court', async () => {
const useCase = new InscrireEtudiantUseCase(new InMemoryEtudiantRepository());
expect(() => useCase.execute('A', '[email protected]'))
.rejects.toThrow(NomTropCourtException);
});
⚡ 0ms, 0 base de données. Le test du use case est pur : il teste la logique métier, rien d'autre.
On teste que le InMemory se comporte comme attendu : save, findByEmail, logAction.
it('sauvegarde et retrouve un étudiant par email', async () => {
const repo = new InMemoryEtudiantRepository();
const etd = new Etudiant('1', 'Alice', '[email protected]');
await repo.save(etd);
const found = await repo.findByEmail('[email protected]');
expect(found).not.toBeNull();
expect(found?.nom).toBe('Alice');
});
it('retourne null si email introuvable', async () => {
const repo = new InMemoryEtudiantRepository();
expect(await repo.findByEmail('[email protected]')).toBeNull();
});
💡 Le même test marcherait avec PostgreSQL (test d'intégration avec RefreshDatabase). L'interface garantit la cohérence.
On teste la route HTTP complète : la requête, la validation, la réponse JSON.
it('inscrit un étudiant via POST /inscriptions', async () => {
const response = await this.postJson('/inscriptions', {
nom: 'alice',
email: '[email protected]'
});
expect(response.status).toBe(201);
expect(response.json().nom).toBe('Alice');
});
it('rejette un email invalide', async () => {
const response = await this.postJson('/inscriptions', {
nom: 'alice', email: 'pas-un-email'
});
expect(response.status).toBe(422);
});
🧪 3 niveaux de tests : Use case (unitaire, 0ms) → Repository (InMemory, 0ms) → Controller (feature, avec base de test).
❌ Tout d'un coup
// On supprime tout, on recommence
class Controller {
// TODO: tout réécrire
// TODO: repository
// TODO: use case
// TODO: tests...
}
Rien ne marche pendant des heures. Impossible de revenir en arrière.
✅ Étape par étape
// Étape 1 : extraire le repository
git commit -m "refactor: repository"
// Étape 2 : extraire le use case
git commit -m "refactor: use case"
// Étape 3 : simplifier le controller
git commit -m "refactor: thin controller"
À chaque étape : le code marche. On peut revenir si besoin.
🔑 Règle d'or : une étape = un commit. Tester après CHAQUE étape. Le comportement ne change jamais.
❌ Refactoriser sans tester
$ php artisan test
// "Je testerai à la fin"
...
$ php artisan test
FAILED ❌ 12 tests
Où est le bug ? Dans quelle étape ?
On ne sait plus quel commit a cassé quoi.
✅ Tester après chaque étape
// Étape 1 : repository extrait
$ php artisan test
PASSED ✅ 5 tests
// Étape 2 : use case extrait
$ php artisan test
PASSED ✅ 5 tests
Si ça casse, on sait exactement à quelle étape.
🛡️ Le refactoring = même comportement. Si les tests passent avant et après, le comportement est préservé.
❌ Controller qui fait encore trop
public function store($req) {
// Validation inline (20 lignes)
if ($req->nom === '') { ... }
if (!str_contains(...)) { ... }
// Logique métier (15 lignes)
if ($this->repo->findByEmail(...)) { ... }
$etd = new Etudiant(ucfirst(...));
// Réponse (10 lignes)
return new Response(...);
}
45 lignes ! Le controller fait encore 3 métiers.
✅ Controller qui ne fait que du HTTP
public function store(
InscriptionRequest $req
): Response {
$etd = $this->useCase->execute(
$req->validated()['nom'],
$req->validated()['email']
);
return new Response(json_encode($etd), 201);
}
5 lignes ! Recevoir → appeler → répondre. C'est tout.
📏 Un controller ne devrait jamais contenir de logique métier, de SQL, ou de validation complexe. Il orchestre, point.
Tests + SOLID + refactoring — le trilogy du code propre
🧪
Tests
📐
SOLID
🔧
Refactoring
🎯 L'objectif final : un code où chaque couche est testable indépendamment, modifiable sans peur, et lisible.
🍝
Spaghetti = responsabilités mélangées
Validation + logique + SQL dans une méthode = impossible à tester
📦
Repository = abstraction SQL
Interface + InMemory + PostgreSQL. Le controller ne sait pas laquelle.
🎯
Use case = logique métier pure
Pas de HTTP, pas de SQL. Testable en 0ms avec InMemory.
🪶
Controller = HTTP seulement
Recevoir la requête, appeler le use case, répondre. 5 lignes.
🪜
Refactoring = étape par étape
Une étape = un commit = un test. Le comportement ne change jamais.
Refactoriser, c'est rendre le code testable — étape par étape
Semaine prochaine : projet final — appliquer tout ce qu'on a vu !