Aller au contenu

Injection de dépendances

TodoController reçoit un TodoService sans que tu aies écrit le moindre new. Ce chapitre ouvre le conteneur qui fait ce travail : ce qu'il crée, comment il choisit, et ce qu'il glisse entre tes objets.

Dans ce chapitre

  • L'ApplicationContext, les beans du projet et leur graphe
  • Le câblage que Spring écrit à ta place (comparé à State en Axum et Depends en FastAPI)
  • L'injection par constructeur, et pourquoi éviter l'injection par champ
  • @Configuration et @Bean pour les objets que tu ne peux pas annoter
  • Plusieurs implémentations d'une interface : @Primary, @Qualifier, List<...>
  • Portées, cycle de vie, proxies et dépendances circulaires

Le conteneur et ses beans

Dans Annotations et réflexion, tu as écrit un mini conteneur : une table « type → instance » remplie en appelant les constructeurs par réflexion. Le vrai s'appelle l'ApplicationContext ; c'est lui que SpringApplication.run crée et renvoie.

Un bean est un objet créé par ce conteneur, qui gère ensuite sa vie. Il a un type et un nom : celui de la classe avec une minuscule (todoService), ou celui de la méthode @Bean qui le fabrique. Tout objet n'est pas un bean : une entité Todo ou un TodoResponse sont des données, créées à chaque requête par ton code, Hibernate ou Jackson. Les beans sont les objets « de service », créés une fois au démarrage.

Au démarrage de todo-api, le contexte contient 374 beans. Sept viennent de ton code (la classe principale en fait partie), tous les autres de l'auto-configuration. Ceux qui servent à TodoController (flèches pleines : injection ; pointillés : utilisation) :

flowchart LR
    subgraph toi [Tes beans]
        ctrl["todoController<br/>TodoController"]
        svc["todoService<br/>TodoService (proxy)"]
        props["TodoProperties"]
        repo["todoRepository<br/>généré par Spring Data"]
    end
    subgraph boot [Auto-configuration]
        tx["transactionManager<br/>JpaTransactionManager"]
        emf["entityManagerFactory<br/>Hibernate"]
        ds["dataSource<br/>HikariDataSource"]
        servlet["dispatcherServlet"]
    end
    ctrl --> svc
    svc --> repo
    svc --> props
    svc -.->|transactions| tx
    repo --> emf
    tx --> emf
    emf --> ds
    servlet -.->|routes| ctrl

Le câblage que Spring écrit à ta place

Voici, écrit à la main, ce que le conteneur fait au démarrage pour le fil rouge, dans l'ordre des dépendances (fragment volontairement incomplet) :

TodoProperties properties = new TodoProperties(50);        // lu dans application.properties
TodoRepository repository = /* classe générée par Spring Data JPA */;
TodoService service = new TodoService(repository, properties);   // puis enveloppé dans un proxy
TodoController controller = new TodoController(service);
// ... enregistrer les routes de controller, démarrer Tomcat

Avec Axum ou FastAPI, ce câblage, c'est toi qui l'écris (extraits) :

// Dans main : construit une fois, partagé entre les requêtes par Arc
let repository = TodoRepository::new(pool);
let service = Arc::new(TodoService::new(repository, TodoProperties { max_open: 50 }));
let app = Router::new().route("/api/todos", get(list)).with_state(service);
// ...
async fn list(State(service): State<Arc<TodoService>>) -> Json<Vec<TodoResponse>> {
    Json(service.list().await)
}
def get_repository() -> TodoRepository:
    return TodoRepository()

def get_service(repository: TodoRepository = Depends(get_repository)) -> TodoService:
    return TodoService(repository, max_open=50)   # câblage écrit à la main

@app.get("/api/todos")
def list_todos(service: TodoService = Depends(get_service)) -> list[str]:
    return service.list()

Trois différences avec Spring :

  • Spring déduit le câblage des types des paramètres de constructeur : ni with_state, ni get_service.
  • Il construit tout une seule fois, au démarrage. FastAPI rappelle get_service à chaque requête : un print dans le constructeur de TodoService s'affiche à chaque appel.
  • Une dépendance manquante fait échouer le démarrage : pas la compilation comme en Rust, mais avant toute requête. Retire @Service de TodoService : l'appli s'arrête sur APPLICATION FAILED TO START avec
