Aller au contenu

Spring Boot : Bonnes pratiques

Injection d’un Bean : Authowired ou pas ?

Exemple avec l’utilisation des Repositories

Il y a une distinction importante à faire entre une injection par constructeur et une injection par annotation (@Autowired). Il est vrai que l’utilisation de l’autowired est bien pratique, cependant Spring recommande la déclaration par constructeur.

Voyons un exemple :

Injection par Authowired

Spring se charge de remplir les propriétés après avoir créé l’objet du contrôleur. C’est simple,pratique mais pas idéal à long terme !

@RestController
@RequestMapping("/api/public")
public class PublicController {

    Autowired
    private AdherentRepository adherentRepository;

    @Autowired
    private ChienRepository chienRepository;

}

Injection par contrôleur

Ici, Spring injecte automatiquement les dépendances via le constructeur, sans besoin de @Autowired, car depuis Spring 4.3, si une classe n’a qu’un seul constructeur, Spring l’utilise automatiquement pour l’injection !

@RestController
@RequestMapping("/api/public")
public class PublicController {

    // déclaration
    private final ChienRepository chichienRepositoryens;
    private final AdherentRepository adherentRepository;

    // les 2 repositories utilisés
    public PublicQueryController(ChienRepository chienRepository,
                                 AdherentRepository adherentRepository)
    {
        // initialisation des repos
        this.ChienRepository = chienRepository;
        this.AdherentRepository = adherentRepository;
    }
}

Intérêt d’utiliser le Contrôleur

Avec l’injection par le constructeur, toutes les dépendances requises sont explicites. Si le contrôleur ne peut pas exister sans un Repository, on le sait.

Ça évite de créer des “demi-objets” avec des dépendances null !

C’est parfait pour l’injection de dépendances immuables (donc plus sûr en multi-thread)

Version en utilisant Lombok

Lombok génère automatiquement le constructeur avec les bons paramètres en utilisant les annotations comme ci-dessous @RequiredArgsConstructor. On gagne en lisibilité sans sacrifier la rigueur.

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/public")
public class PublicController {
    private final ChienRepository chienRepository;
    private final AdherentRepository adherentRepository;
}

Comparaison avec les tests unitaires

L’injection par constructeur rend les tests unitaires plus propres et plus faciles ! Tester un contrôleur Spring Boot sans démarrer le serveur (test unitaire pur). On veut juste vérifier le comportement du contrôleur quand on mocke les repositories.

Version avec injection dans le contrôleur

Version testable sans Spring.

On va créer un test JUnit avec Mockito (aucun @SpringBootTest n’est nécessaire).

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

class PublicControllerTest {

    @Test
    void testGetAllChiens() {
        // On créé un mock du repository
        ChienRepository mockChienRepo = Mockito.mock(ChienRepository.class);

        // On définit le comportement du mock
        Mockito.when(mockChienRepo.findAll()).thenReturn(List.of(
                new Chien("Rex"), new Chien("Milou")
        ));

        // on injecte les mocks dans le contrôleur
        PublicController controller = new PublicController(mockChienRepo);

        // On appelle la méthode à tester
        List<Chien> result = controller.getAllChiens();

        // on vérifie le résultat
        assertThat(result).hasSize(2);
        assertThat(result.get(0).getNom()).isEqualTo("Rex");

        // on vérifie que le repository a bien été appelé
        Mockito.verify(mockChienRepo).findAll();
    }
}

Il faut ajouter la classes et repositories pour que notre exemple compile et fonctionne :

On se limite à la classe Chien pour le moment.

public class Chien {
    private String nom;
    public Chien(String nom) { this.nom = nom; }
    public String getNom() { return nom; }
}
public interface ChienRepository {
    List<Chien> findAll();
}

Pourquoi c’est possible uniquement avec l’injection par constructeur ?

Dans notre exemple de tests, on ne démarre pas Spring Boot et pourtant, notre contrôleur fonctionne !

Si on avait utilisé le code ci-dessous :

@Autowired
private ChienRepository chienRepository;

Nous ne pourrions pas injecter manuellement un mock sans Spring et on aurait dû écrire un test d’intégration avec @SpringBootTest qui est beaucoup plus lent et lourd. On aurait perdu la modularité et la testabilité.

