Spring Framework
Inversion de contrôle et injection de dépendances dans Spring
Débutant9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Le travail fondamental de Spring consiste à construire votre graphe d'objets à votre place. À l'issue de ce guide, vous saurez ce qu'est le conteneur IoC, comment l'ApplicationContext découvre et câble les beans, pourquoi l'injection par constructeur est le style recommandé, comment lever une ambiguïté avec des qualifiers, et à quoi correspond, en configuration Java moderne, la configuration XML des projets plus anciens.
Ce que vous allez apprendre
- Expliquer ce que signifie l'inversion de contrôle et pourquoi un conteneur justifie cette indirection
- Déclarer des beans avec @Configuration, @Bean et le scan de composants
- Choisir entre injection par constructeur, par setter et par champ, et justifier ce choix
- Lever les dépendances ambiguës avec @Qualifier, @Primary et l'injection de collections
- Reconnaître une configuration de l'ère XML et la traduire en configuration Java
Toute application non triviale est un graphe d'objets : un contrôleur web a besoin d'un service, le service d'un repository, le repository d'une source de données, et la source de données d'identifiants. Il faut bien que quelqu'un construise ce graphe, dans le bon ordre, avec les bonnes implémentations pour l'environnement courant. La réponse du Spring Framework est un conteneur qui prend en charge la création des objets et fournit à chacun les collaborateurs qu'il déclare. Cette seule idée — l'inversion de contrôle (IoC), mise en œuvre par l'injection de dépendances (DI) — est le socle sur lequel repose tout le reste du framework, et l'intégralité de Spring Boot. Ce guide porte sur Spring Framework 6.x avec Java 17 ou version ultérieure.
Le problème que résout le conteneur
Prenons un service qui envoie des confirmations de commande. Sans conteneur, il crée lui-même ses collaborateurs :
public class OrderService {
private final OrderRepository repository = new JdbcOrderRepository(DataSources.production());
private final Mailer mailer = new SmtpMailer("smtp.internal", 587);
public void confirm(long orderId) {
Order order = repository.find(orderId);
mailer.send(order.customerEmail(), "Order " + orderId + " confirmed");
}
}
La classe fonctionne, mais elle a pris en silence quatre décisions qui ne la regardent pas : quelle implémentation de repository utiliser, d'où vient la source de données, quel transport de mails employer et comment il est configuré. Impossible de tester unitairement confirm sans base de données ni serveur SMTP. Impossible de remplacer le mailer par une version adossée à une file de messages sans modifier ce fichier. Chaque classe qui suit ce modèle devient une petite factory codée en dur pour ses propres dépendances.
L'inversion de contrôle renverse la flèche. OrderService cesse de rechercher ou de construire ce dont il a besoin et se contente de le déclarer :
public class OrderService {
private final OrderRepository repository;
private final Mailer mailer;
public OrderService(OrderRepository repository, Mailer mailer) {
this.repository = repository;
this.mailer = mailer;
}
public void confirm(long orderId) { /* unchanged */ }
}
La classe dépend désormais de deux rôles exprimés sous forme d'interfaces, et c'est un élément extérieur qui décide quels objets concrets les remplissent. Dans un test, cet élément, c'est vous, qui passez des fakes. En production, c'est le conteneur Spring. La classe elle-même n'importe rien de Spring — c'est voulu, et c'est bon signe.
Ce qu'est le conteneur
Le conteneur est un objet qui lit des définitions de beans — des recettes décrivant quelles classes instancier, comment les configurer et de quoi elles dépendent — et s'en sert pour construire un graphe d'objets gérés, appelés beans. L'interface centrale est ApplicationContext. Lorsque vous en créez un, il :
- Charge les définitions de beans depuis vos classes de configuration (ou du XML, ou des packages scannés).
- Exécute les bean factory post-processors, qui peuvent modifier les définitions (résolution des placeholders de propriétés, par exemple).
- Instancie chaque bean singleton en résolvant récursivement ses dépendances, dans l'ordre imposé par celles-ci.
- Applique les bean post-processors à chaque nouvelle instance (c'est là que
@PostConstruct, les proxies AOP et les intercepteurs de transactions sont greffés). - Publie un événement de rafraîchissement, puis se tient prêt à répondre aux appels
getBean— même si un code bien conçu appelle rarementgetBeandirectement.
Les deux implémentations que vous croiserez le plus souvent sont AnnotationConfigApplicationContext, pour un usage autonome ou en test, et AnnotationConfigWebApplicationContext (ou AnnotationConfigServletWebServerApplicationContext côté Boot) pour les applications web.
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
OrderService service = context.getBean(OrderService.class);
service.confirm(42L);
} // close() destroys singletons and runs @PreDestroy callbacks
Déclarer des beans en configuration Java
Une classe de configuration est une classe annotée @Configuration dont chaque méthode @Bean produit un bean. Le nom de la méthode devient le nom du bean, sauf si vous le redéfinissez.
@Configuration
public class AppConfig {
@Bean
public DataSource dataSource() {
var ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:postgresql://db:5432/shop");
ds.setUsername("shop");
ds.setPassword(System.getenv("DB_PASSWORD"));
return ds;
}
@Bean
public OrderRepository orderRepository(DataSource dataSource) { // parameter is injected
return new JdbcOrderRepository(dataSource);
}
@Bean
public Mailer mailer() {
return new SmtpMailer("smtp.internal", 587);
}
@Bean
public OrderService orderService(OrderRepository repository, Mailer mailer) {
return new OrderService(repository, mailer);
}
}
Les paramètres de méthode sont résolus par type depuis le conteneur : une méthode @Bean n'a donc jamais besoin d'appeler une autre méthode @Bean pour obtenir une dépendance. Elle peut le faire — orderRepository(dataSource()) — et Spring renverra quand même l'unique instance gérée plutôt qu'un nouvel objet, car les classes @Configuration sont sous-classées avec CGLIB à l'exécution pour intercepter ces appels. Cette interception est pratique mais invisible, et c'est pourquoi passer les dépendances en paramètres reste le style le plus lisible.
Les méthodes @Bean brillent pour les classes qui ne vous appartiennent pas : un pool de connexions, un client tiers, une Clock. Pour vos propres classes, le scan de composants demande moins de cérémonie.
Le scan de composants
Annotez vos classes avec une annotation stéréotype et indiquez au conteneur quels packages scanner :
@Service
public class OrderService {
private final OrderRepository repository;
private final Mailer mailer;
public OrderService(OrderRepository repository, Mailer mailer) {
this.repository = repository;
this.mailer = mailer;
}
}
@Repository
public class JdbcOrderRepository implements OrderRepository { /* … */ }
@Configuration
@ComponentScan("com.example.shop")
public class AppConfig { /* only third-party beans left here */ }
@Component est le stéréotype générique ; @Service, @Repository, @Controller et @RestController en sont des spécialisations. Sur le plan fonctionnel, ils sont presque identiques — un @Service scanné n'est qu'un @Component —, mais @Repository active en plus la traduction des exceptions d'accès aux données, et les autres documentent l'intention et permettent de filtrer les scans. Scannez le package le plus restreint qui contient votre code : scanner com ou une chaîne vide embarque toutes les bibliothèques du classpath qui utilisent des stéréotypes Spring, et ralentit le démarrage.
Les trois styles d'injection
Spring peut injecter via un constructeur, un setter ou directement dans un champ. Ces trois styles ne sont pas interchangeables.
| Style | Comment | Avantages | Inconvénients |
|---|---|---|---|
| Constructeur | Les dépendances sont des paramètres du constructeur | Les champs peuvent être final ; l'objet n'est jamais à moitié construit ; fonctionne sans Spring dans les tests ; un excès de paramètres est un défaut de conception visible | Un peu plus de code à écrire (rien du tout avec les records ou Lombok) |
| Setter | @Autowired sur un setter | Dépendances optionnelles ; reconfigurable après la construction | L'objet peut exister dans un état invalide ; mutabilité |
| Champ | @Autowired sur un champ privé | Le plus court | Ne peut pas être final ; invisible pour les appelants ; les tests exigent la réflexion ou le conteneur ; masque des listes de dépendances qui s'allongent |
L'équipe Spring recommande l'injection par constructeur pour les dépendances obligatoires et l'injection par setter pour les dépendances optionnelles. L'injection par champ apparaît dans d'innombrables tutoriels parce qu'elle est concise, mais elle lie la classe à un conteneur fondé sur la réflexion et rend impossible un simple new dans un test unitaire. Avec un constructeur unique, @Autowired est superflu : le conteneur l'utilise implicitement.
Les dépendances optionnelles s'expriment de plusieurs façons. Choisissez-en une et tenez-vous-y :
@Service
public class ReportService {
private final ReportStore store;
private final Optional<AuditSink> audit; // empty if no AuditSink bean
private final ObjectProvider<Formatter> formatters; // lazy, may be many, may be none
public ReportService(ReportStore store, Optional<AuditSink> audit, ObjectProvider<Formatter> formatters) {
this.store = store;
this.audit = audit;
this.formatters = formatters;
}
void run() {
audit.ifPresent(a -> a.record("report"));
formatters.orderedStream().forEach(Formatter::apply);
}
}
ObjectProvider résout aussi un problème classique : lorsqu'un singleton a besoin d'un nouveau bean de scope prototype à chaque appel, provider.getObject() interroge le conteneur à chaque fois au lieu de conserver une instance unique devenue obsolète.
Lever les ambiguïtés
L'injection se fait par type. Lorsque deux beans implémentent Mailer, le conteneur ne peut pas choisir et échoue au démarrage avec une NoUniqueBeanDefinitionException. Vous disposez de quatre outils :
@Primarysur l'un des candidats en fait le choix par défaut lorsque rien de plus précis n'est demandé.@Qualifier("smtp")au point d'injection désigne le bean voulu (par son nom, ou par une annotation de qualifier personnalisée que vous définissez vous-même pour la sûreté de typage).- Repli sur le nom du paramètre : si un paramètre s'appelle
smtpMaileret qu'un bean porte ce nom, c'est lui qui l'emporte — un mécanisme fragile, car un renommage casse le câblage sans bruit. - Injection de collections : déclarez
List<Mailer>ouMap<String, Mailer>pour recevoir tous les candidats, triés selon@OrderouOrdered.
@Bean @Primary Mailer smtpMailer() { … }
@Bean Mailer queueMailer() { … }
public NotificationService(@Qualifier("queueMailer") Mailer mailer) { … }
Spring Framework 6.2 a introduit @Fallback, le pendant de @Primary : un bean retenu uniquement lorsqu'aucun autre candidat n'existe — pratique pour les valeurs par défaut d'une bibliothèque que les applications peuvent redéfinir.
Les types génériques participent aussi à la résolution : Repository<Order> et Repository<Customer> sont des cibles d'injection distinctes ; vous n'avez donc souvent besoin d'aucun qualifier si vos interfaces portent des paramètres de type.
Du XML à la configuration Java
Les bases de code anciennes, et la plupart des supports de formation Spring 3/4, définissent les beans en XML :
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:context="http://www.springframework.org/schema/context">
<context:component-scan base-package="com.example.shop"/>
<bean id="mailer" class="com.example.shop.SmtpMailer">
<constructor-arg value="smtp.internal"/>
<constructor-arg value="587"/>
</bean>
<bean id="orderService" class="com.example.shop.OrderService">
<constructor-arg ref="orderRepository"/>
<constructor-arg ref="mailer"/>
<property name="retryCount" value="3"/> <!-- setter injection -->
</bean>
</beans>
La correspondance est mécanique : <bean> devient une méthode @Bean, <constructor-arg ref> un paramètre de méthode, <property> un appel de setter, <context:component-scan> devient @ComponentScan, et <import resource> un @Import d'une autre classe de configuration. Le XML se charge toujours avec ClassPathXmlApplicationContext et peut être intégré via @ImportResource("classpath:legacy-beans.xml"), ce qui constitue la voie raisonnable pour une migration progressive. L'ère XML s'appuyait aussi sur les annotations @Resource de la JSR-250 et @Inject de la JSR-330 ; toutes deux fonctionnent encore, désormais depuis les packages jakarta.annotation et jakarta.inject, mais @Autowired reste le choix idiomatique.
Erreurs courantes
Appeler getBean depuis le code applicatif. Faire circuler l'ApplicationContext et rechercher des beans par leur nom réintroduit le couplage du service locator que la DI devait justement supprimer. Injectez ce dont vous avez besoin ; si l'ensemble des beans est dynamique, injectez un ObjectProvider<T> ou une Map<String, T>.
Faire un new sur une classe qui porte des annotations Spring. Un objet que vous construisez vous-même n'est pas géré : pas d'injection, pas de @Transactional, pas de @PostConstruct. Le symptôme typique est une NullPointerException sur un champ qui « aurait dû être injecté ».
Scanner trop large. Un @ComponentScan sans argument scanne le package de la classe de configuration et tous ses sous-packages. Placez votre configuration racine au sommet de la hiérarchie de packages de votre code, jamais au-dessus.
Mélanger injection par champ et constructeurs. Une classe qui reçoit certaines dépendances par constructeur et d'autres par des champs @Autowired est déroutante et impossible à tester. Faites tout passer par le constructeur.
Dépendances circulaires par constructeur. A(B) et B(A) échouent au démarrage. Ne passez pas à l'injection par champ pour faire taire l'erreur ; isolez le comportement commun dans un troisième bean, ou utilisez ObjectProvider<A> pour les rares cas légitimes de liaison tardive.
Deux beans, aucun qualifier. Ajouter une seconde implémentation d'une interface — par exemple un CachingProductRepository qui enveloppe celui en JDBC — casse tous les points d'injection existants de ce type. Désignez l'implémentation par défaut voulue avec @Primary au moment où vous ajoutez le second bean.
Bonnes pratiques
- Gardez autant que possible vos classes de domaine et de service exemptes d'imports Spring. Un constructeur et une interface suffisent ; l'annotation stéréotype est la seule chose spécifique à Spring dont elles ont besoin, et même elle peut migrer vers une méthode
@Bean. - Préférez un seul constructeur par classe, avec des champs
final. Si un constructeur dépasse cinq ou six paramètres, la classe en fait trop. - Utilisez des méthodes
@Beanpour les types tiers et le scan de composants pour les vôtres. - Nommez les beans délibérément dès qu'un type risque d'en avoir plusieurs, et, dans les grandes bases de code, préférez des annotations de qualifier personnalisées aux noms sous forme de chaînes.
- Laissez le démarrage échouer vite. Un bean manquant découvert au boot coûte bien moins cher qu'un bean découvert à la première requête en production : évitez donc
@Autowired(required = false)sauf si la dépendance est réellement optionnelle.
Points clés à retenir
- L'inversion de contrôle signifie que les objets déclarent leurs dépendances et que le conteneur les leur fournit ; l'injection de dépendances en est le mécanisme.
ApplicationContextlit les définitions de beans depuis les classes@Configurationet les composants scannés, construit le graphe des singletons au démarrage et applique les post-processors à chaque bean.- L'injection par constructeur est le style recommandé : immuable, testable sans Spring et honnête sur les dépendances d'une classe.
- L'injection se fait par type ; utilisez
@Primary,@Qualifierou l'injection de collections lorsque plusieurs beans partagent un même type. - La configuration XML fonctionne toujours et se transpose point par point en configuration Java, que tout nouveau code devrait adopter.
- Ne faites jamais de
newsur une classe gérée par Spring et n'appelez jamaisgetBeandans le code applicatif : les deux contournent le conteneur.
Sources et lectures complémentaires
Questions fréquentes
- Quelle est la différence entre BeanFactory et ApplicationContext ?
- BeanFactory est l'interface minimale du conteneur : elle sait créer et câbler des beans à la demande. ApplicationContext l'étend avec tout ce dont une application a besoin — création anticipée des singletons, publication d'événements, sources de messages pour l'i18n, chargement de ressources et enregistrement automatique des post-processors. Le code applicatif devrait toujours utiliser un ApplicationContext ; BeanFactory existe pour les contraintes d'empreinte extrêmes et pour les mécanismes internes du framework.
- Faut-il encore mettre @Autowired sur un constructeur ?
- Pas lorsque la classe possède exactement un constructeur. Depuis Spring Framework 4.3, le conteneur utilise implicitement un constructeur unique. @Autowired n'est nécessaire que pour désigner un constructeur parmi plusieurs, ou sur les points d'injection par setter et par champ.
- Pourquoi Spring signale-t-il qu'un bean a une dépendance circulaire ?
- Le bean A a besoin de B dans son constructeur et B a besoin de A dans le sien : aucun des deux ne peut être créé en premier, et le conteneur lève une BeanCurrentlyInCreationException. L'injection par setter ou par champ peut techniquement briser le cycle, car Spring injecte alors dans un singleton à moitié construit, mais le cycle signale presque toujours des responsabilités emmêlées. Extrayez plutôt la partie commune dans une troisième classe.
- La configuration XML est-elle toujours prise en charge dans Spring Framework 6 ?
- Oui. Les définitions de beans en XML, les namespaces p et c et context:component-scan fonctionnent toujours et restent entièrement documentés. Le nouveau code devrait utiliser la configuration Java, car elle est vérifiée par le compilateur, résiste au refactoring et c'est ce que supposent Spring Boot et la documentation de référence ; pendant une migration, un fichier XML peut toutefois être importé dans une configuration Java avec @ImportResource.