Aller au contenu

Formation en Clean Code


Table des matières

  1. Introduction — Qu’est-ce que le Clean Code ?
  2. Le nommage — Donner du sens au code
  3. Les fonctions — Faire une seule chose, bien
  4. Les commentaires — Quand (ne pas) écrire
  5. La mise en forme — Le code se lit comme un journal
  6. Les objets et les structures de données
  7. La gestion des erreurs
  8. Les principes SOLID
  9. Les tests — Le Clean Code se prouve
  10. Le refactoring — Nettoyer progressivement
  11. Clean Code avec Spring Boot
  12. TP Final — Librairie en ligne

1. Introduction — Qu’est-ce que le Clean Code ?

1.1 La dette technique

Imaginez construire une maison en empilant des briques sans mortier : ça monte vite, mais dès le premier coup de vent, tout s’effondre. En développement logiciel, écrire du code sale revient exactement à ça. On accumule ce qu’on appelle la dette technique.

Définition : La dette technique est l’ensemble des mauvais choix de conception qui semblent efficaces à court terme mais qui ralentissent (et coûtent cher) à long terme.

Temps passé à ajouter une fonctionnalité
                ↑
                │         /‾‾‾‾‾‾‾‾‾  ← Code "sale"
                │        /
                │   ____/
                │  /              ← Code "propre"
                │ /_______________
                └──────────────────→ Temps (mois)

Au début, le code “sale” semble plus rapide. Mais après quelques mois, chaque nouvelle fonctionnalité prend de plus en plus de temps à cause des interactions complexes, des bugs et de la difficulté à comprendre le code existant.

1.2 Pourquoi écrire du Clean Code ?

Le code est lu bien plus souvent qu’il n’est écrit. Des études montrent qu’un développeur passe environ 10 fois plus de temps à lire du code qu’à en écrire. Écrire du code lisible, c’est donc un investissement pour vous et votre équipe.

Citation de Robert C. Martin : “Le seul indicateur valide de la qualité du code est le nombre de WTF/minute lors de la revue de code.”

Pour info, le WTF est une formule humoristique très connue dans le monde du clean code.

WTF signifie littéralement : What The F*?… je vous laisse deviner les lettres qui manquent !

En français correct on dirnait Mais c’est quoi ce truc ?!, pourquoi il a fait ça ?!, ou encore Qui a codé ça avec les pieds ?

L’idée est simple : Plus un.e développeur.euse dit WTF ?! en lisant du code, plus le code est probablement difficile à comprendre, mal nommé, mal structuré ou trop compliqu !

Code propre  → 0-2 WTF/minute
Code sale    → 10+ WTF/minute

Exemple avec un code java :

public double calc(double x, double y, int z) {
    if (z == 1) {
        return x * y * 0.2;
    } else if (z == 2) {
        return x * y * 0.1;
    } else if (z == 3) {
        return x * y * 0.05;
    }
    return 0;
}

Réactions possibles en revue :

WTF c’est quoi x ?
WTF c’est quoi y ?
WTF c’est quoi z ?
WTF pourquoi 0.2 ?
WTF pourquoi 1, 2, 3 ?
WTF qu’est-ce qu’on calcule exactement ?

Donc ici, beaucoup de WTF/minute !

Version plus clean du code :

public double calculerRemise(double prixUnitaire, double quantite, TypeClient typeClient) {
    double montantCommande = prixUnitaire * quantite;

    return switch (typeClient) {
        case PREMIUM -> montantCommande * 0.20;
        case FIDELE -> montantCommande * 0.10;
        case NOUVEAU -> montantCommande * 0.05;
    };
}

Avec l’enum :

public enum TypeClient {
    PREMIUM,
    FIDELE,
    NOUVEAU
}

Là, le lecteur ou la lectrice comprend presque naturellement :

On calcule une remise.
Le montant dépend du prix, de la quantité et du type de client.
Premium = 20 %
Fidèle = 10 %
Nouveau = 5 %

Donc : peu de WTF/minute… :)

Autre illustration :

if (user.getRole() == 1) {
    afficherTableauDeBordAdmin();
}

Votre réaction, WTF : c’est quoi 1 ?

Proposition de code propre :

if (user.hasRole(Role.ADMIN)) {
    afficherTableauDeBordAdmin();
}

1.3 Les caractéristiques du Clean Code

Selon les grands auteurs (Martin, Fowler, Beck), le Clean Code est :

1.4 La règle du Boy Scout

Laissez le code plus propre que vous ne l’avez trouvé… comme pour les toilettes, désolé pour la comparaison.

Chaque fois que vous touchez un fichier, améliorez-le légèrement : renommez une variable obscure, extrayez une fonction, supprimez un commentaire inutile. Le nettoyage se fait progressivement, sans grand refactoring risqué.


2. Le nommage — Donner du sens au code

Le nommage est probablement la compétence la plus importante du Clean Code. Un bon nom rend un commentaire inutile. Un mauvais nom exige des explications à chaque lecture.

2.1 Utiliser des noms révélateurs d’intention

Le nom d’une variable, méthode ou classe doit répondre à trois questions : Pourquoi existe-t-elle ? Que fait-elle ? Comment l’utiliser ?

// SALE — que signifient ces variables ?
int d; // elapsed time in days
List<int[]> theList;
int[] l1;

// PROPRE — le nom explique tout
int elapsedTimeInDays;
List<Cell> gameBoard;
int[] flaggedCells;

Règle : Si vous devez ajouter un commentaire pour expliquer une variable, c’est que son nom est mauvais.

// SALE — le commentaire compense un mauvais nom
// Check if employee is eligible for full benefits
if ((employee.flags & HOURLY_FLAG) && (employee.age > 65)) { ... }

// PROPRE — le code se lit comme une phrase
if (employee.isEligibleForFullBenefits()) { ... }

2.2 Éviter la désinformation

Certains noms induisent en erreur même s’ils semblent logiques :

// SALE — "List" dans le nom implique une java.util.List
// Si ce n'est pas une List, c'est trompeur !
Map<String, User> accountList = new HashMap<>();

// PROPRE
Map<String, User> accountsByUsername = new HashMap<>();

// SALE — noms quasi identiques, difficiles à distinguer
String XYZControllerForEfficientHandlingOfStrings;
String XYZControllerForEfficientStorageOfStrings;

// SALE — lettres similaires visuellement (l vs 1, O vs 0)
int l = 1;  // Est-ce un l minuscule ou un 1 ?
int O = 0;  // Est-ce un O majuscule ou un 0 ?

2.3 Noms prononçables et cherchables

// SALE — imprononçable, impossible à discuter à l'oral
private Date genymdhms;
private Date modymdhms;
private final int pszqint = 102;

// PROPRE — on peut en parler
private Date generationTimestamp;
private Date modificationTimestamp;
private final int RECORDS_PER_PAGE = 102;

Règle des noms cherchables : Préférez les noms longs aux noms courts si le code est important. MAX_CLASSES_PER_STUDENT est bien plus trouvable dans une recherche que 7.

