Advanced Java

Dependency Injection

Module 2

Mastering IoC, Spring Framework & Testing

Dependency Injection: Why?

Dependency Injection (DI) has been proposed to improve the following situations:


Goal: Decouple object creation from object usage

The Problem: Tight Coupling

public class UserService {
    private UserRepository repository;

    public UserService() {
        // Tight coupling - hard to test, hard to change
        this.repository = new MySQLUserRepository();
    }

    public User findUser(Long id) {
        return repository.findById(id);
    }
}

Issues:

The Solution: Dependency Injection

public class UserService {
    private UserRepository repository; // (#1:Repository interface - not a concrete implementation)

    // (#2:Dependencies are injected from outside via constructor)
    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public User findUser(Long id) {
        return repository.findById(id); // (#3:Works with any UserRepository implementation)
    }
}

Benefits:

Dependency Injection: How?

The principle of Dependency Injection relies on:


IoC Container

Types of Dependency Injection

1. Constructor Injection (Recommended)

public UserService(UserRepository repository) {
    this.repository = repository;
}

2. Setter Injection

public void setRepository(UserRepository repository) {
    this.repository = repository;
}

3. Field Injection (Not Recommended)

@Autowired
private UserRepository repository;

DI Frameworks

To implement DI, several frameworks exist:


Spring is among the most used, this is why our examples will focus on Spring

Spring Framework Overview

Spring is a comprehensive framework for enterprise Java development.


Core Modules:


Note: Spring MVC, Spring Boot, and web features will be covered in the Microservices module

Why Testing Matters with DI

Dependency Injection makes testing significantly easier:


Key Insight: If your code is hard to test, it's often because of tight coupling!

Pure Java: Creating Test Doubles Manually

// Fake implementation for testing
class FakeUserRepository implements UserRepository {
    private Map<Long, User> users = new HashMap<>();
    private long nextId = 1L;

    @Override
    public User save(User user) {
        if (user.getId() == null) {
            user.setId(nextId++);
        }
        users.put(user.getId(), user);
        return user;
    }

    @Override
    public Optional<User> findById(Long id) {
        return Optional.ofNullable(users.get(id));
    }
}

Benefit: No framework needed - just plain Java!

Testing with Manual Fakes

class UserServiceTest {

    @Test
    void shouldSaveAndFindUser() {
        // Arrange - use fake implementation
        UserRepository fakeRepo = new FakeUserRepository();
        UserService service = new UserService(fakeRepo);

        User user = new User("john@example.com");

        // Act
        User saved = service.createUser(user);
        User found = service.findUser(saved.getId());

        // Assert
        assertNotNull(found);
        assertEquals("john@example.com", found.getEmail());
        assertEquals(saved.getId(), found.getId());
    }
}

Advantage: Real behavior, easy to debug, no magic

Pure Java: Stub for Simple Cases

// Stub - returns predefined values
class StubEmailService implements EmailService {
    private boolean emailSent = false;
    private String lastEmail;

    @Override
    public void sendWelcomeEmail(String email) {
        this.emailSent = true;
        this.lastEmail = email;
    }

    public boolean wasEmailSent() {
        return emailSent;
    }

    public String getLastEmail() {
        return lastEmail;
    }
}

Testing with Stubs

class UserServiceTest {

    @Test
    void shouldSendWelcomeEmail() {
        // Arrange
        UserRepository fakeRepo = new FakeUserRepository();
        StubEmailService emailStub = new StubEmailService();
        UserService service = new UserService(fakeRepo, emailStub);

        User user = new User("test@example.com");

        // Act
        service.createUser(user);

        // Assert
        assertTrue(emailStub.wasEmailSent());
        assertEquals("test@example.com", emailStub.getLastEmail());
    }
}

Benefit: Simple verification without mocking framework