Parameter 0 of constructor in xyz.dunkan.todo.todo.TodoController required a bean of type 'xyz.dunkan.todo.todo.TodoService' that could not be found.

Le scan des composants

@ComponentScan, inclus dans @SpringBootApplication (voir Premier projet), enregistre les classes de xyz.dunkan.todo et de ses sous-paquets qui portent un de ces stéréotypes :

Annotation Couche Particularité
@Component générique la base : les autres sont annotées @Component
@Service métier aucune, elle documente le rôle
@Repository persistance traduit les exceptions SQL en DataAccessException
@RestController web méthodes reliées à des routes (API REST)
@Configuration configuration déclare des beans avec @Bean (plus bas)

TodoRepository ne porte aucune de ces annotations : c'est une interface, que Spring Data JPA repère parce qu'elle étend JpaRepository et dont il génère l'implémentation. Les entités JPA et les records @ConfigurationProperties sont cherchés de la même façon. Toutes ces recherches partent du paquet de la classe @SpringBootApplication : voilà pourquoi TodoApiApplication est à la racine, au-dessus de hello et de todo.

L'injection par constructeur

src/main/java/xyz/dunkan/todo/todo/TodoService.java (extrait)
@Service
@Transactional
public class TodoService {

    private final TodoRepository repository; // (1)!
    private final TodoProperties properties;