// SALE — difficile à chercher dans un grand projet
for (int j = 0; j < 34; j++) {
    s += (t[j] * 4) / 5;
}

// PROPRE — chaque constante est nommée et cherchable
int realDaysPerIdealDay = 4;
final int WORK_DAYS_PER_WEEK = 5;
int sum = 0;
for (int taskIndex = 0; taskIndex < NUMBER_OF_TASKS; taskIndex++) {
    int realTaskDays = taskEstimate[taskIndex] * realDaysPerIdealDay;
    int realTaskWeeks = realTaskDays / WORK_DAYS_PER_WEEK;
    sum += realTaskWeeks;
}

2.4 Conventions de nommage Java

Élément Convention Exemple
Classe PascalCase, nom CustomerOrder, EmailService
Interface PascalCase, nom ou adjectif Serializable, UserRepository
Méthode camelCase, verbe calculateTotal(), findById()
Variable camelCase, nom customerName, orderList
Constante UPPER_SNAKE_CASE MAX_RETRY_COUNT, DEFAULT_TIMEOUT
Package lowercase com.myapp.service
// SALE — mélange de conventions
public class customer_order {
    private String CustomerName;
    public static final int maxretry = 3;
    public void Calculate_Total() { }
}

// PROPRE — conventions respectées
public class CustomerOrder {
    private String customerName;
    public static final int MAX_RETRY_COUNT = 3;
    public void calculateTotal() { }
}

2.5 Noms de classes et méthodes : les bonnes pratiques

// Classes : noms (substantifs) — ce qu'elles SONT
// Verbes dans les noms de classes
class ProcessData { }
class ManageUsers { }

// Substantifs clairs
class DataProcessor { }
class UserManager { }      // ou mieux : UserService, UserRepository selon le rôle

// Méthodes : verbes — ce qu'elles FONT
// Noms dans les méthodes
String name();         // Getter ?
boolean valid();       // Est-ce que ça vérifie ? Retourne un flag ?

// Verbes expressifs
String getName();
boolean isValid();
boolean hasPermission();
void sendEmail();
User createUser();
List<Order> findOrdersByCustomer(Customer customer);

2.6 Encodages et préfixes — à éviter

La notation hongroise (préfixe indiquant le type) était utile dans les années 80 avec des IDE rudimentaires. Avec les IDE modernes, c’est du bruit inutile.

//  SALE — préfixes inutiles
String m_customerName;   // préfixe membre
String strName;          // préfixe type
IUserService iService;   // préfixe Interface

// PROPRE — les IDE font le travail
String customerName;
String name;
UserService userService;   // ou juste "service" selon le contexte

3. Les fonctions — Faire une seule chose, bien

3.1 La règle d’or : une fonction = une chose

C’est probablement la règle la plus importante des fonctions. Une fonction ne doit faire qu’une seule chose, la faire bien, et ne faire qu’elle.

Comment savoir si une fonction fait trop de choses ? Si vous pouvez en extraire une autre fonction avec un nom différent qui n’est pas une simple reformulation de l’implémentation, alors la première en fait trop.

// SALE — cette méthode fait TOUT : valider, calculer, formater, sauvegarder
public String processOrder(Order order) {
    // Validation
    if (order == null) throw new IllegalArgumentException("Order cannot be null");
    if (order.getItems().isEmpty()) throw new IllegalArgumentException("Order has no items");

    // Calcul du total
    double total = 0;
    for (Item item : order.getItems()) {
        total += item.getPrice() * item.getQuantity();
        if (item.isOnSale()) total -= item.getDiscount();
    }
    if (order.hasPromoCode()) total *= 0.9;

    // Formatage
    String receipt = "=== RECEIPT ===\n";
    receipt += "Date: " + LocalDate.now() + "\n";
    for (Item item : order.getItems()) {
        receipt += item.getName() + " x" + item.getQuantity() + " = " + item.getPrice() + "\n";
    }
    receipt += "TOTAL: " + total + "\n";

    // Sauvegarde
    orderRepository.save(order);
    emailService.sendReceipt(order.getCustomerEmail(), receipt);

    return receipt;
}
// PROPRE — chaque méthode fait une seule chose
public String processOrder(Order order) {
    validateOrder(order);
    double total = calculateTotal(order);
    String receipt = generateReceipt(order, total);
    saveAndNotify(order, receipt);
    return receipt;
}

private void validateOrder(Order order) {
    if (order == null) throw new IllegalArgumentException("Order cannot be null");
    if (order.getItems().isEmpty()) throw new IllegalArgumentException("Order has no items");
}

private double calculateTotal(Order order) {
    double subtotal = order.getItems().stream()
        .mapToDouble(item -> item.getPrice() * item.getQuantity() - item.getDiscount())
        .sum();
    return order.hasPromoCode() ? subtotal * 0.9 : subtotal;
}

private String generateReceipt(Order order, double total) {
    StringBuilder receipt = new StringBuilder("=== RECEIPT ===\n");
    receipt.append("Date: ").append(LocalDate.now()).append("\n");
    order.getItems().forEach(item ->
        receipt.append(formatLine(item)).append("\n")
    );
    receipt.append("TOTAL: ").append(total).append("\n");
    return receipt.toString();
}

private String formatLine(Item item) {
    return String.format("%s x%d = %.2f€", item.getName(), item.getQuantity(), item.getPrice());
}

private void saveAndNotify(Order order, String receipt) {
    orderRepository.save(order);
    emailService.sendReceipt(order.getCustomerEmail(), receipt);
}

3.2 La taille des fonctions

Les fonctions doivent être petites. Et encore plus petites que ça. Robert Martin recommande des fonctions de 5 à 10 lignes maximum (hors cas exceptionnels). En pratique, visez moins de 20 lignes.

Règle du niveau d’abstraction unique : Toutes les instructions d’une fonction doivent être au même niveau d’abstraction. Ne mélangez pas le “quoi” (haut niveau) et le “comment” (bas niveau).

// SALE — mélange de niveaux d'abstraction
public void renderPage(Page page) {
    // Haut niveau
    String content = page.getContent();

    // Bas niveau (détails d'implémentation)
    StringBuffer buffer = new StringBuffer();
    buffer.append("<html><body>");
    for (char c : content.toCharArray()) {
        if (c == '<') buffer.append("&lt;");
        else if (c == '>') buffer.append("&gt;");
        else buffer.append(c);
    }
    buffer.append("</body></html>");

    // Haut niveau à nouveau
    htmlWriter.write(buffer.toString());
}

// PROPRE — même niveau d'abstraction dans chaque méthode
public void renderPage(Page page) {
    String escapedContent = escapeHtml(page.getContent());
    String html = wrapInHtml(escapedContent);
    htmlWriter.write(html);
}

private String escapeHtml(String content) {
    return content
        .replace("<", "&lt;")
        .replace(">", "&gt;");
}