Integration Tests with Spring Context

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = TestConfig.class)
class UserServiceIntegrationTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @Test
    void testWithSpringContext() {
        User user = new User("john@example.com");

        // Use real (test) implementation
        User saved = userService.createUser(user);
        User found = userService.findUser(saved.getId());

        assertNotNull(found);
        assertEquals("john@example.com", found.getEmail());
    }
}

Spring loads and wires beans automatically - testing real integration!

Creating Test Configuration

@Configuration
@ComponentScan(basePackages = "com.example.service")
public class TestConfig {

    @Bean
    @Primary  // Override production bean
    public UserRepository testUserRepository() {
        return new InMemoryUserRepository(); // Use in-memory fake
    }

    @Bean
    public EmailService testEmailService() {
        return new StubEmailService(); // Use stub implementation
    }
}

// Test configuration file
// src/test/resources/application-test.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.jpa.hibernate.ddl-auto=create-drop

Spring Test Slices

Load only specific parts of Spring context for faster tests:

// Test only the service layer
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {UserService.class, TestConfig.class})
class UserServiceSliceTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @Test
    void testServiceLayer() {
        // Only UserService and its dependencies are loaded
        User user = new User("test@example.com");
        User saved = userService.createUser(user);
        assertNotNull(saved.getId());
    }
}

Benefit: Faster than loading entire application context

Spring Beans and Stereotypes

A bean is an object managed by the Spring IoC container.


Defining Beans with Stereotypes:

@Component      // Generic Spring-managed component
public class MyComponent { }

@Service        // Business logic layer
public class UserService { }

@Repository     // Data access layer
public class UserRepository { }

Note: @Controller and @RestController are for web layer (covered in Microservices)

Constructor Injection - Best Practice

@Service
public class UserService {

    private final UserRepository userRepository; // (#1:Final fields ensure immutability)
    private final EmailService emailService;

    // (#2:Spring automatically injects dependencies - no @Autowired needed!)
    public UserService(UserRepository userRepository,
                      EmailService emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }

    public void createUser(User user) {
        userRepository.save(user); // (#3:Dependencies are guaranteed to be non-null)
        emailService.sendWelcomeEmail(user.getEmail());
    }
}

Benefits: Immutability, mandatory dependencies, easier testing

Spring Configuration Methods

Spring provides multiple ways to configure beans:

  1. Annotation-based: @Component, @Service, @Repository
  2. Java-based: @Configuration and @Bean
  3. XML-based: Legacy approach (not recommended)

We'll focus on annotation and Java-based configuration.

Java-Based Configuration with @Bean

Use @Configuration for third-party classes or complex setup:

@Configuration
public class AppConfig {

    @Bean
    public DataSource dataSource() {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
        dataSource.setUsername("user");
        dataSource.setPassword("password");
        dataSource.setMaximumPoolSize(10);
        dataSource.setConnectionTimeout(30000);
        return dataSource;
    }

    @Bean
    public JdbcTemplate jdbcTemplate(DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }
}

Bean Method Dependencies

@Configuration
public class ServiceConfig {

    @Bean
    public UserRepository userRepository(DataSource dataSource) {
        return new JdbcUserRepository(dataSource);
    }

    @Bean
    public EmailService emailService() {
        return new SmtpEmailService();
    }

    @Bean
    public UserService userService(UserRepository repository,
                                    EmailService emailService) {
        return new UserService(repository, emailService);
    }
}

Note: Spring resolves dependencies automatically based on method parameters

Bean Initialization and Destruction

@Configuration
public class DataSourceConfig {

    @Bean(initMethod = "initialize", destroyMethod = "close")
    public CustomDataSource dataSource() {
        CustomDataSource ds = new CustomDataSource();
        ds.setUrl("jdbc:postgresql://localhost:5432/mydb");
        return ds;
    }
}

// In the bean class
public class CustomDataSource {
    public void initialize() {
        // Called after properties are set
        System.out.println("Initializing connection pool");
    }