Comparatif des 2 approches

Critère @Autowired sur champ Injection par constructeur
Lisibilité rapide à écrire un peu plus verbeux
Tests unitaires difficile à mocker sans framework (Mockito, etc.) facile à injecter manuellement dans un test
Immuabilité champ modifiable (non final) champ final, objet immuable
Null Safety risque d’avoir null si mal configuré dépendance garantie à la création
Meilleure pratique Spring moderne obsolète pour les nouveaux projets recommandée officiellement
Support Lombok (@RequiredArgsConstructor) inutilisable fonctionne parfaitement

Conclusion

En résumé

L’injection par constructeur nous permet de tester sans Spring (tests unitaires plus rapides) et aussi avec Spring (MockMvc/intégration)

Donc, cette syntaxe offre le meilleur des deux mondes :

Test d’intégration avec Spring Boot (Exemple avec MockMvc)

c’est-à-dire un test qui démarre un contexte Spring minimal pour simuler un vrai appel HTTP au contrôleur via MockMvc. on va ainsi voir comment le même contrôleur peut être testé dans un environnement “réel” (mais rapide) dans lequel Spring gère tout ! (injection, routage, JSON, etc.)

On reprend notre contrôleur à tester en y mettant Concours.

@RestController
@RequestMapping("/api/public")
public class PublicController {

    private final ChienRepository chiens;
    private final ConcoursRepository concours;

    public PublicController(ChienRepository chiens, ConcoursRepository concours) {
        this.chiens = chiens;
        this.concours = concours;
    }

    @GetMapping("/chiens")
    public List<Chien> getAllChiens() {
        return chiens.findAll();
    }

    @GetMapping("/concours")
    public List<Concours> getAllConcours() {
        return concours.findAll();
    }
}

Nos classes modèles Chien et Concours :

public class Chien {
    private String nom;

    public Chien(String nom) { this.nom = nom; }
    public String getNom() { return nom; }
}

public class Concours {
    private String nom;

    public Concours(String nom) { this.nom = nom; }
    public String getNom() { return nom; }
}


Test d’intégration complet avec MockMvc

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(PublicQueryController.class) // on démarre uniquement la couche web
class PublicQueryControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc; // outil pour simuler des requêtes HTTP

    // on remplace les vrais repositories par des mocks
    @MockBean
    private ChienRepository chienRepository;

    @MockBean
    private ConcoursRepository concoursRepository;

    @Test
    void shouldReturnListOfChiens() throws Exception {
        // on configure le comportement du mock
        when(chienRepository.findAll()).thenReturn(List.of(
                new Chien("Rex"),
                new Chien("Milou")
        ));

        // on simule un appel GET /api/public/chiens
        mockMvc.perform(get("/api/public/chiens"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].nom").value("Rex"))
                .andExpect(jsonPath("$[1].nom").value("Milou"));
    }
}

Tableau récapitulatif du code

Élément Rôle
@WebMvcTest(PublicQueryController.class) Démarre uniquement la couche Web (sans base de données ni services)
@MockBean Indique à Spring de fournir des mocks injectés automatiquement dans le contrôleur
MockMvc Permet de simuler une requête HTTP réelle sans serveur externe
jsonPath() Vérifie le contenu du JSON de la réponse

Résultat attendu (en console)

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /api/public/chiens
      Handler:
             Type = PublicQueryController
             Method = getAllChiens()

MockHttpServletResponse:
      Status = 200
      Body = [{"nom":"Rex"},{"nom":"Milou"}]

Comparaison des 3 modes de tests

Type de tests Démarrage Spring ? Vitesse Niveau testé Annotation clé
Unitaire (Mockito) Non Très rapide Logique interne
Intégration Web (MockMvc) Oui (Web seulement) Rapide Routes, JSON, contrôleurs @WebMvcTest
End-to-End (SpringBootTest) Oui (tout le contexte) Lent Application complète (web + DB + service) @SpringBootTest

Ne pas abuser du @Autowired et des annotations magiques

Pas d’injection sur champ, ni de @Autowired en vrac, il vaut mieux :

Exemple :

@Configuration
public class AppConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Respecter une architecture claire (3 couches minimum)

Objectif : Séparer les responsabilités et éviter le code spaghetti !