private String wrapInHtml(String content) {
    return "<html><body>" + content + "</body></html>";
}

3.3 Les arguments de fonction

Moins d’arguments = meilleure lisibilité. L’idéal est zéro argument (niladic), puis un (monadic), deux (dyadic). Trois arguments (triadic) nécessitent une très bonne justification. Plus de trois : refactorisez !

// SALE — trop d'arguments, difficile à lire à l'appel
void createUser(String firstName, String lastName, String email,
                String phone, int age, String role, boolean active) { }

// À l'appel, on ne sait pas ce que signifie chaque valeur
createUser("Alice", "Dupont", "alice@test.com", "0612345678", 30, "ADMIN", true);

// PROPRE — regrouper en objet
void createUser(UserCreationRequest request) { }

// L'objet est auto-documenté
UserCreationRequest request = UserCreationRequest.builder()
    .firstName("Alice")
    .lastName("Dupont")
    .email("alice@test.com")
    .phone("0612345678")
    .age(30)
    .role(Role.ADMIN)
    .active(true)
    .build();
createUser(request);

Les arguments booléens sont un signe d’alerte ! Passer true ou false à une méthode signifie souvent que la méthode fait deux choses différentes selon le flag.

// SALE — que signifie "true" ici ?
render(page, true);

// Ce flag indique que la méthode fait deux choses
void render(Page page, boolean isSuite) {
    if (isSuite) renderPageWithSetupAndTearDown(page);
    else renderPage(page);
}

// PROPRE — deux méthodes distinctes
renderPageWithSetupAndTearDown(page);
renderPage(page);

3.4 Les effets de bord

Une fonction propre ne modifie pas d’état externe au-delà de ce que son nom indique. Les effets de bord cachés sont une source majeure de bugs.

// SALE — vérifier le mot de passe a un effet de bord caché (initialise la session !)
public boolean checkPassword(String userName, String password) {
    User user = UserGateway.findByName(userName);
    if (user != null) {
        String codedPhrase = user.getPhraseEncodedByPassword();
        if (codedPhrase.equals(password)) {
            Session.initialize(); // EFFET DE BORD CACHÉ
            return true;
        }
    }
    return false;
}

// PROPRE — le nom reflète tout ce que fait la méthode
public boolean checkPasswordAndInitializeSession(String userName, String password) { ... }
// Ou mieux : séparer les deux responsabilités
public boolean isPasswordValid(String userName, String password) { ... }
public void initializeSession(User user) { ... }

3.5 La loi de Déméter (principe du moindre connaissance)

Une méthode ne doit parler qu’à ses “amis directs” : elle ne doit pas “naviguer” à travers des objets pour en atteindre d’autres.

// SALE — "train wreck" : chaîne d'appels révélatrice d'un couplage fort
String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

// PROPRE — on demande ce dont on a besoin directement
String outputDir = ctxt.getScratchDirectoryPath();
// ctxt encapsule la logique de récupération du chemin

4. Les commentaires — Quand (ne pas) écrire

4.1 Les commentaires compensent un code mal écrit

Principe : Le meilleur commentaire est celui qu’on n’a pas besoin d’écrire.

Les commentaires ne corrigent pas un mauvais code, ils le cachent. Un code qui nécessite de nombreux commentaires pour être compris est un code à refactoriser.

// SALE — commentaire qui explique un code illisible
// Check to see if the employee is eligible for full benefits
if ((employee.flags & HOURLY_FLAG) && (employee.age > 65)) { }

// PROPRE — le code s'explique lui-même, commentaire inutile
if (employee.isEligibleForFullBenefits()) { }

4.2 Les bons commentaires

Certains commentaires sont légitimes et utiles :

Commentaires légaux (obligatoires)

// Copyright (C) 2024 by VavrBank Inc. All rights reserved.
// Released under the MIT License. See LICENSE file for details.

Explication d’intention (le POURQUOI)

// On utilise un TreeMap plutôt qu'un HashMap pour garantir
// l'ordre alphabétique des catégories dans l'export PDF.
Map<String, List<Product>> productsByCategory = new TreeMap<>();

Clarification d’un algorithme non évident

// Algorithme de Luhn — valide les numéros de carte bancaire
// https://en.wikipedia.org/wiki/Luhn_algorithm
public boolean isValidCreditCard(String number) { ... }

Avertissement de conséquences

// ATTENTION : Ce test prend ~3 minutes car il appelle l'API externe de paiement.
// Ne pas inclure dans la suite de tests unitaires rapides.
@Test
public void testRealPaymentGateway() { ... }

TODO — tâches à faire

// TODO: Remplacer par un cache Redis quand le trafic dépasse 1000 req/s
// Ticket: JIRA-1234
private Map<String, Product> productCache = new HashMap<>();

4.3 Les mauvais commentaires

Commentaires redondants (du bruit)

//  — le code dit déjà tout ça
/** The name of the customer */
private String customerName;

/** Returns the customer name */
public String getCustomerName() {
    return customerName; // return the customer name
}

Commentaires trompeurs

// — le commentaire dit que la méthode ferme l'inputStream, mais le code ne le fait PAS toujours
// Cette méthode ferme le stream et retourne l'instance
public synchronized void waitForClose(final long timeoutMillis) throws Exception {
    if (!closed) {
        wait(timeoutMillis);
        if (!closed) throw new Exception("MockResponseSender could not be closed");
        // Stream n'est PAS fermé ici ! Commentaire mensonger.
    }
}

Code commenté — à supprimer !

// — du code mort qui pollue la lecture. Supprimez-le ! Git s'en souvient.
// InputStreamResponse response = new InputStreamResponse();
// response.setBody(formatter.getResultStream(), formatter.getByteCount());
OutputStreamResponse response = new OutputStreamResponse();
response.setBody(formatter.getResultStream(), formatter.getByteCount());

Journaux de modifications en commentaire

//  — c'est le rôle de Git, pas des commentaires
// 2024-01-15 (Alice) : Ajout de la gestion du cas null
// 2024-01-20 (Bob) : Correction du bug #234
// 2024-02-01 (Alice) : Refactoring de la méthode calculate
public void calculate() { ... }

Commentaires de position (bannières)

//  — du bruit visuel
/////////////////////////////////////////////////////////////////////
// ACTION METHODS
/////////////////////////////////////////////////////////////////////

public void doAction() { ... }

/////////////////////////////////////////////////////////////////////
// GETTERS AND SETTERS
/////////////////////////////////////////////////////////////////////

5. La mise en forme — Le code se lit comme un journal

5.1 La métaphore du journal

Un bon journal place les informations les plus importantes en haut (titre, résumé), et les détails en bas. Le code doit suivre la même logique : les concepts de haut niveau en premier, les détails d’implémentation en bas.

// PROPRE — structure journalistique : du haut niveau vers le bas niveau
public class OrderService {

