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, execution(* com.example.orders..Service.(..)), is not wired to OrderPricingService or to any one method. It matches every public method, on every bean whose class name ends in Service, anywhere in the com.example.orders package or any of its sub-packages — the leading matches any return type, the trailing *Service. matches any class ending in Service and any method name on it, and (..) matches any argument list. TimingAspect runs its @Around advice around all of them, without OrderPricingService (or any other matched class) declaring anything about it. This breadth is exactly AOP’s value — one aspect definition, applied everywhere it matches, no source-code change per class — and exactly its risk: a pointcut written more broadly than intended silently starts intercepting classes nobody meant to affect.

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.

flowchart TD A["@SpringBootApplication on the main class"] --> B["Component scan\n(finds @Component/@Service/@Repository/@Controller)"] A --> C["@EnableAutoConfiguration"] C --> D["Candidate auto-configuration classes\n(declared by starters on the classpath)"] D --> E{"@Conditional* evaluation"} E -->|"@ConditionalOnClass:\nis the class on the classpath?"| F E -->|"@ConditionalOnMissingBean:\nhas the app already defined one?"| F E -->|"@ConditionalOnProperty:\ndoes a property enable it?"| F F["Matching conditions"] --> G["Beans registered in the ApplicationContext"] B --> G

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.