Core Concepts: IoC, DI, Beans, and Auto-Configuration
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
Every Spring Boot application is, underneath the "opinionated defaults", an ordinary Spring IoC container
managing a graph of beans. This page covers how beans are declared, injected, scoped, and hooked into their
lifecycle; a short detour into AOP explains the proxy mechanism that later pages' @Transactional and
@Cacheable annotations rely on; and a final section demystifies auto-configuration itself.
The IoC container and beans
The Inversion of Control (IoC) container (an ApplicationContext) creates objects, wires their dependencies,
and manages their lifecycle, instead of each object constructing its own collaborators with new. An object
managed this way is a bean.
The simplest way to declare a bean is to annotate a class with a stereotype annotation so component scanning picks it up:
@Service
public class OrderPricingService {
private final TaxCalculator taxCalculator;
// constructor injection: the recommended default
public OrderPricingService(TaxCalculator taxCalculator) {
this.taxCalculator = taxCalculator;
}
public BigDecimal priceWithTax(BigDecimal net) {
return net.add(taxCalculator.taxFor(net));
}
}
@Repository
public class JdbcOrderRepository implements OrderRepository {
// data-access bean; @Repository also enables Spring's
// persistence-exception translation into DataAccessException
}
@Component
public class RequestIdGenerator {
// generic bean stereotype for anything that isn't a
// web/service/persistence-layer concern
}
@Component, @Service, and @Repository are functionally interchangeable as far as component scanning is
concerned — all three are meta-annotated with @Component — but they document intent: @Service marks
business/orchestration logic, @Repository marks data-access classes and additionally triggers persistence
exception translation, and @Component is the generic fallback. @Controller/@RestController (covered on the
REST APIs page) are the web-layer equivalent. See
Core Annotations for exactly what each stereotype adds — in
particular, @Repository’s automatic translation of native persistence exceptions into
`DataAccessException — plus a categorized reference of every other core annotation used throughout this
section.
Dependency injection: constructor vs. field vs. setter
Spring supports three injection styles. Constructor injection is the recommended default: it makes
dependencies explicit and immutable (final fields), fails fast at startup if a dependency is missing, and
lets the class be instantiated in a plain unit test without touching the container:
// preferred: constructor injection
@Service
public class InvoiceService {
private final OrderRepository orderRepository;
private final OrderPricingService pricingService;
// a single constructor needs no @Autowired -- Spring infers it
public InvoiceService(OrderRepository orderRepository, OrderPricingService pricingService) {
this.orderRepository = orderRepository;
this.pricingService = pricingService;
}
}
// field injection: works, but hides dependencies and cannot be made final
@Service
public class LegacyStyleService {
@Autowired
private OrderRepository orderRepository;
}
// setter injection: useful for optional dependencies that may be
// reconfigured after construction
@Service
public class NotifierService {
private EmailSender emailSender;
@Autowired(required = false)
public void setEmailSender(EmailSender emailSender) {
this.emailSender = emailSender;
}
}
Field injection is discouraged because it allows objects to exist in a partially-constructed, untestable state
and hides a growing dependency list that would otherwise make a long constructor signal a class doing too much.
Setter injection remains appropriate for genuinely optional collaborators. See
the Spring Framework IoC container reference
for the full dependency-injection model, including @Qualifier, @Primary, and collection/Optional
injection.
Component scanning, Java configuration, and bean scopes
Component scanning discovers stereotype-annotated classes on the classpath under the application’s base
package (Spring Boot’s @SpringBootApplication enables this implicitly for the package it lives in and all
sub-packages). For beans you don’t own the source of — third-party clients, or objects that need constructor
arguments computed from configuration — declare them explicitly in a @Configuration class instead:
@Configuration
public class ClientConfig {
@Bean
public RestClient paymentGatewayClient(RestClient.Builder builder,
@Value("${payment.gateway.base-url}") String baseUrl) {
return builder.baseUrl(baseUrl).build();
}
@Bean
public Clock systemClock() {
return Clock.systemUTC();
}
}
Each @Bean method’s return value is registered under the container, named after the method by default; other
beans depend on it either by declaring it as a constructor parameter (Spring resolves by type) or, when several
candidates share a type, with @Qualifier("paymentGatewayClient").
Bean scopes
By default every bean is a singleton: one shared instance per container, created once and reused for every injection point. Other scopes exist for cases where that is wrong:
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ReportBuilder {
// a new instance is created every time this bean is injected or looked up
}
@Component
@RequestScope
public class RequestAuditContext {
// one instance per HTTP request; only valid in a web-aware ApplicationContext
}
@Component
@SessionScope
public class ShoppingCart {
// one instance per HTTP session
}
-
singleton (default) — one instance per container; safe only for stateless, thread-shared beans.
-
prototype — a new instance per injection/lookup; the container does not manage its destruction callback.
-
request — one instance per HTTP request (web applications only).
-
session — one instance per HTTP session (web applications only).
Injecting a prototype-scoped bean into a singleton naively captures only the first instance forever; use
ObjectProvider<ReportBuilder> or a scoped proxy (proxyMode = ScopedProxyMode.TARGET_CLASS) when a singleton
needs a fresh prototype/request-scoped instance per call.
Lifecycle callbacks
Beans can hook into the container’s initialization and shutdown phases:
@Component
public class CacheWarmer implements InitializingBean, DisposableBean {
private final Map<String, String> cache = new ConcurrentHashMap<>();
@PostConstruct
void warmUp() {
// runs after dependency injection, before the bean is put into service
cache.put("region", "eu-west-1");
}
@Override
public void afterPropertiesSet() {
// InitializingBean's equivalent hook; runs after @PostConstruct
}
@PreDestroy
void clearCache() {
// runs on graceful container shutdown, before destruction
cache.clear();
}
@Override
public void destroy() {
// DisposableBean's equivalent hook; runs after @PreDestroy
}
}
@PostConstruct/@PreDestroy (annotation-based, no framework coupling) are generally preferred over
implementing InitializingBean/DisposableBean (interface-based, ties the class to Spring’s API) unless a
library needs to avoid a dependency on jakarta.annotation. A @Bean method can also declare
initMethod/destroyMethod for classes whose source you don’t control.
A short note on AOP and proxies
Aspect-Oriented Programming (AOP) lets cross-cutting concerns (logging, auditing, retries) be defined once and applied to many beans without editing each one’s source:
@Aspect
@Component
public class TimingAspect {
private static final Logger log = LoggerFactory.getLogger(TimingAspect.class);
@Around("execution(* com.example.orders..*Service.*(..))")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
try {
return joinPoint.proceed();
} finally {
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
log.info("{} took {} ms", joinPoint.getSignature(), elapsedMs);
}
}
}
@Around wraps the matched method call: joinPoint.proceed() invokes the real method, and code before/after it
runs as the "advice". Spring implements this with runtime proxies: for a bean implementing an interface, a
JDK dynamic proxy is created; for a concrete class, a CGLIB subclass proxy is created instead. The proxy
intercepts calls to the bean and delegates to the interceptor chain (aspects, then transaction/cache advisors)
before reaching the real method.
|
The pointcut expression above, |
Targeting a custom annotation instead of a package/class pattern
A pointcut expression works well for "every class in this package," but @Transactional and @Cacheable
(covered on later pages) don’t sweep up whole packages — they apply advice to specifically annotated methods,
wherever those methods live. The same effect is available for a custom aspect by defining an annotation and
matching on it with @annotation(…) instead of execution(…):
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LogExecutionTime {
}
@Aspect
@Component
public class LogExecutionTimeAspect {
private static final Logger log = LoggerFactory.getLogger(LogExecutionTimeAspect.class);
@Around("@annotation(com.example.orders.LogExecutionTime)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
try {
return joinPoint.proceed();
} finally {
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
log.info("{} took {} ms", joinPoint.getSignature(), elapsedMs);
}
}
}
@Service
public class OrderPricingService {
@LogExecutionTime
public BigDecimal priceWithTax(BigDecimal net) {
// only this method is advised -- every other method on this class,
// and every method on every other *Service class, is untouched
return net.add(taxCalculator.taxFor(net));
}
public BigDecimal discountedPrice(BigDecimal net, BigDecimal discount) {
// not advised: no @LogExecutionTime here
return priceWithTax(net).subtract(discount);
}
}
Now the advice runs only where @LogExecutionTime is actually present, regardless of which package or class the
method lives in — this is the same mechanism @Transactional and @Cacheable use internally: each is
backed by its own @Aspect-equivalent advisor matching @annotation(…)-style pointcuts against Spring’s own
annotations, which is why annotating this method is enough to opt it into that behavior without touching a
pointcut expression anywhere. @Target(ElementType.TYPE) extends the same annotation to a whole class instead of
one method, mirroring how @Transactional can be placed at the class level to cover every method on it.
This matters beyond custom aspects: @Transactional and @Cacheable are themselves implemented as proxies,
not as compiler magic. That is why calling an @Transactional method from within the same class
(this.someTransactionalMethod()) bypasses the proxy and silently skips the transaction — the call never goes
through the container-managed proxy that carries the advice. Understanding this proxy mechanism now means those
annotations won’t be a black box later. See
the Spring Framework AOP reference for pointcut
expression syntax, advice types (@Before, @After, @AfterReturning, @AfterThrowing), and the AspectJ
integration.
How auto-configuration assembles beans
Spring Boot’s @SpringBootApplication bundles three annotations: @SpringBootConfiguration (itself a
@Configuration), @ComponentScan, and @EnableAutoConfiguration. The last one is what makes Spring Boot feel
"opinionated": it scans the classpath for auto-configuration classes and conditionally registers the beans they
declare, based on what’s actually present.
For example, adding spring-boot-starter-data-jpa to the classpath makes DataSourceAutoConfiguration and
HibernateJpaAutoConfiguration eligible; they only actually register a DataSource/EntityManagerFactory bean
if @ConditionalOnClass finds the JDBC driver and Hibernate on the classpath, @ConditionalOnMissingBean
confirms the application hasn’t already declared its own DataSource bean, and any required
@ConditionalOnProperty conditions (like a configured spring.datasource.url) are satisfied. Defining your own
@Bean of the same type is the standard way to override an auto-configured one, since
@ConditionalOnMissingBean backs off as soon as it sees yours.
Run an application with --debug (or set debug=true) to print the auto-configuration report, which lists
every candidate as "Positive" (applied) or "Negative" (condition not met, with the reason):
java -jar app.jar --debug
=========================
AUTO-CONFIGURATION REPORT
=========================
Positive matches:
-----------------
DataSourceAutoConfiguration matched:
- @ConditionalOnClass found required class 'javax.sql.DataSource' (OnClassCondition)
Negative matches:
-----------------
RabbitAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class 'org.springframework.amqp.rabbit.connection.ConnectionFactory' (OnClassCondition)
See
the Spring Boot auto-configuration
reference for how to write custom auto-configuration, the @AutoConfiguration ordering annotations
(@AutoConfigureBefore/@AutoConfigureAfter), and the full catalog of @Conditional* annotations Spring Boot
ships with.