    // Point d'entrée public — haut niveau, vue d'ensemble
    public Receipt processOrder(Order order) {
        validateOrder(order);
        double total = calculateTotal(order);
        Receipt receipt = createReceipt(order, total);
        fulfillOrder(order, receipt);
        return receipt;
    }

    // Niveau intermédiaire
    private void validateOrder(Order order) {
        checkOrderNotEmpty(order);
        checkItemsAvailability(order);
    }

    private double calculateTotal(Order order) {
        double subtotal = sumItemPrices(order.getItems());
        return applyDiscounts(subtotal, order.getDiscounts());
    }

    // Bas niveau — détails d'implémentation
    private double sumItemPrices(List<Item> items) {
        return items.stream().mapToDouble(Item::getPrice).sum();
    }

    private double applyDiscounts(double subtotal, List<Discount> discounts) {
        return discounts.stream()
            .reduce(subtotal, (price, d) -> d.apply(price), Double::sum);
    }
}

5.2 L’espacement vertical — les lignes blanches comme séparateurs

Les lignes blanches séparent des concepts distincts. Leur absence crée un mur de code illisible.

// SALE — tout est aggloméré, on ne distingue pas les blocs logiques
public class BoldWidget extends ParentWidget {
    public static final String REGEXP = "'''.+?'''";
    private static final Pattern pattern = Pattern.compile(REGEXP, Pattern.MULTILINE+Pattern.DOTALL);
    public BoldWidget(ParentWidget parent, String text) throws Exception {
        super(parent);
        Matcher match = pattern.matcher(text);
        match.find();
        addChildWidgets(match.group(1));
    }
    public String render() throws Exception {
        StringBuffer html = new StringBuffer("<b>");
        html.append(childHtml()).append("</b>");
        return html.toString();
    }
}

// PROPRE — les lignes blanches séparent les concepts
public class BoldWidget extends ParentWidget {

    public static final String REGEXP = "'''.+?'''";
    private static final Pattern PATTERN =
        Pattern.compile(REGEXP, Pattern.MULTILINE + Pattern.DOTALL);

    public BoldWidget(ParentWidget parent, String text) throws Exception {
        super(parent);
        Matcher match = PATTERN.matcher(text);
        match.find();
        addChildWidgets(match.group(1));
    }

    public String render() throws Exception {
        StringBuffer html = new StringBuffer("<b>");
        html.append(childHtml()).append("</b>");
        return html.toString();
    }
}

5.3 L’espacement horizontal — densité de l’information

//  SALE — trop dense ou trop étalé
int result=a*b+c/d;
int result = a * b + c / d ;  // espace avant ";"

//  PROPRE — espaces autour des opérateurs, pas avant ";" ni les parenthèses de méthode
int result = a * b + c / d;

// La priorité des opérateurs peut se lire dans les espaces
// Multiplication (haute priorité) : pas d'espace
// Addition (basse priorité) : espace
double discriminant = b*b - 4*a*c;

5.4 L’indentation — la hiérarchie visuelle

//  SALE — indentation ignorée
public class Foo {
public int bar;
public void baz() {
if (bar > 0) {
for (int i = 0; i < bar; i++) {
System.out.println(i);
}
}
}
}

//  PROPRE — l'indentation révèle la structure
public class Foo {
    public int bar;

    public void baz() {
        if (bar > 0) {
            for (int i = 0; i < bar; i++) {
                System.out.println(i);
            }
        }
    }
}

Règle des accolades : En Java, les accolades ouvrantes sont sur la même ligne que la déclaration (style K&R). C’est la convention Java standard.

5.5 Longueur des lignes

La règle traditionnelle est 80 caractères par ligne. La règle moderne est 120 caractères maximum (largeur d’un écran standard). Au-delà, retournez à la ligne.

//  SALE — ligne trop longue, nécessite un scroll horizontal
return orderRepository.findByCustomerAndStatusAndCreatedDateBetween(customer, OrderStatus.ACTIVE, startDate, endDate);

//  PROPRE — retour à la ligne logique
return orderRepository.findByCustomerAndStatusAndCreatedDateBetween(
    customer,
    OrderStatus.ACTIVE,
    startDate,
    endDate
);

6. Les objets et les structures de données

6.1 L’encapsulation — cacher les données

L’encapsulation ne consiste pas seulement à mettre des getters/setters sur des champs privés. C’est cacher la représentation interne et exposer une abstraction.

//  SALE — des getters/setters mécaniques n'apportent aucune abstraction
// C'est l'équivalent d'un champ public !
public class Point {
    private double x;
    private double y;
    public double getX() { return x; }
    public void setX(double x) { this.x = x; }
    public double getY() { return y; }
    public void setY(double y) { this.y = y; }
}

// PROPRE — abstraction géométrique : on peut changer la représentation interne
// (passer en coordonnées polaires) sans casser le code qui utilise cette interface
public interface Point {
    double getX();
    double getY();
    void setCartesian(double x, double y); // Opération cohérente
    double getR();                         // Coordonnée polaire
    double getTheta();
    void setPolar(double r, double theta);
}

6.2 La Loi de Déméter en profondeur

//  SALE — on "navigue" à travers des objets internes
// On connaît la structure interne de Car (engine) et de Engine (fuelInjector)
double fuelLevel = car.getEngine().getFuelInjector().getFuelLevel();

//  PROPRE — on demande à Car ce dont on a besoin
// Car encapsule ses composants internes
double fuelLevel = car.getFuelLevel();
// Car délègue en interne à engine.getFuelLevel()

6.3 Data Transfer Objects (DTO)

Les DTO sont des structures de données pures, sans comportement, utilisées pour transférer des données entre les couches.

// DTO : structure pure, pas de logique métier
//  PROPRE pour un DTO
public record UserDTO(
    Long id,
    String firstName,
    String lastName,
    String email
) {}

// Entité de domaine : données + comportement
// PROPRE pour une entité
public class User {
    private Long id;
    private String firstName;
    private String lastName;
    private String email;
    private String passwordHash;

    // Comportement métier
    public String getFullName() {
        return firstName + " " + lastName;
    }

    public boolean hasVerifiedEmail() {
        return email != null && emailVerifiedAt != null;
    }

    // PAS de setter pour le mot de passe en clair !
    public void changePassword(String newPassword) {
        validatePasswordStrength(newPassword);
        this.passwordHash = hashPassword(newPassword);
    }
}

6.4 Éviter les structures de données anémiques

Une classe anémique est une classe qui n’a que des données (getters/setters) et aucun comportement. C’est une antipatterne car elle force la logique métier à fuir vers les services.

//  SALE — classe anémique : juste un sac de données
public class Order {
    private List<Item> items;
    private double total;
    private String status;

    public List<Item> getItems() { return items; }
    public void setItems(List<Item> items) { this.items = items; }
    public double getTotal() { return total; }
    public void setTotal(double total) { this.total = total; }
    // ... getters/setters à l'infini
}

