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.
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(); }
Selon les grands auteurs (Martin, Fowler, Beck), le Clean Code est :
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é.
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.
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()) { ... }
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 ?
// 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.
MAX_CLASSES_PER_STUDENT
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; }
CustomerOrder
EmailService
Serializable
UserRepository
calculateTotal()
findById()
customerName
orderList
MAX_RETRY_COUNT
DEFAULT_TIMEOUT
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() { } }
// 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);
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
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); }
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("<"); else if (c == '>') buffer.append(">"); 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("<", "<") .replace(">", ">"); } private String wrapInHtml(String content) { return "<html><body>" + content + "</body></html>"; }
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.
true
false
// 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);
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) { ... }
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
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()) { }
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<>();
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 /////////////////////////////////////////////////////////////////////
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); } }
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(); } }
// 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;
// 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.
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 );
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); }
// 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()
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); } }
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; } }
// 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é)
En Java, il existe deux types d’exceptions :
Exception
RuntimeException
// 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); } }
// 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 ! ); }
// 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 }
// 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; } }
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.
“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); } }
“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()); } }
“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; } }
“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"); } }
“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<>(); // ... }
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)); }
// 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); }
// 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() { }
// 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(); }
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.
42
0.15
// 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); }
// 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); }
// 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); }
┌─────────────────────────────────────┐ │ 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.
// 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)); } }
// 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"); } } }
// 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) {}
// 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(); } }
Le TP complet est disponible dans le fichier tp-enonce-clean-code
tp-enonce-clean-code
Optional
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 :