Master Spring Dependency Injection and IoC
Spring Dependency Injection
Dependency Injection (DI) is a fundamental design pattern and the core feature of the Spring Framework. It implements Inversion of Control (IoC), where the control of object creation and dependency management is transferred from the application code to the Spring container. This leads to more maintainable, testable, and loosely coupled applications.
Understanding Dependency Injection
Traditional object creation creates tight coupling between classes:
// Without Dependency Injection - Tight Coupling
public class OrderService {
private PaymentService paymentService;
private EmailService emailService;
public OrderService() {
// Hard-coded dependencies - difficult to test and maintain
this.paymentService = new CreditCardPaymentService();
this.emailService = new SMTPEmailService();
}
public void processOrder(Order order) {
paymentService.processPayment(order.getAmount());
emailService.sendConfirmation(order.getCustomerEmail());
}
}
// With Dependency Injection - Loose Coupling
@Service
public class OrderService {
private final PaymentService paymentService;
private final EmailService emailService;
// Dependencies injected by Spring container
@Autowired
public OrderService(PaymentService paymentService, EmailService emailService) {
this.paymentService = paymentService;
this.emailService = emailService;
}
public void processOrder(Order order) {
paymentService.processPayment(order.getAmount());
emailService.sendConfirmation(order.getCustomerEmail());
}
}
Types of Dependency Injection
Spring supports three main types of dependency injection:
1. Constructor Injection (Recommended)
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
private final PasswordEncoder passwordEncoder;
// Constructor injection - dependencies are immutable and required
public UserService(UserRepository userRepository,
EmailService emailService,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.emailService = emailService;
this.passwordEncoder = passwordEncoder;
}
public User createUser(String email, String password) {
String encodedPassword = passwordEncoder.encode(password);
User user = new User(email, encodedPassword);
User saved = userRepository.save(user);
emailService.sendWelcomeEmail(saved);
return saved;
}
}
// Benefits of Constructor Injection:
// 1. Immutable dependencies (final fields)
// 2. Required dependencies are explicit
// 3. Easier to unit test
// 4. Prevents circular dependencies
// 5. Thread-safe
2. Setter Injection
@Service
public class ProductService {
private ProductRepository productRepository;
private CacheService cacheService;
// Setter injection - optional dependencies
@Autowired
public void setProductRepository(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Autowired(required = false) // Optional dependency
public void setCacheService(CacheService cacheService) {
this.cacheService = cacheService;
}
public Product findById(Long id) {
// Use cache if available
if (cacheService != null) {
Product cached = cacheService.get("product:" + id);
if (cached != null) return cached;
}
Product product = productRepository.findById(id);
if (cacheService != null && product != null) {
cacheService.put("product:" + id, product);
}
return product;
}
}
3. Field Injection (Not Recommended)
@Service
public class NotificationService {
@Autowired
private EmailService emailService; // Field injection
@Autowired
private SMSService smsService;
public void sendNotification(String message, String recipient) {
emailService.send(message, recipient);
smsService.send(message, recipient);
}
}
// Problems with Field Injection:
// 1. Cannot create immutable fields
// 2. Harder to unit test (requires reflection)
// 3. Hidden dependencies
// 4. Violates Single Responsibility Principle
// 5. Can lead to circular dependencies
Bean Configuration Approaches
Spring provides multiple ways to configure beans:
1. Annotation-Based Configuration
// Component scanning and stereotype annotations
@Component
public class FileUploadService {
public void uploadFile(String filename, byte[] content) {
// Implementation
}
}
@Repository
public class JpaUserRepository implements UserRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public User findById(Long id) {
return entityManager.find(User.class, id);
}
}
@Service
public class ReportService {
private final DataAnalysisService dataService;
public ReportService(DataAnalysisService dataService) {
this.dataService = dataService;
}
public Report generateReport(String type) {
return dataService.analyzeAndCreateReport(type);
}
}
@Controller
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok(user);
}
}
2. Java Configuration
@Configuration
@EnableJpaRepositories
@ComponentScan(basePackages = "com.example")
public class AppConfig {
@Bean
@Primary
public DataSource primaryDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:postgresql://localhost:5432/maindb");
dataSource.setUsername("user");
dataSource.setPassword("password");
dataSource.setMaximumPoolSize(20);
return dataSource;
}
@Bean
@Qualifier("reporting")
public DataSource reportingDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:postgresql://localhost:5432/reportdb");
dataSource.setUsername("report_user");
dataSource.setPassword("report_password");
dataSource.setMaximumPoolSize(5);
return dataSource;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
@Scope("prototype") // New instance for each injection
public EmailTemplate emailTemplate() {
return new EmailTemplate();
}
@Bean
@ConditionalOnProperty(name = "cache.enabled", havingValue = "true")
public CacheService cacheService() {
return new RedisCacheService();
}
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
}
3. Profile-Based Configuration
@Configuration
public class DatabaseConfig {
@Bean
@Profile("development")
public DataSource devDataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.build();
}
@Bean
@Profile("production")
public DataSource prodDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:postgresql://prod-db:5432/app");
dataSource.setUsername("${DB_USERNAME}");
dataSource.setPassword("${DB_PASSWORD}");
return dataSource;
}
@Bean
@Profile("test")
public DataSource testDataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.setName("testdb")
.build();
}
}
@Service
@Profile("!production") // Not in production
public class MockPaymentService implements PaymentService {
@Override
public PaymentResult processPayment(BigDecimal amount) {
return PaymentResult.success("MOCK-" + UUID.randomUUID());
}
}
@Service
@Profile("production")
public class StripePaymentService implements PaymentService {
@Override
public PaymentResult processPayment(BigDecimal amount) {
// Real payment processing
return null;
}
}
Bean Scopes and Lifecycle
Understanding bean scopes and lifecycle is crucial for proper dependency management:
Bean Scopes
@Component
@Scope("singleton") // Default scope - one instance per container
public class ConfigurationService {
private final Properties config = new Properties();
@PostConstruct
public void loadConfiguration() {
// Load configuration once at startup
}
}
@Component
@Scope("prototype") // New instance for each injection
public class ReportGenerator {
private String reportType;
private Date generationTime;
public void setReportType(String reportType) {
this.reportType = reportType;
this.generationTime = new Date();
}
}
@Component
@Scope("request") // One instance per HTTP request (web applications)
public class RequestContext {
private String userId;
private String sessionId;
public void setContext(HttpServletRequest request) {
this.userId = request.getHeader("User-ID");
this.sessionId = request.getSession().getId();
}
}
@Component
@Scope("session") // One instance per HTTP session
public class ShoppingCart {
private List<CartItem> items = new ArrayList<>();
public void addItem(CartItem item) {
items.add(item);
}
}
Bean Lifecycle Callbacks
@Component
public class DatabaseConnectionPool implements InitializingBean, DisposableBean {
private DataSource dataSource;
private HealthCheckScheduler healthChecker;
// Method called after dependency injection
@PostConstruct
public void initialize() {
System.out.println("Initializing database connection pool");
setupConnectionPool();
}
// InitializingBean interface method
@Override
public void afterPropertiesSet() throws Exception {
System.out.println("All properties set, starting health checker");
healthChecker = new HealthCheckScheduler(dataSource);
healthChecker.start();
}
// Method called before bean destruction
@PreDestroy
public void cleanup() {
System.out.println("Cleaning up database connection pool");
if (healthChecker != null) {
healthChecker.stop();
}
}
// DisposableBean interface method
@Override
public void destroy() throws Exception {
System.out.println("Destroying database connection pool");
shutdownConnectionPool();
}
private void setupConnectionPool() {
// Setup logic
}
private void shutdownConnectionPool() {
// Cleanup logic
}
}
Advanced Dependency Injection Concepts
Conditional Bean Creation
@Configuration
public class ConditionalConfig {
@Bean
@ConditionalOnClass(RedisTemplate.class)
@ConditionalOnProperty(name = "redis.enabled", havingValue = "true")
public CacheService redisCacheService() {
return new RedisCacheService();
}
@Bean
@ConditionalOnMissingBean(CacheService.class)
public CacheService inMemoryCacheService() {
return new InMemoryCacheService();
}
@Bean
@ConditionalOnWebApplication
public WebSecurityConfig webSecurityConfig() {
return new WebSecurityConfig();
}
}
Qualifier and Primary Annotations
// Multiple implementations of same interface
@Service
@Qualifier("email")
public class EmailNotificationService implements NotificationService {
@Override
public void send(String message, String recipient) {
// Email implementation
}
}
@Service
@Qualifier("sms")
public class SMSNotificationService implements NotificationService {
@Override
public void send(String message, String recipient) {
// SMS implementation
}
}
@Service
@Primary // Default implementation when no qualifier specified
public class PushNotificationService implements NotificationService {
@Override
public void send(String message, String recipient) {
// Push notification implementation
}
}
// Using qualifiers in injection
@Service
public class NotificationManager {
private final NotificationService emailService;
private final NotificationService smsService;
private final NotificationService defaultService;
public NotificationManager(@Qualifier("email") NotificationService emailService,
@Qualifier("sms") NotificationService smsService,
NotificationService defaultService) { // Uses @Primary
this.emailService = emailService;
this.smsService = smsService;
this.defaultService = defaultService;
}
public void sendMultiChannel(String message, String recipient) {
emailService.send(message, recipient);
smsService.send(message, recipient);
defaultService.send(message, recipient);
}
}
Circular Dependency Resolution
// Circular dependency example and solutions
// Problem: Circular dependency
@Service
public class UserService {
private final OrderService orderService; // Circular dependency
public UserService(OrderService orderService) {
this.orderService = orderService;
}
}
@Service
public class OrderService {
private final UserService userService; // Circular dependency
public OrderService(UserService userService) {
this.userService = userService;
}
}
// Solution 1: Setter injection with @Lazy
@Service
public class UserService {
private final OrderService orderService;
public UserService(@Lazy OrderService orderService) {
this.orderService = orderService;
}
}
// Solution 2: Extract common functionality
@Service
public class UserOrderService {
public void processUserOrder(User user, Order order) {
// Common logic extracted
}
}
@Service
public class UserService {
private final UserOrderService userOrderService;
public UserService(UserOrderService userOrderService) {
this.userOrderService = userOrderService;
}
}
@Service
public class OrderService {
private final UserOrderService userOrderService;
public OrderService(UserOrderService userOrderService) {
this.userOrderService = userOrderService;
}
}
Testing with Dependency Injection
Dependency injection makes testing much easier:
Unit Testing
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private UserService userService;
@Test
void shouldCreateUser() {
// Given
String email = "[email protected]";
String password = "password123";
String encodedPassword = "encoded_password";
User expectedUser = new User(email, encodedPassword);
expectedUser.setId(1L);
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(userRepository.save(any(User.class))).thenReturn(expectedUser);
// When
User result = userService.createUser(email, password);
// Then
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getEmail()).isEqualTo(email);
verify(emailService).sendWelcomeEmail(result);
}
}
Integration Testing
@SpringBootTest
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:testdb",
"spring.jpa.hibernate.ddl-auto=create-drop"
})
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@MockBean // Mock specific beans in integration tests
private EmailService emailService;
@Test
void shouldCreateAndRetrieveUser() {
// Given
String email = "[email protected]";
String password = "password123";
// When
User created = userService.createUser(email, password);
Optional<User> retrieved = userRepository.findById(created.getId());
// Then
assertThat(retrieved).isPresent();
assertThat(retrieved.get().getEmail()).isEqualTo(email);
verify(emailService).sendWelcomeEmail(created);
}
}
Best Practices
// 1. Prefer constructor injection
@Service
public class GoodService {
private final DependencyA dependencyA;
private final DependencyB dependencyB;
// All dependencies required and immutable
public GoodService(DependencyA dependencyA, DependencyB dependencyB) {
this.dependencyA = dependencyA;
this.dependencyB = dependencyB;
}
}
// 2. Use interfaces for dependencies
public interface PaymentService {
PaymentResult processPayment(BigDecimal amount);
}
@Service
public class OrderService {
private final PaymentService paymentService; // Interface, not implementation
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
// 3. Avoid too many dependencies (consider refactoring)
@Service
public class OverlyComplexService {
// Too many dependencies might indicate SRP violation
public OverlyComplexService(DepA a, DepB b, DepC c, DepD d, DepE e, DepF f) {
// Consider breaking this into smaller services
}
}
// 4. Use @Lazy for expensive beans
@Service
public class ExpensiveService {
private final ExpensiveResource resource;
public ExpensiveService(@Lazy ExpensiveResource resource) {
this.resource = resource; // Created only when first accessed
}
}
// 5. Validate dependencies
@Service
public class ValidatingService {
private final CriticalDependency dependency;
public ValidatingService(CriticalDependency dependency) {
this.dependency = Objects.requireNonNull(dependency, "Critical dependency cannot be null");
}
@PostConstruct
public void validateState() {
if (!dependency.isHealthy()) {
throw new IllegalStateException("Critical dependency is not healthy");
}
}
}
Summary
Spring Dependency Injection provides powerful IoC capabilities:
Key Benefits:
- Loose Coupling: Dependencies are injected, not hard-coded
- Testability: Easy to mock dependencies for unit testing
- Flexibility: Different implementations can be injected
- Maintainability: Changes to dependencies don't require code changes
- Configuration Management: Centralized bean configuration
Injection Types:
- Constructor Injection: Recommended for required dependencies
- Setter Injection: Good for optional dependencies
- Field Injection: Not recommended due to limitations
Configuration Approaches:
- Annotation-Based: @Component, @Service, @Repository, @Controller
- Java Configuration: @Configuration classes with @Bean methods
- Profile-Based: Environment-specific configurations
Advanced Features:
- Bean Scopes: singleton, prototype, request, session
- Lifecycle Callbacks: @PostConstruct, @PreDestroy
- Conditional Beans: @ConditionalOnClass, @ConditionalOnProperty
- Qualifiers: @Qualifier, @Primary for multiple implementations
Best Practices:
- Prefer Constructor Injection: Immutable dependencies and explicit requirements
- Use Interfaces: Program against abstractions
- Avoid Circular Dependencies: Refactor or use @Lazy
- Test Thoroughly: Both unit and integration tests
- Validate Dependencies: Ensure critical dependencies are healthy
Dependency Injection is fundamental to Spring's architecture and enables building maintainable, testable, and flexible applications.