// Logique métier éparpillée dans le service
public class OrderService {
    public void addItem(Order order, Item item) {
        order.getItems().add(item);
        order.setTotal(order.getTotal() + item.getPrice());
        if (order.getTotal() > 100) order.setStatus("ELIGIBLE_DISCOUNT");
    }
}

// PROPRE — classe riche : données + comportement
public class Order {
    private final List<Item> items = new ArrayList<>();
    private OrderStatus status = OrderStatus.PENDING;

    public void addItem(Item item) {
        items.add(item);
        updateStatus();
    }

    public double getTotal() {
        return items.stream().mapToDouble(Item::getPrice).sum();
    }

    private void updateStatus() {
        if (getTotal() > 100) status = OrderStatus.ELIGIBLE_DISCOUNT;
    }

    public boolean isEligibleForDiscount() {
        return status == OrderStatus.ELIGIBLE_DISCOUNT;
    }
}

7. La gestion des erreurs

7.1 Les exceptions plutôt que les codes d’erreur

// SALE — codes d'erreur à la C : le code appelant DOIT vérifier la valeur de retour
// Mais rien ne l'y oblige ! Les erreurs peuvent être ignorées silencieusement.
public int processPayment(Payment payment) {
    if (payment == null) return -1;        // Code d'erreur
    if (!payment.isValid()) return -2;     // Code d'erreur
    if (balance < payment.getAmount()) return -3; // Code d'erreur
    // ... traitement
    return 0; // Succès
}

// L'appelant peut ignorer l'erreur facilement (et c'est souvent ce qui se passe)
processPayment(payment); // Le code de retour est ignoré !

// PROPRE — les exceptions FORCENT la gestion des erreurs
public void processPayment(Payment payment) {
    validatePayment(payment);
    checkSufficientBalance(payment.getAmount());
    executeTransaction(payment);
}

// L'exception ne peut pas être ignorée silencieusement
// (ou du moins, c'est visible et délibéré)

7.2 Utiliser des exceptions non-vérifiées

En Java, il existe deux types d’exceptions :

//  SALE — checked exceptions polluent toutes les signatures
public User findById(Long id) throws DatabaseException, UserNotFoundException { }
// Chaque méthode appelante doit gérer ou propager ces exceptions

//  PROPRE — unchecked exceptions : seules les couches concernées les gèrent
public User findById(Long id) {
    return repository.findById(id)
        .orElseThrow(() -> new UserNotFoundException("User not found: " + id));
}

// Exception métier personnalisée
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message) {
        super(message);
    }
    public UserNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
}

7.3 Fournir du contexte dans les exceptions

//  SALE — aucune information pour le débogage
throw new RuntimeException("Error");
throw new Exception("Invalid input");

//  PROPRE — contexte complet pour diagnostiquer rapidement
throw new PaymentException(
    String.format("Payment failed for order %s: amount %.2f exceeds balance %.2f",
        order.getId(), payment.getAmount(), account.getBalance())
);

//  PROPRE — conservation de la cause originale
try {
    // ...
} catch (JdbcException e) {
    throw new UserRepositoryException(
        "Failed to find user with id " + id, e  // On conserve la cause !
    );
}

7.4 Ne jamais retourner null — ne jamais passer null

//  SALE — retourner null force chaque appelant à vérifier null
public List<Employee> getEmployees() {
    if (noEmployeesInDatabase) return null;
    // ...
}

// Chaque appelant doit faire :
List<Employee> employees = getEmployees();
if (employees != null) {  // Oubli → NullPointerException !
    for (Employee e : employees) { ... }
}

//  PROPRE — retourner une liste vide, jamais null
public List<Employee> getEmployees() {
    if (noEmployeesInDatabase) return Collections.emptyList();
    // ...
}

// L'appelant n'a pas à vérifier null
for (Employee e : getEmployees()) { ... }  // Fonctionne même si la liste est vide
//  SALE — passer null oblige la méthode à se défendre contre null
public void calculateMetrics(String firstName, String lastName) {
    if (firstName == null || lastName == null) {
        throw new NullPointerException("Name cannot be null");
    }
    // ...
}

// L'appelant peut accidentellement passer null :
calculateMetrics(customer.getFirstName(), null);

//  PROPRE — utilisez Optional pour signaler qu'une valeur peut être absente
public void calculateMetrics(String firstName, String lastName) {
    // Ces paramètres ne sont JAMAIS null par contrat
    Objects.requireNonNull(firstName, "firstName is required");
    Objects.requireNonNull(lastName, "lastName is required");
    // ...
}

// Ou mieux : encapsuler dans un objet
public void calculateMetrics(PersonName name) {
    // PersonName garantit que ses champs ne sont pas null
}

7.5 Définir une hiérarchie d’exceptions claire

//  PROPRE — hiérarchie d'exceptions applicatives bien structurée
public class AppException extends RuntimeException {
    public AppException(String message) { super(message); }
    public AppException(String message, Throwable cause) { super(message, cause); }
}

public class ResourceNotFoundException extends AppException {
    public ResourceNotFoundException(String resourceType, Object id) {
        super(String.format("%s not found with id: %s", resourceType, id));
    }
}

public class BusinessRuleException extends AppException {
    private final String ruleCode;

    public BusinessRuleException(String ruleCode, String message) {
        super(message);
        this.ruleCode = ruleCode;
    }

    public String getRuleCode() { return ruleCode; }
}

public class ValidationException extends AppException {
    private final List<String> violations;

    public ValidationException(List<String> violations) {
        super("Validation failed: " + String.join(", ", violations));
        this.violations = List.copyOf(violations);
    }

    public List<String> getViolations() { return violations; }
}

8. Les principes SOLID

Les principes SOLID sont cinq principes de conception orientée objet formulés par Robert C. Martin. Ils rendent le code plus maintenable, extensible et testable.

8.1 S — Single Responsibility Principle (SRP)

“Une classe ne devrait avoir qu’une seule raison de changer.”

Une classe qui a plusieurs responsabilités est fragile : changer une responsabilité peut casser les autres.

//  SALE — UserService fait TOUT : gestion métier, envoi d'emails, persistance
public class UserService {
    public void createUser(User user) {
        // Validation
        if (user.getEmail() == null) throw new RuntimeException("Email required");

        // Persistance (raison 1 de changer)
        userRepository.save(user);

        // Envoi d'email (raison 2 de changer)
        String htmlEmail = "<html><body>Bienvenue " + user.getName() + "</body></html>";
        emailClient.sendEmail(user.getEmail(), "Bienvenue", htmlEmail);

        // Log (raison 3 de changer)
        logger.info("User created: " + user.getId() + " at " + LocalDateTime.now());
    }
}

//  PROPRE — chaque classe a une seule responsabilité
public class UserService {
    private final UserRepository userRepository;
    private final UserNotificationService notificationService;

    public User createUser(CreateUserRequest request) {
        validateRequest(request);
        User user = userRepository.save(new User(request));
        notificationService.sendWelcomeEmail(user);
        return user;
    }

