J 5 — Refactoriser du spaghetti

Séparer un controller monolithique en couches testables

Bootcode IWA-S04 — Semaine 15, Jour 5

Objectifs de la leçon

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

Plan du cours

1

Le code spaghetti : un controller qui fait TOUT

Validation, logique métier et SQL mélangés — le chaos

2

Étape 1 : extraire le repository

Interface + InMemory + PostgreSQL — la dépendance la plus lourde

3

Étape 2 : extraire le use case

Logique métier pure, sans HTTP ni SQL

4

Étape 3 : simplifier le controller

HTTP seulement : recevoir la requête, répondre

5

Écrire les tests pour chaque couche isolée

Use case, repository, controller — chacun son test

6

Résumé de la semaine

Tests + SOLID + refactoring — le bilan

Module 1

Le code spaghetti

Un controller qui fait TOUT — validation, logique, SQL

Le controller spaghetti 🍝

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

Le controller spaghetti 🍝 (suite)

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

Pourquoi c'est un problème ?

❌ 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

Extraire le repository

Interface + InMemory + PostgreSQL — la dépendance la plus lourde

1️⃣ Définir l'interface du repository

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.

1️⃣ Implémentation InMemory (pour les tests)

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.

1️⃣ Implémentation PostgreSQL (pour la prod)

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.

1️⃣ Le controller après extraction du repository

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

Extraire le use case

Logique métier pure — sans HTTP, sans SQL

2️⃣ Le use case : logique métier pure

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

email

);

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.

2️⃣ Le controller après extraction du use case

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

Simplifier le controller

HTTP seulement : recevoir la requête, répondre

3️⃣ Le controller simplifié : HTTP seulement

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 / Après — la transformation

❌ 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

Écrire les tests

Une couche isolée = un test rapide et fiable

Test du use case — unitaire et instantané

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.

Test du repository InMemory

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.

Test du controller — feature test

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

⚠️ Piège 1 : tout refactoriser d'un coup

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

⚠️ Piège 2 : oublier de tester après chaque étape

❌ 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é.

⚠️ Piège 3 : le controller reste trop gros

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

Résumé de la semaine 15

Tests + SOLID + refactoring — le trilogy du code propre

🧪

Tests

  • • Pest : it() et expect()
  • • Tests unitaires (0ms)
  • • Tests feature (HTTP)
  • • InMemory pour isoler

📐

SOLID

  • • S : une classe = un métier
  • • O : ouvert à l'extension
  • • L : interchangeabilité
  • • D : dépendre des abstractions

🔧

Refactoring

  • • Même comportement
  • • Étape par étape
  • • Tester après chaque
  • • Couches testables

🎯 L'objectif final : un code où chaque couche est testable indépendamment, modifiable sans peur, et lisible.

À retenir !

🍝

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.

Questions ?

Refactoriser, c'est rendre le code testable — étape par étape

Semaine prochaine : projet final — appliquer tout ce qu'on a vu !