api/
 ├── controller/
 │    └── AdherentController.java
 ├── service/
 │    └── AdherentService.java
 ├── repository/
 │    └── AdherentRepository.java
 └── model/
      └── Adherent.java

Pour simplifier :

Couche Rôle Annotation
Contrôleur Reçoit les requêtes HTTP @RestController
Service Logique métier @Service
Repository Accès BD @Repository

Utiliser ResponseEntity pour les réponses HTTP

C’est la meilleure façon pour :

Exemple :

@GetMapping("/{id}")
public ResponseEntity<Adherent> getAdherent(@PathVariable Long id) {
    return adherentService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

Centraliser la gestion des erreurs avec @ControllerAdvice

Il faut prendre l’habitude de contrôler les exceptions (ne pas les laisser surcharger vos logs !).

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<String> handleNotFound(EntityNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
    }
}

Valider les entrées avec @Valid et @NotNull

Voici une autre bonne pratique pour éviter les NullPointerException et les données pourries dès la réception !

@PostMapping("/adherents")
public ResponseEntity<?> create(@Valid @RequestBody AdherentDTO dto) {
    return ResponseEntity.ok(service.save(dto));
}

public class AdherentDTO {
    @NotBlank private String nom;
    @Email private String email;
}

Nous verrons en détail les différents paramètres existants.

Distinguer les entités (@Entity) des DTOs

Même si on a tendance à le faire lors des TP et projets exemples pour simplifier, il est préférable d’utiliser des DTO pour les raisons ci-dessous.

public record AdherentDTO(Long id, String nom, String email) {}

@Service
public class AdherentService {
    public AdherentDTO toDTO(Adherent a) {
        return new AdherentDTO(a.getId(), a.getNom(), a.getEmail());
    }
}

On peut aussi utiliser MapStruct ou ModelMapper pour automatiser la conversion !

Éviter la logique métier dans les contrôleurs

Le contrôleur ne doit faire que :

Code correct :

@GetMapping
public List<Chien> getChiens() {
    return service.findAdultes();
}

Exemple de code incorrect :

@GetMapping
public List<Chien> getChiens() {
    return repository.findAll()
                     .stream()
                     .filter(c -> c.getAge() > 2)
                     .toList();
}

Activer les logs proprement (SLF4J / Lombok)

SLF4J (Simple Logging Façade for Java) est une surcouche d’abstraction permettant à votre application de fonctionner avec une API de log quelconque (comme LOG4J 2, java.util.logging).

Ainsi le changement de la solution de log concrètement utilisée, n’impactera aucune ligne de code de votre application.

Pour éviter les System.out.println surtout en prod :

@Slf4j
@Service
public class ConcoursService {
    public void inscrire(Chien chien) {
        log.info("Inscription du chien {}", chien.getNom());
    }
}

Utiliser des propriétés externalisées (application.yml)

Il ne faut pas de configuration en dur, il vaut mieux utiliser les fichiers :

Exemples :

app:
  nom: MonSiteAccessible
  mail: contact@monsiteaccessible.com

et

@ConfigurationProperties(prefix = "app")
public record AppProperties(String nom, String mail) {}

Créer des profils Spring différents (dev, test, prod)

# application-dev.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb

Bien pratique…

# application-prod.yml
spring:
  datasource:
    url: jdbc:postgresql://prod-db:5432/app

Pour sélectionner le profil avec SpringBoot :


--spring.profiles.active=dev

Être prêt pour la Prod : Observabilité et Sécurité

Il faut activer :

GET /actuator/health` {"status":"UP"}

Bonus

Mauvaise pratique Conséquence
Tout mettre dans un seul contrôleur Code illisible
Injecter les repos directement dans le contrôleur Couplage fort
Oublier la validation (@Valid) Données incohérentes

Ckeck-list pour votre projet Spring Boot

Domaine Bonne pratique Vérifié
Injection Par constructeur, final
Architecture 3 couches (Controller / Service / Repository)
DTO Séparer DTO et Entité
Validation @Valid, @NotBlank, @Email
Gestion erreurs @ControllerAdvice global
Logs @Slf4j, pas de System.out
Config application.yml externalisé
Profils application-dev.yml, application-prod.yml
Sécurité Spring Security minimal
Monitoring Spring Actuator actif

et là, votre application sera propre !