    private void validateRequest(CreateUserRequest request) {
        if (request.email() == null) throw new ValidationException("Email required");
    }
}

public class UserNotificationService {
    private final EmailClient emailClient;
    private final TemplateEngine templateEngine;

    public void sendWelcomeEmail(User user) {
        String body = templateEngine.render("welcome", Map.of("user", user));
        emailClient.sendEmail(user.getEmail(), "Bienvenue chez nous !", body);
    }
}

8.2 O — Open/Closed Principle (OCP)

“Une classe doit être ouverte à l’extension, mais fermée à la modification.”

On ne doit pas modifier une classe existante pour ajouter une fonctionnalité. On doit pouvoir l’étendre.

//  SALE — ajouter un nouveau type de remise nécessite de MODIFIER la classe
public class DiscountCalculator {
    public double calculate(Order order, String discountType) {
        return switch (discountType) {
            case "PERCENTAGE" -> order.getTotal() * 0.1;
            case "FIXED" -> order.getTotal() - 5.0;
            // Pour ajouter "BOGO", il faut MODIFIER cette classe !
            default -> order.getTotal();
        };
    }
}

// PROPRE — on ajoute des types de remise en créant de nouvelles classes
public interface DiscountStrategy {
    double apply(double total);
}

public class PercentageDiscount implements DiscountStrategy {
    private final double percentage;
    public PercentageDiscount(double percentage) { this.percentage = percentage; }

    @Override
    public double apply(double total) { return total * (1 - percentage / 100); }
}

public class FixedDiscount implements DiscountStrategy {
    private final double amount;
    public FixedDiscount(double amount) { this.amount = amount; }

    @Override
    public double apply(double total) { return Math.max(0, total - amount); }
}

// Pour ajouter BOGO : nouvelle classe, AUCUNE modification existante
public class BuyOneGetOneDiscount implements DiscountStrategy {
    @Override
    public double apply(double total) { return total / 2; }
}

public class DiscountCalculator {
    // Cette classe ne change JAMAIS pour de nouveaux types de remise
    public double calculate(Order order, DiscountStrategy strategy) {
        return strategy.apply(order.getTotal());
    }
}

8.3 L — Liskov Substitution Principle (LSP)

“Les objets d’une sous-classe doivent pouvoir remplacer les objets de la classe parente sans altérer le comportement du programme.”

//  SALE — violation du LSP : Square étend Rectangle mais change le comportement attendu
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int getArea() { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // Un carré : largeur = hauteur
    }

    @Override
    public void setHeight(int height) {
        this.height = height;
        this.width = height; // Un carré : largeur = hauteur
    }
}

// Ce code fonctionne avec Rectangle mais CASSE avec Square !
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(10);
// Attendu : area = 50. Obtenu : area = 100 ! (Square a écrasé width avec 10)
assert r.getArea() == 50; // ÉCHOUE !

//  PROPRE — pas d'héritage, interface commune
public interface Shape {
    int getArea();
}

public class Rectangle implements Shape {
    private final int width;
    private final int height;
    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
    @Override
    public int getArea() { return width * height; }
}

public class Square implements Shape {
    private final int side;
    public Square(int side) { this.side = side; }

    @Override
    public int getArea() { return side * side; }
}

8.4 I — Interface Segregation Principle (ISP)

“Les clients ne doivent pas être forcés de dépendre d’interfaces qu’ils n’utilisent pas.”

//  SALE — interface "grasse" qui force des implémentations vides
public interface Animal {
    void eat();
    void sleep();
    void fly();    // Les chiens ne volent pas !
    void swim();   // Les aigles ne nagent pas (ou rarement) !
}

public class Dog implements Animal {
    public void eat() { System.out.println("Dog eating"); }
    public void sleep() { System.out.println("Dog sleeping"); }
    public void fly() { throw new UnsupportedOperationException("Dogs can't fly!"); }
    public void swim() { System.out.println("Dog swimming"); }
}

//  PROPRE — interfaces petites et ciblées
public interface Eatable { void eat(); }
public interface Sleepable { void sleep(); }
public interface Flyable { void fly(); }
public interface Swimmable { void swim(); }

public class Dog implements Eatable, Sleepable, Swimmable {
    public void eat() { System.out.println("Dog eating"); }
    public void sleep() { System.out.println("Dog sleeping"); }
    public void swim() { System.out.println("Dog swimming"); }
    // Dog n'implémente PAS Flyable — c'est logique et honnête
}

public class Eagle implements Eatable, Sleepable, Flyable {
    public void eat() { System.out.println("Eagle eating"); }
    public void sleep() { System.out.println("Eagle sleeping"); }
    public void fly() { System.out.println("Eagle flying"); }
}

8.5 D — Dependency Inversion Principle (DIP)

“Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d’abstractions.”

C’est la base de l’injection de dépendances (le cœur de Spring Boot !).

//  SALE — UserService dépend directement de MySqlUserRepository (implémentation concrète)
public class UserService {
    // Couplage fort : impossible de tester sans MySQL, impossible de changer d'implémentation
    private MySqlUserRepository repository = new MySqlUserRepository();

    public User findById(Long id) {
        return repository.findById(id);
    }
}

//  PROPRE — UserService dépend d'une abstraction (interface)
// L'implémentation concrète est injectée de l'extérieur
public class UserService {
    private final UserRepository repository; // Interface, pas une classe concrète

    // Injection de dépendance via le constructeur
    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public User findById(Long id) {
        return repository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("User not found: " + id));
    }
}

// L'interface — l'abstraction
public interface UserRepository {
    Optional<User> findById(Long id);
    User save(User user);
}

// Implémentation pour la production
@Repository
public class JpaUserRepository implements UserRepository { ... }

// Implémentation pour les tests
public class InMemoryUserRepository implements UserRepository {
    private final Map<Long, User> store = new HashMap<>();
    // ...
}

9. Les tests — Le Clean Code se prouve

9.1 Les tests propres : les 3 A (Arrange, Act, Assert)

Un test propre suit une structure en trois phases clairement séparées :

@Test
void shouldCalculateTotalWithDiscount() {
    // ARRANGE — Préparer le contexte (les données d'entrée)
    Order order = new Order();
    order.addItem(new Item("Livre Java", 35.00));
    order.addItem(new Item("Livre Spring", 40.00));
    DiscountStrategy discount = new PercentageDiscount(10); // 10% de remise

    // ACT — Exécuter l'action testée (une seule action !)
    double total = order.calculateTotal(discount);

    // ASSERT — Vérifier le résultat (une ou quelques assertions cohérentes)
    assertThat(total).isCloseTo(67.50, within(0.01));
}

9.2 Les principes F.I.R.S.T.

Lettre Principe Signification
F Fast Les tests doivent être rapides (< 1s unitaire)
I Independent Les tests ne doivent pas dépendre les uns des autres
R Repeatable Le même résultat peu importe l’environnement
S Self-Validating Pas besoin d’un humain pour interpréter le résultat
T Timely Écrits au moment du code (idéalement avant, TDD)