    public TodoService(TodoRepository repository, TodoProperties properties) { // (2)!
        this.repository = repository;
        this.properties = properties;
    }
  1. final : affecté une fois dans le constructeur, jamais réassigné. Le bean est complet dès sa création, et final garantit aussi que tous les threads voient ces valeurs.
  2. Un seul constructeur : Spring l'utilise, sans @Autowired. S'il y en a plusieurs, marque le bon avec @Autowired ; sinon Spring cherche un constructeur sans argument et échoue : Failed to instantiate [xyz.dunkan.todo.todo.TodoService]: No default constructor found.

Le grand avantage : TodoService reste une classe Java ordinaire. TodoServiceTest la construit sans Spring, avec un faux repository : service = new TodoService(repository, new TodoProperties(2));. Ajoute une dépendance au constructeur, et le compilateur te montre chaque endroit à mettre à jour : constructor TodoService in class xyz.dunkan.todo.todo.TodoService cannot be applied to given types.

L'injection par champ : à éviter

Tu la croiseras dans beaucoup de tutoriels (et dans les classes de test, où elle est acceptable : c'est JUnit qui les instancie, pas Spring) :

@Service
public class TodoService {
    @Autowired private TodoRepository repository;   // rempli par réflexion, après le constructeur
    @Autowired private TodoProperties properties;
}

Spring l'accepte, mais :

  • les champs ne peuvent pas être final, et l'objet existe à moitié construit entre le new et l'injection ;
  • les dépendances sont cachées : la signature ne dit pas ce dont la classe a besoin ;
  • hors de Spring, l'objet est inutilisable : new TodoService().create(...) dans un test lève NullPointerException: Cannot invoke "xyz.dunkan.todo.todo.TodoRepository.countByDoneFalse()" because "this.repository" is null ;
  • dix champs @Autowired passent inaperçus, un constructeur à dix paramètres saute aux yeux (signe qu'il faut découper la classe).

@Configuration et @Bean

Le scan ne voit que tes classes. Pour faire un bean d'un java.time.Clock (classe du JDK) ou d'un client d'une bibliothèque, que tu ne peux pas annoter, écris une méthode @Bean dans une classe @Configuration :

src/main/java/xyz/dunkan/todo/ClockConfig.java (sans les import)
@Configuration
public class ClockConfig {
    @Bean // (1)!
    public Clock clock() {
        return Clock.systemUTC();
    }
}
  1. Spring appelle cette fabrique une fois au démarrage (comme une fonction fn clock() -> Clock qu'appellerait ton main) et enregistre le résultat sous le nom clock. Tout constructeur qui demande un Clock le reçoit ; les paramètres d'une méthode @Bean sont eux-mêmes injectés. Pourquoi un Clock plutôt qu'Instant.now() ? Pour le remplacer par une horloge figée dans les tests (exercice 1).

Sous le capot : une classe @Configuration est un proxy

Si une méthode @Bean en appelle une autre de la même classe (return new Truc(clock());), elle reçoit le même singleton, pas un nouvel objet : Spring remplace ta classe par une sous-classe générée (ClockConfig$$SpringCGLIB$$0) qui intercepte ces appels. Même mécanisme que pour @Transactional, plus bas.

Plusieurs implémentations d'une interface

Imagine une interface TodoNotifier et deux implémentations @Component, LogNotifier et WebhookNotifier. Si TodoService demande un TodoNotifier, Spring refuse de choisir :

Parameter 2 of constructor in xyz.dunkan.todo.todo.TodoService required a single bean, but 2 were found:
    - logNotifier: defined in URL [.../LogNotifier.class]
    - webhookNotifier: defined in URL [.../WebhookNotifier.class]
...
Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed

Les trois solutions proposées :

@Component
@Primary // (1)!
public class WebhookNotifier implements TodoNotifier { ... }

public TodoService(TodoRepository repository, TodoProperties properties,
                   @Qualifier("logNotifier") TodoNotifier notifier) { ... } // (2)!

public TodoService(TodoRepository repository, TodoProperties properties,
                   List<TodoNotifier> notifiers) { ... } // (3)!
  1. Le choix par défaut quand plusieurs beans conviennent : ici WebhookNotifier.
  2. Désigne un bean par son nom ; @Qualifier l'emporte sur @Primary.
  3. Reçoit tous les beans du type : [LogNotifier, WebhookNotifier]. Annote les classes avec @Order(1), @Order(2) pour fixer l'ordre.

En Rust, tu choisirais l'implémentation toi-même dans main (un Arc<dyn TodoNotifier>) ; Spring choisit par type, d'où l'ambiguïté. En dernier recours, il compare aussi les noms des beans aux noms des paramètres : un paramètre nommé logNotifier suffit. Fragile (un renommage change le bean injecté) : préfère @Qualifier.

Un singleton partagé entre threads

Par défaut, un bean est un singleton : context.getBean(TodoService.class) == context.getBean(TodoService.class) vaut true. D'autres portées existent, rarement utiles : prototype (@Scope("prototype"), une instance neuve à chaque injection ou appel à getBean), request (@RequestScope, une par requête HTTP) et session (@SessionScope). Piège du prototype : injecté dans un singleton, il n'est créé qu'une fois, au moment de l'injection.

Piège : un champ mutable dans un bean

Le singleton TodoService est appelé en même temps par tous les threads de Tomcat, un par requête. Un champ modifiable est donc partagé par toutes les requêtes en cours :

@Service
public class TodoService {
    private String lastTitle;   // BUG : écrasé par la requête d'à côté

La démonstration et les règles sont dans Threads et concurrence : les champs d'un bean servent aux dépendances (final) et à la configuration, l'état d'une requête reste dans des variables locales, un compteur partagé passe par un AtomicLong (exercice 3). En Rust, un Arc<T> partagé entre threads impose que T soit Sync ; ici, personne ne vérifie pour toi.

Le cycle de vie

Pour chaque singleton, dans l'ordre des dépendances : le constructeur (avec l'injection), puis la méthode annotée @PostConstruct. À l'arrêt (Ctrl+C, ou SIGTERM envoyé par Docker), les méthodes @PreDestroy, dans l'ordre inverse.

TodoService.java (ajout)
@PostConstruct   // jakarta.annotation.PostConstruct
void init() { log.info("TodoService prêt : au plus {} tâches ouvertes", properties.maxOpen()); }

@PreDestroy      // jakarta.annotation.PreDestroy
void close() { log.info("TodoService arrêté"); }
INFO xyz.dunkan.todo.todo.TodoService   : TodoService prêt : au plus 50 tâches ouvertes
...
INFO xyz.dunkan.todo.todo.TodoService   : TodoService arrêté
INFO j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
INFO com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...

@PreDestroy joue le rôle du Drop de Rust, pour un objet qui vit aussi longtemps que l'appli. Le service s'arrête avant Hibernate et le pool de connexions, dont il dépend. Pour un bean déclaré avec @Bean, Spring appelle aussi tout seul sa méthode publique close() ou shutdown() : c'est ainsi que le pool HikariCP est fermé.

Les proxies

Ce que Spring injecte n'est pas toujours ton objet. Les classes réelles de trois beans du projet (affichées par l'exercice 2) :

todoController -> xyz.dunkan.todo.todo.TodoController
todoService -> xyz.dunkan.todo.todo.TodoService$$SpringCGLIB$$0
todoRepository -> jdk.proxy2.$Proxy149

TodoService porte @Transactional : Spring a généré une sous-classe (bibliothèque CGLIB) qui ouvre la transaction, appelle ta méthode, puis valide ou annule ; c'est elle que TodoController reçoit. TodoRepository est une interface : son implémentation est un proxy dynamique du JDK (java.lang.reflect.Proxy). TodoController, lui, est ton objet tel quel.

Le mécanisme est démonté dans Annotations et réflexion, avec son piège le plus célèbre : un appel interne (this.methode()) ne passe pas par le proxy. Vérification avec un service sans @Transactional sur la classe :

@Service
public class TodoImporter {
    // ... champ repository et constructeur
    public void importAll(List<String> titles) {
        for (String title : titles) {
            importOne(title);   // this.importOne(...) : pas de proxy
        }
    }
    @Transactional
    public void importOne(String title) {
        System.out.println("importOne(" + title + ") transaction ? "
                + TransactionSynchronizationManager.isActualTransactionActive());
        repository.save(new Todo(title));
    }
}
// importer.importAll(List.of("A", "B")) affiche : importOne(A) transaction ? false
//                                                   importOne(B) transaction ? false
// importer.importOne("C") affiche :                importOne(C) transaction ? true

Pour la même raison, une méthode private ou final n'est jamais interceptée : la sous-classe ne peut pas la redéfinir.

Les dépendances circulaires

Si TodoService demande un TodoStatsService dont le constructeur demande un TodoService, aucun des deux ne peut être construit en premier. Spring s'arrête au démarrage :

The dependencies of some of the beans in the application context form a cycle:

   todoController defined in URL [.../TodoController.class]
┌─────┐
|  todoService defined in URL [.../TodoService.class]
↑     ↓
|  todoStatsService defined in URL [.../TodoStatsService.class]
└─────┘

Le message suggère spring.main.allow-circular-references=true, mais avec l'injection par constructeur, cela ne change rien (le même rapport s'affiche) : la propriété ne marche qu'avec l'injection par champ, en injectant un objet pas encore initialisé. Le vrai remède est la conception : ici, TodoStatsService peut dépendre de TodoRepository plutôt que de TodoService.

Exercices

1. Une horloge injectée. Ajoute ClockConfig (plus haut) au projet, injecte le Clock dans TodoService et sers-t'en pour dater les nouvelles tâches. Dans TodoServiceTest, passe une horloge figée (Clock.fixed(...)) et vérifie la valeur exacte de createdAt.

Solution

Ajoute à Todo un constructeur Todo(String title, Instant createdAt) (qui tronque createdAt à la microseconde) ; l'ancien Todo(String title), utilisé par TodoRepositoryTest, l'appelle avec Instant.now(). Puis :

TodoService.java (extrait)
private final Clock clock;   // + un paramètre Clock clock dans le constructeur

// dans create :
Todo saved = repository.save(new Todo(request.title(), Instant.now(clock)));
TodoServiceTest.java (extrait)
@BeforeEach
void setUp() {
    Clock clock = Clock.fixed(Instant.parse("2026-01-15T10:00:00Z"), ZoneOffset.UTC);
    service = new TodoService(repository, new TodoProperties(2), clock);
}

// dans createSavesNewTodo :
assertThat(response.createdAt()).isEqualTo("2026-01-15T10:00:00Z");

Rien à changer dans TodoController : Spring trouve le Clock tout seul. ./mvnw verify passe (20 tests). C'est l'idée d'un trait Clock passé à ton service en Rust pour tester le temps.

2. Inventaire. Ajoute à TodoApiApplication un bean CommandLineRunner (une interface dont Spring appelle la méthode run une fois l'appli démarrée) qui affiche le nombre total de beans, puis le nom et la classe réelle des beans dont le type est dans xyz.dunkan.

Solution
TodoApiApplication.java (ajout)
@Bean
public CommandLineRunner listBeans(ApplicationContext context) {
    return args -> {
        System.out.println(context.getBeanDefinitionCount() + " beans au total, dont :");
        for (String name : context.getBeanDefinitionNames()) {
            Class<?> type = context.getType(name);   // TodoRepository pour le repository
            if (type != null && type.getName().startsWith("xyz.dunkan")) {
                Object bean = context.getBean(name);
                System.out.println("  " + name + " -> " + bean.getClass().getName());
            }
        }
    };
}
375 beans au total, dont :
  todoApiApplication -> xyz.dunkan.todo.TodoApiApplication$$SpringCGLIB$$0
  helloController -> xyz.dunkan.todo.hello.HelloController
  apiExceptionHandler -> xyz.dunkan.todo.todo.ApiExceptionHandler
  todoController -> xyz.dunkan.todo.todo.TodoController
  todoService -> xyz.dunkan.todo.todo.TodoService$$SpringCGLIB$$0
  listBeans -> xyz.dunkan.todo.TodoApiApplication$$Lambda/0x0000000023ab2448
  todo-xyz.dunkan.todo.todo.TodoProperties -> xyz.dunkan.todo.todo.TodoProperties
  todoRepository -> jdk.proxy2.$Proxy149

Le bean listBeans porte le nom de sa méthode, et sa classe est celle de la lambda. TodoApiApplication est devenue un proxy : elle déclare maintenant un @Bean, comme une @Configuration.

3. Plusieurs écouteurs. Crée une interface TodoListener (void onCreated(TodoResponse todo)) et deux implémentations : l'une écrit un log, l'autre compte les créations depuis le démarrage. TodoService doit appeler les deux, dans cet ordre, après chaque création.

Solution
CountingTodoListener.java
@Component
@Order(2)
public class CountingTodoListener implements TodoListener {
    private static final Logger log = LoggerFactory.getLogger(CountingTodoListener.class);

    // Singleton appelé par plusieurs threads de Tomcat : AtomicLong, pas un long
    private final AtomicLong created = new AtomicLong();
    @Override
    public void onCreated(TodoResponse todo) {
        log.info("{} tâche(s) créée(s) depuis le démarrage", created.incrementAndGet());
    }
}

LoggingTodoListener a la même forme, avec @Order(1) et log.info("Tâche créée : {} (id {})", todo.title(), todo.id()). TodoService reçoit un List<TodoListener> listeners dans son constructeur, et create se termine ainsi :

TodoResponse response = TodoResponse.from(saved);
listeners.forEach(listener -> listener.onCreated(response));
return response;

Dans TodoServiceTest : new TodoService(repository, new TodoProperties(2), List.of()). Deux POST donnent :

[nio-8080-exec-1] x.dunkan.todo.todo.LoggingTodoListener   : Tâche créée : Acheter du pain (id 1)
[nio-8080-exec-1] x.dunkan.todo.todo.CountingTodoListener  : 1 tâche(s) créée(s) depuis le démarrage
[nio-8080-exec-2] x.dunkan.todo.todo.LoggingTodoListener   : Tâche créée : Lire la doc (id 2)
[nio-8080-exec-2] x.dunkan.todo.todo.CountingTodoListener  : 2 tâche(s) créée(s) depuis le démarrage

Deux threads (exec-1, exec-2) pour le même compteur : d'où l'AtomicLong. Un troisième écouteur ne demandera plus de toucher à TodoService.

À retenir

  • L'ApplicationContext crée les beans au démarrage, dans l'ordre de leurs dépendances ; une dépendance manquante, ambiguë ou circulaire fait échouer le démarrage.
  • Le scan trouve les classes @Component, @Service, @RestController... sous le paquet de la classe principale ; @Bean dans une @Configuration sert pour les classes que tu ne peux pas annoter.
  • Injection par constructeur, champs final, pas d'@Autowired avec un seul constructeur : la classe reste testable avec un simple new.
  • Plusieurs candidats pour un type : @Primary, @Qualifier, ou une List<Interface> pour les recevoir tous.
  • Un bean est un singleton partagé entre les threads : pas d'état mutable dans ses champs.
  • @PostConstruct et @PreDestroy encadrent la vie d'un bean ; la destruction se fait dans l'ordre inverse.
  • Les beans @Transactional sont des proxies : un auto-appel les contourne.