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é à
Stateen Axum etDependsen FastAPI) - L'injection par constructeur, et pourquoi éviter l'injection par champ
@Configurationet@Beanpour 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, niget_service. - Il construit tout une seule fois, au démarrage. FastAPI rappelle
get_serviceà chaque requête : unprintdans le constructeur deTodoServices'affiche à chaque appel. - Une dépendance manquante fait échouer le démarrage : pas la compilation comme en Rust, mais avant toute requête. Retire
@ServicedeTodoService: l'appli s'arrête surAPPLICATION FAILED TO STARTavec
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¶
@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;
}
final: affecté une fois dans le constructeur, jamais réassigné. Le bean est complet dès sa création, etfinalgarantit aussi que tous les threads voient ces valeurs.- 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 lenewet 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èveNullPointerException: Cannot invoke "xyz.dunkan.todo.todo.TodoRepository.countByDoneFalse()" because "this.repository" is null; - dix champs
@Autowiredpassent 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 :
@Configuration
public class ClockConfig {
@Bean // (1)!
public Clock clock() {
return Clock.systemUTC();
}
}
- Spring appelle cette fabrique une fois au démarrage (comme une fonction
fn clock() -> Clockqu'appellerait tonmain) et enregistre le résultat sous le nomclock. Tout constructeur qui demande unClockle reçoit ; les paramètres d'une méthode@Beansont eux-mêmes injectés. Pourquoi unClockplutô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)!
- Le choix par défaut quand plusieurs beans conviennent : ici
WebhookNotifier. - Désigne un bean par son nom ;
@Qualifierl'emporte sur@Primary. - 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.
@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 :
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)));
@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
@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
@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'
ApplicationContextcré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 ;@Beandans une@Configurationsert pour les classes que tu ne peux pas annoter. - Injection par constructeur, champs
final, pas d'@Autowiredavec un seul constructeur : la classe reste testable avec un simplenew. - Plusieurs candidats pour un type :
@Primary,@Qualifier, ou uneList<Interface>pour les recevoir tous. - Un bean est un singleton partagé entre les threads : pas d'état mutable dans ses champs.
@PostConstructet@PreDestroyencadrent la vie d'un bean ; la destruction se fait dans l'ordre inverse.- Les beans
@Transactionalsont des proxies : un auto-appel les contourne.