9.3 Un test = un concept

//  SALE — teste plusieurs concepts dans un seul test
@Test
void testOrder() {
    Order order = new Order();
    order.addItem(new Item("A", 10.0));
    assertThat(order.getTotal()).isEqualTo(10.0);
    order.addItem(new Item("B", 20.0));
    assertThat(order.getTotal()).isEqualTo(30.0);
    order.applyDiscount(new FixedDiscount(5.0));
    assertThat(order.getTotal()).isEqualTo(25.0);
    order.clear();
    assertThat(order.getTotal()).isEqualTo(0.0);
    // Si une assertion échoue, on ne sait pas laquelle, ni pourquoi
}

//  PROPRE — un test par concept, noms expressifs
@Test
void totalShouldBeZeroForEmptyOrder() {
    Order order = new Order();
    assertThat(order.getTotal()).isZero();
}

@Test
void totalShouldSumAllItemPrices() {
    Order order = new Order();
    order.addItem(new Item("A", 10.0));
    order.addItem(new Item("B", 20.0));

    assertThat(order.getTotal()).isEqualTo(30.0);
}

@Test
void totalShouldApplyFixedDiscountCorrectly() {
    Order order = orderWithItems(new Item("A", 30.0));

    order.applyDiscount(new FixedDiscount(5.0));

    assertThat(order.getTotal()).isEqualTo(25.0);
}

9.4 Nommer les tests pour qu’ils racontent une histoire

//  SALE — noms opaques
@Test void test1() { }
@Test void testUser() { }
@Test void testCalculateMethod() { }

//  PROPRE — pattern : should[Résultat]_when[Contexte]
@Test
void shouldThrowException_whenEmailIsNull() { }

@Test
void shouldReturnEmptyList_whenNoProductsMatchSearch() { }

@Test
void shouldSendWelcomeEmail_whenUserSuccessfullyRegisters() { }

// Ou style BDD (Behavior Driven Development)
@Test
@DisplayName("Un utilisateur avec un email invalide ne peut pas s'inscrire")
void givenInvalidEmail_whenRegisterUser_thenThrowsValidationException() { }

9.5 Utiliser des builders pour les données de test

//  SALE — répéter la création des objets de test partout
@Test
void test1() {
    User user = new User();
    user.setId(1L);
    user.setFirstName("Alice");
    user.setLastName("Dupont");
    user.setEmail("alice@test.com");
    user.setRole(Role.USER);
    user.setActive(true);
    // ... le test commence seulement maintenant
}

//  PROPRE — helper de test ou builder
// Dans la classe de test (ou une classe TestFixtures partagée)
private User aUser() {
    return User.builder()
        .id(1L)
        .firstName("Alicia")
        .lastName("Duponti")
        .email("aliciae@test.com")
        .role(Role.USER)
        .active(true)
        .build();
}

private User anAdminUser() {
    return aUser().toBuilder()
        .role(Role.ADMIN)
        .build();
}

@Test
void shouldGrantAccessToAdminResources_whenUserIsAdmin() {
    User admin = anAdminUser();
    assertThat(accessControl.canAccess(admin, Resource.ADMIN_DASHBOARD)).isTrue();
}

10. Le refactoring — Nettoyer progressivement

10.1 Identifier les “code smells” (mauvaises odeurs)

Les code smells sont des indicateurs que le code a besoin d’être refactorisé. Ils ne sont pas des bugs, mais des signaux d’alerte.

Code Smell Symptôme Solution
Long Method Méthode > 20 lignes Extraire des méthodes
Large Class Classe > 300 lignes Extraire des classes
Long Parameter List > 3 paramètres Créer un objet paramètre
Duplicate Code Même logique à plusieurs endroits Extraire et réutiliser
Dead Code Code jamais exécuté Supprimer
Magic Numbers Nombres sans contexte (42, 0.15) Constantes nommées
God Class Classe qui sait tout, fait tout Décomposer
Feature Envy Méthode qui utilise plus les données d’une autre classe Déplacer la méthode
Data Clumps Groupe de variables toujours ensemble Créer un objet

10.2 Éliminer les nombres magiques

//  SALE — que signifient 86400, 7, 0.15 ?
if (session.getDuration() > 86400) { expireSession(); }
if (cart.getTotal() > 100) { applyDiscount(0.15); }
for (int i = 0; i < 7; i++) { generateDayReport(i); }

// PROPRE — les constantes ont du sens
private static final int SECONDS_PER_DAY = 86_400;
private static final double LOYALTY_DISCOUNT_RATE = 0.15;
private static final int DAYS_PER_WEEK = 7;
private static final double MINIMUM_ORDER_FOR_DISCOUNT = 100.0;

if (session.getDuration() > SECONDS_PER_DAY) { expireSession(); }
if (cart.getTotal() > MINIMUM_ORDER_FOR_DISCOUNT) { applyDiscount(LOYALTY_DISCOUNT_RATE); }
for (int day = 0; day < DAYS_PER_WEEK; day++) { generateDayReport(day); }

10.3 Extraire des méthodes (Extract Method)

//  SALE — méthode longue avec commentaires pour s'y retrouver
public void printOwing(Order order) {
    // print banner
    System.out.println("*************************");
    System.out.println("***** Customer Owes *****");
    System.out.println("*************************");

    // calculate outstanding
    double outstanding = 0.0;
    for (OrderLine line : order.getLines()) {
        outstanding += line.getAmount();
    }

    // print details
    System.out.println("name: " + order.getCustomer().getName());
    System.out.println("amount: " + outstanding);
}

//  PROPRE — chaque section devient une méthode nommée
public void printOwing(Order order) {
    printBanner();
    double outstanding = calculateOutstanding(order);
    printOrderDetails(order, outstanding);
}

private void printBanner() {
    System.out.println("*************************");
    System.out.println("***** Customer Owes *****");
    System.out.println("*************************");
}

private double calculateOutstanding(Order order) {
    return order.getLines().stream()
        .mapToDouble(OrderLine::getAmount)
        .sum();
}

private void printOrderDetails(Order order, double outstanding) {
    System.out.println("name: " + order.getCustomer().getName());
    System.out.println("amount: " + outstanding);
}

10.4 Remplacer les conditions complexes

//  SALE — condition difficile à lire
if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
    charge = quantity * winterRate + winterServiceCharge;
} else {
    charge = quantity * summerRate;
}

//  PROPRE — méthode extraite pour clarifier la condition
if (isNotSummer(date)) {
    charge = calculateWinterCharge(quantity);
} else {
    charge = calculateSummerCharge(quantity);
}

private boolean isNotSummer(LocalDate date) {
    return date.isBefore(SUMMER_START) || date.isAfter(SUMMER_END);
}

11. Clean Code avec Spring Boot

11.1 Architecture en couches propre