    public void close() {
        // Called before bean is destroyed
        System.out.println("Closing connection pool");
    }
}

Bean Scopes

Scope Description
singleton One instance per Spring container (default)
prototype New instance each time requested
request One instance per HTTP request (web apps)
session One instance per HTTP session (web apps)

@Scope("prototype")
@Component
public class PrototypeBean { }

@Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
@Component
public class SingletonBean { }

Handling Multiple Implementations

public interface PaymentService {
    void processPayment(Payment payment);
}

@Service("creditCardPayment")
public class CreditCardPaymentService implements PaymentService { }

@Service("paypalPayment")
public class PayPalPaymentService implements PaymentService { }

// Inject specific implementation
@Service
public class OrderService {

    public OrderService(@Qualifier("creditCardPayment")
                       PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

Using @Primary for Default Bean

public interface NotificationService {
    void notify(String message);
}

@Service
@Primary  // This will be injected by default
public class EmailNotificationService implements NotificationService {
    public void notify(String message) {
        // Send email
    }
}

@Service
public class SmsNotificationService implements NotificationService {
    public void notify(String message) {
        // Send SMS
    }
}

@Service
public class AlertService {
    // EmailNotificationService injected automatically
    public AlertService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
}

Externalizing Configuration with Properties

src/main/resources/application.properties:

# Database configuration
db.url=jdbc:postgresql://localhost:5432/mydb
db.username=admin
db.password=secret
db.pool.size=20

# Application configuration
app.name=My Application
app.version=1.0.0
app.email.from=noreply@example.com
app.email.smtp.host=smtp.gmail.com
app.email.smtp.port=587

# Feature flags
feature.notifications.enabled=true
feature.analytics.enabled=false

Injecting Properties with @Value

@Component
public class EmailService {

    @Value("${app.email.from}")
    private String fromEmail;

    @Value("${app.email.smtp.host}")
    private String smtpHost;

    @Value("${app.email.smtp.port}")
    private int smtpPort;

    // Default value if property not found
    @Value("${feature.notifications.enabled:true}")
    private boolean notificationsEnabled;

    // SpEL expressions
    @Value("#{systemProperties['user.home']}")
    private String userHome;

    public void sendEmail(String to, String subject, String body) {
        if (!notificationsEnabled) {
            return;
        }
        // Send email using configured properties
    }
}

Type-Safe Configuration Properties

@Configuration
@ConfigurationProperties(prefix = "app.email") // (#1:Binds all app.email.* properties)
public class EmailProperties {

    private String from; // (#2:Maps to app.email.from)
    private Smtp smtp = new Smtp(); // (#3:Nested configuration object)
    private boolean enabled = true;

    public static class Smtp {
        private String host; // (#4:Maps to app.email.smtp.host)
        private int port;
        private String username;
        private String password;
        private boolean auth = true;
        private boolean starttls = true;

        // Getters and setters
    }

    // Getters and setters
}
app.email.from=noreply@example.com
app.email.smtp.host=smtp.gmail.com
app.email.smtp.port=587
app.email.smtp.auth=true

Using Configuration Properties

@Service
public class EmailService {

    private final EmailProperties emailProperties;

    public EmailService(EmailProperties emailProperties) {
        this.emailProperties = emailProperties;
    }

    public void sendEmail(String to, String subject, String body) {
        if (!emailProperties.isEnabled()) {
            log.info("Email service is disabled");
            return;
        }

        Properties props = new Properties();
        props.put("mail.smtp.host", emailProperties.getSmtp().getHost());
        props.put("mail.smtp.port", emailProperties.getSmtp().getPort());
        props.put("mail.smtp.auth", emailProperties.getSmtp().isAuth());

        // Create and send email
    }
}

Validating Configuration Properties

@Configuration
@ConfigurationProperties(prefix = "app.email")
@Validated  // Enable validation
public class EmailProperties {

    @NotBlank(message = "Email 'from' address is required")
    @Email
    private String from;

    @Valid
    private Smtp smtp = new Smtp();

    public static class Smtp {
        @NotBlank
        private String host;

        @Min(1)
        @Max(65535)
        private int port;

        @NotBlank
        private String username;

        @NotBlank
        private String password;

        // Getters and setters
    }
}

Profiles for Different Environments

Activate different configurations for dev, test, prod:

# application.properties (base configuration)
app.name=My Application

# application-dev.properties
db.url=jdbc:h2:mem:testdb
logging.level.root=DEBUG
feature.analytics.enabled=false

# application-test.properties
db.url=jdbc:h2:mem:testdb
logging.level.root=INFO

# application-prod.properties
db.url=jdbc:postgresql://prod-db:5432/mydb
logging.level.root=WARN
feature.analytics.enabled=true

Activate via:

java -jar myapp.jar --spring.profiles.active=prod

Profile-Specific Beans

@Configuration
public class DataSourceConfig {

    @Bean
    @Profile("dev") // (#1:Only active when 'dev' profile is enabled)
    public DataSource devDataSource() {
        // (#2:H2 in-memory database for development - fast and disposable)
        return new EmbeddedDatabaseBuilder()
            .setType(EmbeddedDatabaseType.H2)
            .addScript("schema.sql")
            .addScript("test-data.sql")
            .build();
    }

    @Bean
    @Profile("prod") // (#3:Only active when 'prod' profile is enabled)
    public DataSource prodDataSource() {
        // (#4:Production PostgreSQL with connection pooling)
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:postgresql://prod-db:5432/mydb");
        ds.setUsername("prod_user");
        ds.setPassword("prod_password");
        ds.setMaximumPoolSize(50);
        return ds;
    }
}

Activating Multiple Profiles

@Configuration
public class ServiceConfig {

    @Bean
    @Profile("cache")
    public CacheManager cacheManager() {
        return new ConcurrentMapCacheManager("users", "products");
    }

    @Bean
    @Profile("mock-email")
    public EmailService mockEmailService() {
        return new MockEmailService();
    }

    @Bean
    @Profile("!mock-email")  // Active when mock-email NOT active
    public EmailService realEmailService() {
        return new SmtpEmailService();
    }
}

# Activate multiple profiles
java -jar myapp.jar --spring.profiles.active=dev,cache,mock-email

Conditional Bean Creation

@Configuration
public class ConditionalConfig {

    @Bean
    @ConditionalOnProperty(
        name = "cache.enabled",
        havingValue = "true",
        matchIfMissing = false
    )
    public CacheManager cacheManager() {
        return new ConcurrentMapCacheManager("users");
    }

    @Bean
    @ConditionalOnMissingBean  // Only if no other CacheManager exists
    public CacheManager defaultCacheManager() {
        return new NoOpCacheManager();
    }

    @Bean
    @ConditionalOnClass(name = "com.amazonaws.services.s3.AmazonS3")
    public StorageService s3StorageService() {
        return new S3StorageService();
    }
}

Loading External Property Files

@Configuration
@PropertySource("classpath:custom.properties")
@PropertySource("file:/etc/myapp/config.properties")
public class ExternalConfig {

    @Value("${custom.setting}")
    private String customSetting;
}

// Multiple property sources
@Configuration
@PropertySources({
    @PropertySource("classpath:app.properties"),
    @PropertySource("classpath:database.properties")
})
public class MultiConfig { }

Property precedence (highest to lowest):

  1. Command line arguments
  2. System properties
  3. Environment variables
  4. application-{profile}.properties
  5. application.properties

Component Scanning Configuration

@Configuration
@ComponentScan(
    basePackages = {"com.example.service", "com.example.repository"},
    includeFilters = @ComponentScan.Filter(
        type = FilterType.ANNOTATION,
        classes = Service.class
    ),
    excludeFilters = @ComponentScan.Filter(
        type = FilterType.REGEX,
        pattern = "com.example.legacy.*"
    )
)
public class AppConfig { }

// Or use type-safe base package classes
@ComponentScan(basePackageClasses = {
    UserService.class,
    ProductRepository.class
})
public class TypeSafeConfig { }

Bean Lifecycle Hooks

@Component
public class DatabaseConnectionPool {

    @PostConstruct
    public void init() {
        // Called after dependency injection
        // Initialize connection pool
        log.info("Initializing connection pool");
    }

    @PreDestroy
    public void cleanup() {
        // Called before bean is destroyed
        // Close all connections
        log.info("Closing connection pool");
    }
}

// Alternative: implement interfaces
@Component
public class MyBean implements InitializingBean, DisposableBean {

    @Override
    public void afterPropertiesSet() {
        // Same as @PostConstruct
    }

    @Override
    public void destroy() {
        // Same as @PreDestroy
    }
}

Advanced Testing with Mockito

While manual test doubles work well, Mockito provides powerful features for complex scenarios:


Add Mockito dependency to use these features

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Using Mockito Annotations

@ExtendWith(MockitoExtension.class) // (#1:JUnit 5 extension for Mockito support)
class UserServiceTest {

    @Mock // (#2:Create mock instances)
    private UserRepository userRepository;

    @Mock
    private EmailService emailService;

    @InjectMocks  // (#3:Automatically injects @Mock dependencies into UserService)
    private UserService userService;

    @Test
    void shouldCreateUserAndSendEmail() {
        User user = new User("test@example.com");

        when(userRepository.save(any(User.class))) // (#4:Stub the mock behavior)
            .thenReturn(user);

        userService.createUser(user);

        verify(userRepository).save(user); // (#5:Verify method was called)
        verify(emailService).sendWelcomeEmail("test@example.com");
    }
}

Verifying Method Calls with Mockito

@Test
void shouldVerifyInteractions() {
    UserRepository mockRepo = mock(UserRepository.class);
    UserService service = new UserService(mockRepo);

    User user = new User("test@example.com");
    service.createUser(user);

    // Verify exact call
    verify(mockRepo).save(user);

    // Verify number of calls
    verify(mockRepo, times(1)).save(any());

    // Verify no other interactions
    verifyNoMoreInteractions(mockRepo);

    // Verify never called
    verify(mockRepo, never()).delete(any());

    // Verify call order
    InOrder inOrder = inOrder(mockRepo);
    inOrder.verify(mockRepo).save(user);
}

Capturing Arguments with Mockito

@ExtendWith(MockitoExtension.class)
class EmailServiceTest {

    @Mock
    private EmailSender emailSender;

    @InjectMocks
    private NotificationService notificationService;

    @Captor
    private ArgumentCaptor<Email> emailCaptor;

    @Test
    void shouldSendCorrectEmail() {
        notificationService.notifyUser("user@example.com", "Welcome");

        // Capture the argument passed to emailSender
        verify(emailSender).send(emailCaptor.capture());

        Email sentEmail = emailCaptor.getValue();
        assertEquals("user@example.com", sentEmail.getTo());
        assertEquals("Welcome", sentEmail.getSubject());
        assertTrue(sentEmail.getBody().contains("Welcome"));
    }
}

Understanding @Spy vs @Mock

class SpyVsMockTest {

    @Mock
    private List<String> mockList;  // Completely fake

    @Spy
    private List<String> spyList = new ArrayList<>();  // Real object

    @Test
    void demonstrateDifference() {
        // Mock - all methods stubbed
        mockList.add("one");
        assertEquals(0, mockList.size());  // Still 0!

        // Spy - real methods called unless stubbed
        spyList.add("one");
        assertEquals(1, spyList.size());  // Actually adds

        // Can stub specific methods on spy
        when(spyList.size()).thenReturn(100);
        assertEquals(100, spyList.size());
    }
}

Choosing Between Manual Doubles and Mockito

Use Manual Test Doubles When:

Use Mockito When:


Best practice: Start with manual doubles, add Mockito when needed

Exercise 1: Build a Service with DI

Task: Create a user management service with dependency injection


Requirements:

  1. Create User model with id, name, email
  2. Create UserRepository interface with CRUD methods
  3. Create InMemoryUserRepository implementation
  4. Create UserService with constructor injection
  5. Write unit tests using Mockito to test UserService
  6. Write integration tests using Spring context

Exercise 2: Advanced Testing

Task: Write comprehensive tests for a notification system


Requirements:

  1. Create NotificationService that depends on EmailSender
  2. Write tests using @Mock and @InjectMocks
  3. Use ArgumentCaptor to verify email content
  4. Write parameterized tests for email validation
  5. Use verify() to check method invocations
  6. Test exception handling with @Test(expected = ...)

Exercise 3: Configuration Management

Task: Implement comprehensive configuration


Requirements:

  1. Create application.properties with database and email settings
  2. Create DatabaseProperties class with @ConfigurationProperties
  3. Add validation annotations to properties
  4. Create dev, test, and prod profiles
  5. Use @Profile to create environment-specific beans
  6. Test configuration loading with different active profiles

Exercise 4: Multiple Implementations

Task: Handle multiple payment methods


Requirements:

  1. Create PaymentProcessor interface
  2. Implement CreditCardProcessor and PayPalProcessor
  3. Use @Qualifier to inject specific implementation
  4. Use @Primary to set default processor
  5. Create a PaymentService that can switch processors
  6. Write tests for each implementation using mocks

Exercise 5: Bean Scopes & Lifecycle

Task: Understand different bean scopes and lifecycle hooks


Requirements:

  1. Create a singleton CacheService with @PostConstruct and @PreDestroy
  2. Create a prototype RequestContext bean
  3. Inject prototype bean into singleton - observe behavior
  4. Demonstrate thread-safety issues with singleton beans
  5. Use @Lookup or ObjectFactory to fix prototype injection
  6. Test lifecycle methods are called correctly

Exercise 6: Component Scanning & Profiles

Task: Configure a multi-module project with profiles


Requirements:

  1. Create packages: com.example.service, com.example.repository, com.example.config
  2. Configure component scanning to discover beans automatically
  3. Create @Configuration classes for dev, test, and prod profiles
  4. Use @ConditionalOnProperty for feature flags
  5. Write tests that activate different profiles
  6. Verify correct beans are loaded per profile

Exercise 7: Real-World Application

Task: Build a complete order processing system


Requirements:

  1. Create Order, Product, Customer models
  2. Implement OrderService with DI for repository, validator, notification
  3. Use @ConfigurationProperties for email and payment settings
  4. Create separate profiles for dev (in-memory) and prod (database)
  5. Write comprehensive tests using fakes, stubs, and Mockito
  6. Implement @Primary and @Qualifier for multiple notification channels
  7. Add @PostConstruct to initialize sample data in dev profile

Best Practices

1. Prefer Constructor Injection

2. Use Interfaces

3. Keep Beans Stateless

Common Anti-Patterns to Avoid

1. Field Injection

// Avoid this - hard to test, hides dependencies
@Autowired
private UserRepository repository;

2. Circular Dependencies

// A depends on B, B depends on A - BAD!
@Service
public class ServiceA {
    @Autowired private ServiceB serviceB;
}

@Service
public class ServiceB {
    @Autowired private ServiceA serviceA;
}

3. Over-using @Autowired

Constructor injection doesn't need @Autowired in modern Spring

Testing Best Practices

Configuration Best Practices

Summary

In this module, you learned:


Next Module: Data Persistence with JPA & Hibernate

Spring Boot, web features, and microservices patterns in Module 5

Resources

Slide Overview