java4 min read

Spring DI Tutorial: Learn Dependency Injection from Scratch (2026)

Spring DI Tutorial: Learn Dependency Injection from Scratch (2026)

Published:  |  Category: Java  |  Reading time: ~15 min
Spring DI Tutorial: Learn Dependency Injection from Scratch (2026)

Dependency Injection is the core principle behind Spring Framework. Instead of objects creating their own dependencies, an external container injects them. This decouples component creation from business logic, making code testable, configurable, and maintainable. Understanding Spring DI deeply is essential for any Spring developer — everything from bean scopes to circular dependency resolution shapes how you design applications.

This tutorial covers the IoC container, bean wiring mechanisms, scopes, qualifiers, profiles, conditional beans, and the bean lifecycle.

IoC Container and BeanFactory

The ApplicationContext is Spring's IoC container — it instantiates, configures, and assembles beans. ClassPathXmlApplicationContext for XML config, AnnotationConfigApplicationContext for Java config. The context is immutable after refresh and should be created once per application lifecycle.

// Java config
@Configuration
@ComponentScan("com.example")
public class AppConfig {
    @Bean
    public PaymentService paymentService(PaymentGateway gateway) {
        return new PaymentService(gateway);
    }
    @Bean
    public PaymentGateway paymentGateway() {
        return new StripeGateway(System.getenv("STRIPE_API_KEY"));
    }
}
// Bootstrap
AnnotationConfigApplicationContext ctx =
    new AnnotationConfigApplicationContext(AppConfig.class);
PaymentService service = ctx.getBean(PaymentService.class);
ctx.close();

Wiring Mechanisms: XML, Java Config, Annotations

XML wiring () is legacy but still found in older projects. Java config (@Configuration + @Bean) provides type safety and refactoring support. Annotations (@Component, @Service) with constructor injection enable component scanning. All three can coexist in the same application, with annotation config typically preferred for new code.

// Annotation wiring (preferred)
@Service
public class OrderService {
    private final PaymentService paymentService;
    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}
// Equivalent XML

    

// Equivalent Java Config
@Bean
public OrderService orderService(PaymentService paymentService) {
    return new OrderService(paymentService);
}

Bean Scopes

Singleton (default): one instance per container. Prototype: new instance on every injection. Request, Session, and Application scopes are available in web applications. Choose prototype for stateful beans and singleton for stateless services. Session-scoped beans require proxyMode to be injectable into singleton beans.

@Component
@Scope("prototype")
public class ShoppingCart {
    private final List items = new ArrayList<>();
    public void addItem(Item item) { items.add(item); }
}

// Web scopes
@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION,
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserPreferences { ... }

Qualifiers, Primary, and Profiles

When multiple beans implement the same interface, use @Primary for the default or @Qualifier for explicit selection. Custom qualifier annotations reduce string-based coupling. @Profile activates beans for specific environments (dev, test, prod). @Conditional defines custom activation conditions based on classpath, properties, or system state.

@Component
@Primary
public class DefaultPaymentService implements PaymentService { ... }

@Component
@Qualifier("refund")
public class RefundPaymentService implements PaymentService { ... }

@Configuration
@Profile("dev")
public class DevConfig {
    @Bean
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
            .setType(EmbeddedDatabaseType.H2).build();
    }
}

@Configuration
@Profile("prod")
public class ProdConfig {
    @Bean
    public DataSource dataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(System.getenv("DB_URL"));
        return ds;
    }
}

Bean Lifecycle and Callbacks

The container manages bean lifecycle: instantiate, populate properties, set bean name, post-process before init, afterPropertiesSet, post-process after init, ready for use, pre-destroy. Use @PostConstruct and @PreDestroy for initialization and cleanup. Java config supports initMethod and destroyMethod attributes on @Bean.

@Component
public class CacheManager {
    private Cache productCache;
    
    @PostConstruct
    public void initialize() {
        this.productCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();
    }
    
    @PreDestroy
    public void shutdown() {
        productCache.invalidateAll();
        log.info("Cache cleared on shutdown");
    }
}

// Java config init/destroy
@Bean(initMethod = "connect", destroyMethod = "disconnect")
public MessageQueueConnection mqConnection() { ... }

Bean Post-Processors and Custom Annotations

BeanPostProcessor hooks into the bean lifecycle to modify or wrap beans after initialization. Implement postProcessBeforeInitialization and postProcessAfterInitialization methods. Common use cases: injecting custom dependencies, applying annotations, wrapping beans with proxies for logging or timing.

Create custom annotations that trigger specific post-processor behavior. For example, a @Retryable annotation combined with a BeanPostProcessor that wraps matching beans with a retry proxy — no need for AOP or external libraries for simple scenarios.

@Component
public class LogExecutionTimePostProcessor implements BeanPostProcessor {
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        // Check for custom annotation
        if (bean.getClass().isAnnotationPresent(LogExecutionTime.class)) {
            return Proxy.newProxyInstance(
                bean.getClass().getClassLoader(),
                bean.getClass().getInterfaces(),
                (proxy, method, args) -> {
                    long start = System.nanoTime();
                    Object result = method.invoke(bean, args);
                    long duration = System.nanoTime() - start;
                    log.info("{} executed in {} ms",
                        method.getName(), duration / 1_000_000);
                    return result;
                });
        }
        return bean;
    }
}

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {}

Frequently Asked Questions

What is the difference between @Inject, @Autowired, and @Resource?

@Autowired is Spring-specific, by-type injection. @Inject is JSR-330 (works in any DI container), by-type. @Resource is JSR-250, by-name. @Autowired with @Qualifier covers most use cases.

Should I use field injection or constructor injection?

Constructor injection is preferred — it makes dependencies explicit, enables final fields, and supports immutable objects. Field injection hides dependencies and complicates testing. Spring Boot 3+ requires constructor injection for core beans.

What is circular dependency and how do I fix it?

Circular dependency occurs when A depends on B and B depends on A. Fix by extracting the shared dependency into a third class, using @Lazy on one injection, or redesigning the relationships to remove the cycle.

What is the difference between BeanFactory and ApplicationContext?

BeanFactory is the basic DI container. ApplicationContext extends it with AOP support, internationalization, event publishing, and annotation support. Use ApplicationContext for all real Spring applications.

Originally published on Ayodhyyya. Last updated June 1, 2026.