┌─────────────────────────────────────┐
│  Controller (HTTP / REST)           │  ← Reçoit les requêtes, retourne les réponses
├─────────────────────────────────────┤
│  Service (Logique métier)           │  ← Toute la logique de l'application
├─────────────────────────────────────┤
│  Repository (Accès aux données)     │  ← Requêtes BDD, jamais de logique métier
├─────────────────────────────────────┤
│  Domain (Entités / Value Objects)   │  ← Le cœur : entités riches, règles métier
└─────────────────────────────────────┘

Règle d’or : Les dépendances vont vers le bas uniquement. Un Controller peut appeler un Service. Un Service peut appeler un Repository. Jamais l’inverse.

11.2 Controllers propres

//  SALE — logique métier dans le controller
@RestController
@RequestMapping("/api/books")
public class BookController {

    @Autowired
    private BookRepository bookRepository; // Dépendance directe au repository !

    @PostMapping
    public ResponseEntity<?> create(@RequestBody Map<String, Object> body) {
        // Validation dans le controller !
        if (body.get("title") == null || body.get("title").toString().isBlank()) {
            return ResponseEntity.badRequest().body("Title required");
        }
        // Logique métier dans le controller !
        Book book = new Book();
        book.setTitle(body.get("title").toString());
        book.setIsbn(UUID.randomUUID().toString());
        book.setCreatedAt(LocalDateTime.now());
        bookRepository.save(book);
        return ResponseEntity.status(201).body(book);
    }
}

//  PROPRE — controller mince : délègue tout au service
@RestController
@RequestMapping("/api/books")
@RequiredArgsConstructor
public class BookController {

    private final BookService bookService;  // Dépend du service, pas du repository

    @PostMapping
    public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest request) {
        BookResponse created = bookService.createBook(request);
        URI location = URI.create("/api/books/" + created.id());
        return ResponseEntity.created(location).body(created);
    }

    @GetMapping("/{id}")
    public ResponseEntity<BookResponse> findById(@PathVariable Long id) {
        return ResponseEntity.ok(bookService.findById(id));
    }

    @GetMapping
    public ResponseEntity<Page<BookResponse>> search(
            @RequestParam(required = false) String query,
            Pageable pageable) {
        return ResponseEntity.ok(bookService.search(query, pageable));
    }
}

11.3 Services propres

//  PROPRE — service avec responsabilités claires
@Service
@Transactional
@RequiredArgsConstructor
public class BookService {

    private final BookRepository bookRepository;
    private final AuthorRepository authorRepository;
    private final BookMapper bookMapper;

    @Transactional(readOnly = true)
    public BookResponse findById(Long id) {
        Book book = findBookOrThrow(id);
        return bookMapper.toResponse(book);
    }

    public BookResponse createBook(CreateBookRequest request) {
        validateIsbnUniqueness(request.isbn());
        Author author = findAuthorOrThrow(request.authorId());
        Book book = bookMapper.toEntity(request, author);
        Book saved = bookRepository.save(book);
        return bookMapper.toResponse(saved);
    }

    @Transactional(readOnly = true)
    public Page<BookResponse> search(String query, Pageable pageable) {
        Page<Book> books = (query == null || query.isBlank())
            ? bookRepository.findAll(pageable)
            : bookRepository.findByTitleContainingIgnoreCase(query, pageable);
        return books.map(bookMapper::toResponse);
    }

    // Méthodes privées utilitaires — nommées pour exprimer l'intention
    private Book findBookOrThrow(Long id) {
        return bookRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Book", id));
    }

    private Author findAuthorOrThrow(Long authorId) {
        return authorRepository.findById(authorId)
            .orElseThrow(() -> new ResourceNotFoundException("Author", authorId));
    }

    private void validateIsbnUniqueness(String isbn) {
        if (bookRepository.existsByIsbn(isbn)) {
            throw new BusinessRuleException("ISBN_ALREADY_EXISTS",
                "A book with ISBN " + isbn + " already exists");
        }
    }
}

11.4 Gestion globale des exceptions

//  PROPRE — gestion centralisée des exceptions, controllers restent propres
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
    }

    @ExceptionHandler(BusinessRuleException.class)
    public ResponseEntity<ErrorResponse> handleBusinessRule(BusinessRuleException ex) {
        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
            .body(new ErrorResponse(ex.getRuleCode(), ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidation(
            MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
            .toList();
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(new ValidationErrorResponse("VALIDATION_FAILED", errors));
    }
}

public record ErrorResponse(String code, String message) {}
public record ValidationErrorResponse(String code, List<String> errors) {}

11.5 Utiliser les DTOs et Mappers

//  PROPRE — DTO d'entrée avec validation Bean Validation
public record CreateBookRequest(
    @NotBlank(message = "Title is required")
    @Size(max = 255, message = "Title must not exceed 255 characters")
    String title,

    @NotBlank(message = "ISBN is required")
    @Pattern(regexp = "\\d{13}", message = "ISBN must be 13 digits")
    String isbn,

    @NotNull(message = "Author ID is required")
    Long authorId,

    @Min(value = 1, message = "Price must be positive")
    @Max(value = 9999, message = "Price is unrealistically high")
    double price
) {}

//  PROPRE — DTO de sortie (ce qu'on expose à l'extérieur)
public record BookResponse(
    Long id,
    String title,
    String isbn,
    String authorName,
    double price,
    LocalDateTime createdAt
) {}

//  PROPRE — Mapper : conversion entre entité et DTO
@Component
public class BookMapper {

    public BookResponse toResponse(Book book) {
        return new BookResponse(
            book.getId(),
            book.getTitle(),
            book.getIsbn(),
            book.getAuthor().getFullName(),
            book.getPrice(),
            book.getCreatedAt()
        );
    }

    public Book toEntity(CreateBookRequest request, Author author) {
        return Book.builder()
            .title(request.title())
            .isbn(request.isbn())
            .price(request.price())
            .author(author)
            .createdAt(LocalDateTime.now())
            .build();
    }
}

12. TP Final — Librairie en ligne

Le TP complet est disponible dans le fichier tp-enonce-clean-code


Récapitulatif : Les règles d’or du Clean Code

Règle Explication courte
Noms révélateurs Le nom dit ce que c’est / fait, sans commentaire
Fonctions courtes < 20 lignes, une seule chose
Un niveau d’abstraction Ne pas mélanger haut et bas niveau
Pas de commentaires inutiles Si le code est clair, les commentaires sont redondants
Pas de null Retourner Optional ou liste vide, jamais null
Exceptions typées Des classes d’exception métier explicites
SRP Une classe, une raison de changer
DIP Dépendre d’abstractions, pas d’implémentations
Tests F.I.R.S.T. Fast, Independent, Repeatable, Self-validating, Timely
Règle du Boy Scout Toujours laisser le code plus propre qu’on ne l’a trouvé

La citation finale de Uncle Bob : “Écrire du code propre demande la discipline des milliers de petites techniques, apprises à travers l’heuristique difficile de la pratique ‘propre’. […] Il n’y a pas de raccourci.”

Petit rappel :