Dependency Injection

Why Dependency Injection Matters

Dependency Injection (DI) is a design pattern that provides objects with their dependencies from outside rather than creating dependencies internally. This fundamental technique enables testability, flexibility, and maintainable architecture.

Core benefits:

  • Testability: Replace real dependencies with mocks in tests
  • Flexibility: Swap implementations without changing code
  • Decoupling: Classes don’t know about concrete dependencies
  • Configuration centralization: Wire dependencies in one place
  • Reusability: Components work with any compatible dependency

Problem: Tightly coupled code creates dependencies that are hard to test, change, or reuse.

Solution: Inject dependencies from outside, enabling flexible, testable designs that respect the Dependency Inversion Principle.

This guide progresses from manual patterns using only the standard library to framework-based solutions with JSR-330, Spring, and CDI.

Manual Dependency Injection (Standard Library)

Start with manual DI using only Java standard library features to understand the fundamental patterns before introducing frameworks.

Constructor Injection Pattern

Constructor injection provides dependencies when creating objects, ensuring complete initialization and enabling immutability.

// SERVICE INTERFACE
public interface NotificationService {
    void send(String recipient, String message);
}

// CONCRETE IMPLEMENTATION
public class EmailService implements NotificationService {
    @Override
    public void send(String recipient, String message) {
        System.out.println("Email to " + recipient + ": " + message);
        // => Would connect to SMTP server in real implementation
    }
}

// CLIENT WITH CONSTRUCTOR INJECTION
public class UserRegistration {
    private final NotificationService notificationService;  // => Final ensures immutability

    // => CONSTRUCTOR INJECTION: Dependency provided from outside
    public UserRegistration(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void registerUser(String email, String username) {
        // Business logic
        System.out.println("Registering user: " + username);

        // => Use injected dependency
        notificationService.send(email, "Welcome, " + username + "!");
    }
}

// MANUAL WIRING
public class Application {
    public static void main(String[] args) {
        // => Create dependency
        NotificationService emailService = new EmailService();

        // => Inject dependency through constructor
        UserRegistration registration = new UserRegistration(emailService);

        // => Use configured object
        registration.registerUser("user@example.com", "john");
        // => Output: Registering user: john
        // => Output: Email to user@example.com: Welcome, john!
    }
}

Benefits:

  • Dependencies explicit in constructor signature
  • Immutability via final fields
  • Impossible to create incomplete objects
  • Easy to test (inject mocks)

Interface-Based Dependencies

Program to interfaces, not implementations, enabling flexible substitution.

// REPOSITORY INTERFACE
public interface UserRepository {
    void save(User user);
    Optional<User> findByEmail(String email);
}

// IN-MEMORY IMPLEMENTATION (for testing)
public class InMemoryUserRepository implements UserRepository {
    private final Map<String, User> users = new HashMap<>();  // => Simple storage

    @Override
    public void save(User user) {
        users.put(user.email(), user);  // => Store in map
    }

    @Override
    public Optional<User> findByEmail(String email) {
        return Optional.ofNullable(users.get(email));  // => Lookup from map
    }
}

// DATABASE IMPLEMENTATION (for production)
public class DatabaseUserRepository implements UserRepository {
    private final DataSource dataSource;  // => JDBC connection pool

    public DatabaseUserRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public void save(User user) {
        // => Execute INSERT SQL
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(
                 "INSERT INTO users (email, name) VALUES (?, ?)")) {

            stmt.setString(1, user.email());
            stmt.setString(2, user.name());
            stmt.executeUpdate();
        } catch (SQLException e) {
            throw new RuntimeException("Failed to save user", e);
        }
    }

    @Override
    public Optional<User> findByEmail(String email) {
        // => Execute SELECT SQL
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(
                 "SELECT email, name FROM users WHERE email = ?")) {

            stmt.setString(1, email);
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(new User(
                        rs.getString("email"),
                        rs.getString("name")
                    ));
                }
            }
        } catch (SQLException e) {
            throw new RuntimeException("Failed to find user", e);
        }
        return Optional.empty();
    }
}

// SERVICE USING INTERFACE
public class UserService {
    private final UserRepository repository;  // => Depends on interface, not implementation

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

    public void createUser(String email, String name) {
        User user = new User(email, name);
        repository.save(user);  // => Works with ANY UserRepository implementation
    }
}

// CONFIGURATION
public class Application {
    public static void main(String[] args) {
        // PRODUCTION: Use database
        DataSource dataSource = createDataSource();
        UserRepository repository = new DatabaseUserRepository(dataSource);

        // TESTING: Use in-memory
        // UserRepository repository = new InMemoryUserRepository();

        UserService service = new UserService(repository);
        service.createUser("test@example.com", "Test User");
    }

    private static DataSource createDataSource() {
        // => Configure connection pool (HikariCP, etc.)
        return null;  // Placeholder
    }
}

Benefits:

  • Swap implementations without changing UserService
  • Test with in-memory implementation (fast, no database)
  • Production uses database implementation

Factory Pattern for Object Creation

Factories centralize object creation and dependency wiring.

// SIMPLE FACTORY
public class ServiceFactory {
    private final UserRepository userRepository;
    private final NotificationService notificationService;

    public ServiceFactory() {
        // => Create shared dependencies once
        this.userRepository = new InMemoryUserRepository();
        this.notificationService = new EmailService();
    }

    public UserRegistration createUserRegistration() {
        // => Wire dependencies
        return new UserRegistration(notificationService);
    }

    public UserService createUserService() {
        // => Wire dependencies
        return new UserService(userRepository);
    }
}

// USAGE
public class Application {
    public static void main(String[] args) {
        ServiceFactory factory = new ServiceFactory();  // => Single factory

        UserRegistration registration = factory.createUserRegistration();
        UserService userService = factory.createUserService();

        // => Use services
        registration.registerUser("test@example.com", "john");
    }
}

Benefits:

  • Centralized dependency configuration
  • Consistent object creation
  • Easy to change wiring

Service Locator Pattern (Anti-Pattern)

Service Locator hides dependencies and creates tight coupling. Avoid this pattern.

// SERVICE LOCATOR (ANTI-PATTERN)
public class ServiceLocator {
    private static final Map<Class<?>, Object> services = new HashMap<>();

    public static <T> void register(Class<T> serviceClass, T implementation) {
        services.put(serviceClass, implementation);  // => Register service
    }

    public static <T> T get(Class<T> serviceClass) {
        return serviceClass.cast(services.get(serviceClass));  // => Retrieve service
    }
}

// CLIENT USING SERVICE LOCATOR (BAD)
public class UserRegistrationBad {
    public void registerUser(String email, String username) {
        // => HIDDEN DEPENDENCY: NotificationService not visible in constructor
        NotificationService service = ServiceLocator.get(NotificationService.class);
        service.send(email, "Welcome!");
    }
}

// SETUP
public class Application {
    public static void main(String[] args) {
        // => Global registration
        ServiceLocator.register(NotificationService.class, new EmailService());

        UserRegistrationBad registration = new UserRegistrationBad();
        registration.registerUser("test@example.com", "john");
    }
}

Why Service Locator is an anti-pattern:

  • Hidden dependencies: Can’t see what UserRegistrationBad needs
  • Hard to test: Must configure global state before testing
  • Runtime failures: Missing services cause NullPointerException
  • Tight coupling: Code depends on ServiceLocator infrastructure

Prefer constructor injection: Dependencies explicit, testable, no global state.

Why Manual DI Becomes Complex

Manual dependency injection works for small applications but becomes problematic as systems grow.

Challenges:

ProblemImpactExample
Wiring overheadMust manually create and wire all objects50 classes = 50 manual instantiations
ConfigurationHard to manage different environmentsTest vs production vs staging configuration
LifecycleManaging singletons vs per-request instancesWhen to create, when to destroy
Circular depsManual resolution of A → B → AUserService ↔ AuditService
Aspect concernsCross-cutting logic (logging, transactions)Add logging to all service methods

Solution: DI frameworks automate wiring, manage lifecycles, and provide advanced features.

JSR-330 (Standard - javax.inject / jakarta.inject)

JSR-330 defines standard annotations for dependency injection across frameworks. Use these annotations for framework-agnostic code.

@Inject Annotation

Mark injection points with @Inject (constructor, field, or method).

import jakarta.inject.Inject;  // => Standard DI annotation

public class UserService {
    private final UserRepository repository;  // => Dependency

    @Inject  // => Mark constructor for injection
    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public void createUser(String email, String name) {
        repository.save(new User(email, name));
    }
}

Note: Use jakarta.inject (Jakarta EE 9+) or javax.inject (older versions). Jakarta is the modern standard.

@Named and @Qualifier

Disambiguate multiple implementations with @Named or custom qualifiers.

import jakarta.inject.Inject;
import jakarta.inject.Named;

// MULTIPLE IMPLEMENTATIONS
public class EmailNotificationService implements NotificationService {
    @Override
    public void send(String recipient, String message) {
        System.out.println("Email: " + message);
    }
}

public class SmsNotificationService implements NotificationService {
    @Override
    public void send(String recipient, String message) {
        System.out.println("SMS: " + message);
    }
}

// DISAMBIGUATE WITH @Named
public class UserRegistration {
    private final NotificationService emailService;
    private final NotificationService smsService;

    @Inject
    public UserRegistration(
        @Named("email") NotificationService emailService,  // => Inject "email" implementation
        @Named("sms") NotificationService smsService       // => Inject "sms" implementation
    ) {
        this.emailService = emailService;
        this.smsService = smsService;
    }

    public void registerUser(String email, String phone) {
        emailService.send(email, "Welcome!");
        smsService.send(phone, "Account created");
    }
}

Provider<T> for Lazy Initialization

Use Provider<T> to delay object creation until needed.

import jakarta.inject.Inject;
import jakarta.inject.Provider;

public class ReportGenerator {
    private final Provider<ExpensiveResource> resourceProvider;  // => Lazy provider

    @Inject
    public ReportGenerator(Provider<ExpensiveResource> resourceProvider) {
        this.resourceProvider = resourceProvider;
    }

    public void generateReport() {
        // => Resource created only when get() called
        ExpensiveResource resource = resourceProvider.get();
        resource.generateData();
    }
}

// EXPENSIVE RESOURCE (created only when needed)
public class ExpensiveResource {
    public ExpensiveResource() {
        System.out.println("Creating expensive resource...");  // => Delayed until needed
    }

    public void generateData() {
        System.out.println("Generating data...");
    }
}

Benefits:

  • Delay creation until actually needed
  • Avoid circular dependencies
  • Create multiple instances on demand

Singleton Scope (@Singleton)

Mark classes as singletons to share one instance across application.

import jakarta.inject.Inject;
import jakarta.inject.Singleton;

@Singleton  // => One instance shared by all clients
public class ConfigurationService {
    private final Map<String, String> config = new HashMap<>();

    public ConfigurationService() {
        // => Load configuration once
        config.put("app.name", "MyApp");
        config.put("app.version", "1.0");
    }

    public String get(String key) {
        return config.get(key);
    }
}

// BOTH SERVICES SHARE SAME ConfigurationService INSTANCE
public class ServiceA {
    @Inject
    public ServiceA(ConfigurationService config) {
        // => Same instance as ServiceB receives
    }
}

public class ServiceB {
    @Inject
    public ServiceB(ConfigurationService config) {
        // => Same instance as ServiceA receives
    }
}

Standard Annotations Only (No Implementation)

JSR-330 provides annotations but not the implementation. You need a DI container:

  • Spring Framework (most popular)
  • CDI (Jakarta EE standard)
  • Guice (Google’s lightweight DI)
  • Dagger (compile-time DI for Android)
// JSR-330 annotations work with ANY compatible container
import jakarta.inject.Inject;
import jakarta.inject.Singleton;

@Singleton
public class MyService {
    @Inject
    public MyService(Dependency dependency) {
        // => Spring, CDI, Guice all understand these annotations
    }
}

Benefits:

  • Write once, run anywhere (any JSR-330 container)
  • No vendor lock-in
  • Standard annotations across projects

Spring Dependency Injection

Spring Framework provides comprehensive DI with extensive features for enterprise applications.

Component Scanning (@Component, @Service, @Repository)

Mark classes for automatic discovery and registration.

import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.stereotype.Repository;

// GENERIC COMPONENT
@Component  // => Spring discovers and creates instance
public class EmailValidator {
    public boolean isValid(String email) {
        return email.contains("@");
    }
}

// SERVICE LAYER (business logic)
@Service  // => @Component specialized for services
public class UserService {
    private final UserRepository repository;

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

    public void createUser(String email, String name) {
        repository.save(new User(email, name));
    }
}

// REPOSITORY LAYER (data access)
@Repository  // => @Component specialized for persistence
public class UserRepository {
    public void save(User user) {
        // Database operations
    }
}

Annotation purposes:

AnnotationPurposeLayer
@ComponentGeneric Spring-managed beanAny
@ServiceBusiness logicService layer
@RepositoryData accessData layer
@ControllerWeb controllersWeb layer

@Autowired (Field, Constructor, Setter Injection)

Mark injection points with @Autowired (Spring-specific, but supports JSR-330 @Inject too).

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class OrderService {
    // FIELD INJECTION (NOT RECOMMENDED - see constructor injection)
    @Autowired
    private PaymentService paymentService;

    // SETTER INJECTION (for optional dependencies)
    private NotificationService notificationService;

    @Autowired(required = false)  // => Optional dependency
    public void setNotificationService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void processOrder(Order order) {
        paymentService.process(order);
        if (notificationService != null) {
            notificationService.send(order.customerEmail(), "Order processed");
        }
    }
}

Constructor Injection (Recommended Approach)

Prefer constructor injection for required dependencies (immutability, testability, clarity).

import org.springframework.stereotype.Service;

@Service
public class UserRegistration {
    private final UserRepository repository;          // => Final = immutable
    private final NotificationService notification;   // => Final = immutable
    private final EmailValidator validator;           // => Final = immutable

    // CONSTRUCTOR INJECTION (RECOMMENDED)
    // => No @Autowired needed in Spring 4.3+ for single constructor
    public UserRegistration(UserRepository repository,
                           NotificationService notification,
                           EmailValidator validator) {
        this.repository = repository;
        this.notification = notification;
        this.validator = validator;
    }

    public void registerUser(String email, String name) {
        if (!validator.isValid(email)) {
            throw new IllegalArgumentException("Invalid email");
        }

        User user = new User(email, name);
        repository.save(user);
        notification.send(email, "Welcome!");
    }
}

Why constructor injection is recommended:

  • Immutability: Final fields prevent accidental changes
  • Testability: Easy to pass mocks in tests
  • Required dependencies: Impossible to create incomplete objects
  • No Spring dependency: Constructor works without Spring annotations

Testing example:

@Test
void testRegisterUser() {
    // => Create mocks
    UserRepository mockRepo = mock(UserRepository.class);
    NotificationService mockNotif = mock(NotificationService.class);
    EmailValidator mockValidator = mock(EmailValidator.class);

    // => Inject mocks via constructor (no Spring needed)
    UserRegistration registration = new UserRegistration(
        mockRepo, mockNotif, mockValidator
    );

    // => Configure mock behavior
    when(mockValidator.isValid(anyString())).thenReturn(true);

    // => Test
    registration.registerUser("test@example.com", "John");

    // => Verify interactions
    verify(mockRepo).save(any(User.class));
    verify(mockNotif).send(eq("test@example.com"), anyString());
}

@Qualifier for Disambiguation

Use @Qualifier when multiple beans of same type exist.

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

// MULTIPLE IMPLEMENTATIONS
@Component
@Qualifier("email")  // => Tag with "email"
public class EmailNotificationService implements NotificationService {
    @Override
    public void send(String recipient, String message) {
        System.out.println("Email: " + message);
    }
}

@Component
@Qualifier("sms")  // => Tag with "sms"
public class SmsNotificationService implements NotificationService {
    @Override
    public void send(String recipient, String message) {
        System.out.println("SMS: " + message);
    }
}

// INJECT SPECIFIC IMPLEMENTATION
@Service
public class UserRegistration {
    private final NotificationService emailService;
    private final NotificationService smsService;

    public UserRegistration(
        @Qualifier("email") NotificationService emailService,  // => Inject "email" bean
        @Qualifier("sms") NotificationService smsService       // => Inject "sms" bean
    ) {
        this.emailService = emailService;
        this.smsService = smsService;
    }
}

@Primary Annotation

Mark default implementation when multiple exist.

import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

@Component
@Primary  // => Default choice when no @Qualifier specified
public class EmailNotificationService implements NotificationService {
    // Default implementation
}

@Component
public class SmsNotificationService implements NotificationService {
    // Alternative implementation
}

@Service
public class UserService {
    private final NotificationService notificationService;

    public UserService(NotificationService notificationService) {
        // => Receives EmailNotificationService (marked @Primary)
        this.notificationService = notificationService;
    }
}

@Profile for Environment-Specific Beans

Configure different beans for different environments (dev, test, production).

import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;

// DEVELOPMENT: In-memory repository
@Repository
@Profile("dev")  // => Active only when "dev" profile enabled
public class InMemoryUserRepository implements UserRepository {
    private final Map<String, User> users = new HashMap<>();

    @Override
    public void save(User user) {
        users.put(user.email(), user);
    }
}

// PRODUCTION: Database repository
@Repository
@Profile("prod")  // => Active only when "prod" profile enabled
public class DatabaseUserRepository implements UserRepository {
    private final DataSource dataSource;

    public DatabaseUserRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public void save(User user) {
        // Database operations
    }
}

// ACTIVATE PROFILE
// Option 1: application.properties
// spring.profiles.active=dev

// Option 2: Command line
// java -Dspring.profiles.active=prod -jar app.jar

// Option 3: Test annotation
// @ActiveProfiles("dev")

ApplicationContext (Container)

ApplicationContext is Spring’s IoC container that manages beans and dependencies.

import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

// CONFIGURATION CLASS
@Configuration
@ComponentScan(basePackages = "com.example.app")  // => Scan for @Component classes
public class AppConfig {
    // Configuration here
}

// MANUAL CONTAINER USAGE (rare, Spring Boot does this automatically)
public class Application {
    public static void main(String[] args) {
        // => Create container
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);

        // => Retrieve beans
        UserService userService = context.getBean(UserService.class);
        userService.createUser("test@example.com", "John");

        // => All dependencies auto-wired by Spring
    }
}

Bean Scopes

Control bean lifecycle with scopes.

import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.RequestScope;
import org.springframework.web.context.annotation.SessionScope;

// SINGLETON (DEFAULT): One instance per container
@Component
@Scope("singleton")  // => Default, can omit
public class ConfigurationService {
    // Shared instance across entire application
}

// PROTOTYPE: New instance every time
@Component
@Scope("prototype")  // => New instance per injection
public class ReportGenerator {
    // Fresh instance for each use
}

// REQUEST: One instance per HTTP request (web apps)
@Component
@RequestScope  // => New instance per HTTP request
public class RequestContext {
    // Separate instance per web request
}

// SESSION: One instance per HTTP session (web apps)
@Component
@SessionScope  // => New instance per user session
public class ShoppingCart {
    // User's cart maintained across requests in same session
}

Scope comparison:

ScopeLifetimeUse Case
singletonApplication lifecycleStateless services, config
prototypeCreated on demandStateful objects, unique state
requestHTTP requestRequest-specific data (web)
sessionHTTP sessionUser session data (web)

Spring Boot Auto-Configuration

Spring Boot eliminates boilerplate configuration through intelligent defaults and auto-configuration.

@SpringBootApplication

Single annotation that enables component scanning, auto-configuration, and configuration properties.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication  // => Combines @Configuration, @EnableAutoConfiguration, @ComponentScan
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
        // => Starts embedded server, configures beans, scans components
    }
}

What @SpringBootApplication does:

  • @Configuration: Declares configuration class
  • @EnableAutoConfiguration: Auto-configures beans based on classpath
  • @ComponentScan: Scans package and subpackages for components

@EnableAutoConfiguration

Auto-configures beans based on dependencies on classpath.

// AUTOMATIC DATASOURCE CONFIGURATION
// If spring-boot-starter-data-jpa on classpath:
// - Creates DataSource bean from application.properties
// - Creates EntityManagerFactory
// - Creates TransactionManager
// - No manual configuration needed!

// application.properties
// spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
// spring.datasource.username=user
// spring.datasource.password=pass

@Service
public class UserService {
    private final UserRepository repository;

    // => DataSource automatically configured and injected into JPA repository
    public UserService(UserRepository repository) {
        this.repository = repository;
    }
}

Common auto-configurations:

DependencyAuto-configures
spring-boot-starter-webEmbedded Tomcat, Spring MVC
spring-boot-starter-data-jpaDataSource, JPA, TransactionManager
spring-boot-starter-securitySecurity filters, authentication
spring-boot-starter-data-redisRedis connection factory

Conditional Beans

Create beans only when specific conditions met.

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class NotificationConfig {

    // CONDITIONAL ON PROPERTY
    @Bean
    @ConditionalOnProperty(name = "notification.email.enabled", havingValue = "true")
    public NotificationService emailService() {
        return new EmailNotificationService();  // => Created only if property = true
    }

    // CONDITIONAL ON CLASS PRESENCE
    @Bean
    @ConditionalOnClass(name = "com.twilio.Twilio")  // => Check if Twilio SDK on classpath
    public NotificationService smsService() {
        return new SmsNotificationService();  // => Created only if Twilio available
    }

    // CONDITIONAL ON MISSING BEAN (fallback)
    @Bean
    @ConditionalOnMissingBean(NotificationService.class)
    public NotificationService defaultService() {
        return new ConsoleNotificationService();  // => Created only if no other NotificationService
    }
}

Common conditional annotations:

AnnotationBean created when…
@ConditionalOnPropertyProperty has specific value
@ConditionalOnClassClass present on classpath
@ConditionalOnMissingBeanNo bean of type exists
@ConditionalOnBeanBean of type exists
@ConditionalOnExpressionSpEL expression evaluates to true

Custom Auto-Configuration

Create reusable auto-configuration for libraries.

import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

// CONFIGURATION PROPERTIES
@ConfigurationProperties(prefix = "mylib")
public class MyLibraryProperties {
    private String apiKey;      // => mylib.api-key
    private int timeout = 30;   // => mylib.timeout (default: 30)

    // Getters and setters
}

// AUTO-CONFIGURATION CLASS
@Configuration
@ConditionalOnClass(MyLibraryClient.class)  // => Only if library on classpath
@EnableConfigurationProperties(MyLibraryProperties.class)
public class MyLibraryAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public MyLibraryClient myLibraryClient(MyLibraryProperties properties) {
        // => Create client from properties
        return new MyLibraryClient(properties.getApiKey(), properties.getTimeout());
    }
}

// Register in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
// com.example.MyLibraryAutoConfiguration

application.properties/yaml Configuration

Configure beans through external properties.

# application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: user
    password: pass
  jpa:
    hibernate:
      ddl-auto: validate

notification:
  email:
    enabled: true
    from: noreply@example.com
  sms:
    enabled: false

mylib:
  api-key: abc123
  timeout: 60

Inject properties into beans:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class EmailService {
    private final String fromAddress;

    public EmailService(@Value("${notification.email.from}") String fromAddress) {
        this.fromAddress = fromAddress;  // => Injected from application.yml
    }

    public void sendEmail(String to, String message) {
        System.out.println("From: " + fromAddress);  // => noreply@example.com
        System.out.println("To: " + to);
        System.out.println("Message: " + message);
    }
}

CDI (Contexts and Dependency Injection - Jakarta EE)

CDI is the Jakarta EE standard for dependency injection, providing similar features to Spring but following Java EE specifications.

Scopes (@ApplicationScoped, @RequestScoped, @SessionScoped)

Control bean lifecycle with CDI scopes.

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.RequestScoped;
import jakarta.enterprise.context.SessionScoped;
import jakarta.inject.Inject;
import java.io.Serializable;

// APPLICATION SCOPE: One instance per application
@ApplicationScoped
public class ConfigurationService {
    // Singleton behavior
}

// REQUEST SCOPE: One instance per HTTP request
@RequestScoped
public class RequestLogger {
    private long startTime;

    @PostConstruct
    public void init() {
        startTime = System.currentTimeMillis();  // => Track request start
    }

    @PreDestroy
    public void cleanup() {
        long duration = System.currentTimeMillis() - startTime;
        System.out.println("Request took " + duration + "ms");
    }
}

// SESSION SCOPE: One instance per HTTP session
@SessionScoped
public class ShoppingCart implements Serializable {  // => Must be serializable
    private List<Item> items = new ArrayList<>();

    public void addItem(Item item) {
        items.add(item);  // => Persists across requests in same session
    }
}

@Produces for Factory Methods

Create beans programmatically with producer methods.

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import javax.sql.DataSource;

@ApplicationScoped
public class DatabaseProducer {

    @Produces
    @ApplicationScoped
    public DataSource createDataSource() {
        // => Produce DataSource bean
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
        config.setUsername("user");
        config.setPassword("pass");
        return new HikariDataSource(config);
    }

    @Produces
    @RequestScoped
    public EntityManager createEntityManager(EntityManagerFactory emf) {
        // => Produce EntityManager per request
        return emf.createEntityManager();
    }
}

// INJECT PRODUCED BEANS
@ApplicationScoped
public class UserRepository {
    @Inject
    private DataSource dataSource;  // => Injected from producer

    @Inject
    private EntityManager entityManager;  // => Injected from producer
}

Interceptors

Add cross-cutting concerns (logging, transactions) with interceptors.

import jakarta.interceptor.InterceptorBinding;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.InvocationContext;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.*;

// DEFINE INTERCEPTOR BINDING
@InterceptorBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Logged {
}

// IMPLEMENT INTERCEPTOR
@Logged
@Interceptor
@Priority(1000)
public class LoggingInterceptor {

    @AroundInvoke
    public Object logMethodCall(InvocationContext context) throws Exception {
        String methodName = context.getMethod().getName();
        System.out.println("Calling: " + methodName);  // => Before method

        Object result = context.proceed();  // => Execute actual method

        System.out.println("Completed: " + methodName);  // => After method
        return result;
    }
}

// APPLY INTERCEPTOR
@ApplicationScoped
public class UserService {

    @Logged  // => LoggingInterceptor wraps this method
    public void createUser(String email, String name) {
        System.out.println("Creating user: " + name);
    }
}

// OUTPUT when createUser called:
// Calling: createUser
// Creating user: John
// Completed: createUser

CDI vs Spring Comparison

FeatureSpringCDI
StandardFramework-specificJakarta EE standard
Injection@Autowired or @Inject@Inject
Component marking@Component, @Service@Named or none (beans.xml)
Scopes@Scope, @RequestScope@ApplicationScoped, @RequestScoped
Producers@Bean in @Configuration@Produces
Qualifiers@Qualifier@Qualifier
Interceptors@Aspect (AOP)@Interceptor
EcosystemSpring Boot, Spring CloudJakarta EE servers
ServerEmbedded (Tomcat, Jetty)Application server (WildFly, etc.)

When to use:

  • Spring: Microservices, Spring ecosystem, Spring Boot convenience
  • CDI: Jakarta EE applications, application servers, standards compliance

Testing with Dependency Injection

Dependency injection makes testing easier by enabling mock injection.

Constructor Injection Benefits for Testing

Constructor injection enables testing without frameworks.

// PRODUCTION CODE
public class UserService {
    private final UserRepository repository;
    private final NotificationService notification;

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

    public void createUser(String email, String name) {
        User user = new User(email, name);
        repository.save(user);
        notification.send(email, "Welcome!");
    }
}

// UNIT TEST (no Spring needed)
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;

class UserServiceTest {

    @Test
    void testCreateUser() {
        // => Create mocks
        UserRepository mockRepo = mock(UserRepository.class);
        NotificationService mockNotif = mock(NotificationService.class);

        // => Inject mocks via constructor (no DI framework needed)
        UserService service = new UserService(mockRepo, mockNotif);

        // => Test
        service.createUser("test@example.com", "John");

        // => Verify interactions
        verify(mockRepo).save(any(User.class));
        verify(mockNotif).send(eq("test@example.com"), eq("Welcome!"));
    }
}

Mocking Dependencies (Mockito)

Use Mockito to create test doubles.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

class OrderServiceTest {

    @Mock  // => Mockito creates mock
    private PaymentService paymentService;

    @Mock
    private InventoryService inventoryService;

    private OrderService orderService;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);  // => Initialize mocks

        // => Inject mocks
        orderService = new OrderService(paymentService, inventoryService);
    }

    @Test
    void testProcessOrder() {
        Order order = new Order("123", BigDecimal.valueOf(100));

        // => Configure mock behavior
        when(inventoryService.checkStock(order.getId())).thenReturn(true);
        when(paymentService.charge(order.getAmount())).thenReturn(true);

        // => Test
        boolean result = orderService.processOrder(order);

        // => Assertions
        assertTrue(result);
        verify(inventoryService).checkStock("123");
        verify(paymentService).charge(BigDecimal.valueOf(100));
        verify(inventoryService).reserveStock("123");
    }

    @Test
    void testProcessOrderInsufficientStock() {
        Order order = new Order("123", BigDecimal.valueOf(100));

        // => Configure mock to return false
        when(inventoryService.checkStock(order.getId())).thenReturn(false);

        // => Test
        boolean result = orderService.processOrder(order);

        // => Assertions
        assertFalse(result);
        verify(inventoryService).checkStock("123");
        verify(paymentService, never()).charge(any());  // => Payment should not be called
    }
}

Spring Test Support

Test Spring components with Spring Test framework.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest  // => Load Spring application context
class UserServiceIntegrationTest {

    @Autowired  // => Inject real UserService bean
    private UserService userService;

    @MockBean  // => Replace UserRepository bean with mock
    private UserRepository userRepository;

    @MockBean  // => Replace NotificationService bean with mock
    private NotificationService notificationService;

    @Test
    void testCreateUser() {
        // => Configure mock behavior
        when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.empty());

        // => Test with real UserService, mocked dependencies
        userService.createUser("test@example.com", "John");

        // => Verify
        verify(userRepository).save(any(User.class));
        verify(notificationService).send("test@example.com", "Welcome!");
    }
}

@SpringBootTest features:

  • Loads full application context
  • Real bean wiring
  • @MockBean replaces specific beans with mocks
  • Integration testing with partial mocking

Best Practices

Prefer Constructor Injection Over Field Injection

Constructor injection provides immutability, testability, and explicit dependencies.

Avoid field injection:

// BAD: Field injection
@Service
public class UserService {
    @Autowired  // => Mutable, hard to test
    private UserRepository repository;

    // Cannot create UserService without Spring
}

Use constructor injection:

// GOOD: Constructor injection
@Service
public class UserService {
    private final UserRepository repository;  // => Immutable

    public UserService(UserRepository repository) {  // => Easy to test
        this.repository = repository;
    }

    // Can create UserService(mockRepo) in tests
}

Avoid Field Injection

Field injection makes testing harder and hides dependencies.

Problems:

  • Cannot create object without reflection
  • Cannot enforce required dependencies
  • Mutable fields (not final)
  • Hidden dependencies (not in constructor signature)

Use Interfaces for Dependencies

Program to interfaces for flexibility and testability.

// INTERFACE
public interface PaymentProcessor {
    boolean process(Order order);
}

// IMPLEMENTATIONS
@Component
public class StripePaymentProcessor implements PaymentProcessor { /* ... */ }

@Component
public class PayPalPaymentProcessor implements PaymentProcessor { /* ... */ }

// CLIENT DEPENDS ON INTERFACE
@Service
public class OrderService {
    private final PaymentProcessor paymentProcessor;  // => Interface, not concrete class

    public OrderService(PaymentProcessor paymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }
}

Benefits:

  • Swap implementations without changing OrderService
  • Test with mock implementations
  • Multiple payment processors possible

Minimize Circular Dependencies

Circular dependencies indicate design problems.

Example circular dependency:

// BAD: A → B → A
@Service
public class UserService {
    @Autowired
    private AuditService auditService;  // => UserService depends on AuditService

    public void createUser(User user) {
        auditService.log("Creating user");  // => Calls AuditService
    }
}

@Service
public class AuditService {
    @Autowired
    private UserService userService;  // => AuditService depends on UserService

    public void log(String message) {
        User currentUser = userService.getCurrentUser();  // => Calls UserService
        // CIRCULAR: UserService → AuditService → UserService
    }
}

Solution: Introduce event or extract shared dependency:

// GOOD: Break cycle with event
@Service
public class UserService {
    @Autowired
    private ApplicationEventPublisher eventPublisher;

    public void createUser(User user) {
        eventPublisher.publishEvent(new UserCreatedEvent(user));  // => Fire event
    }
}

@Service
public class AuditService {
    @EventListener
    public void onUserCreated(UserCreatedEvent event) {
        // => Listen for event, no direct UserService dependency
        log("User created: " + event.getUser().getName());
    }
}

Document Bean Scopes

Clearly document expected lifecycle behavior.

/**
 * Manages shopping cart state for a user session.
 * Scope: SESSION - One instance per HTTP session, maintains state across requests.
 * Thread-safety: Not thread-safe, relies on session isolation.
 */
@Component
@SessionScope
public class ShoppingCart {
    private List<Item> items = new ArrayList<>();

    public void addItem(Item item) {
        items.add(item);
    }
}

/**
 * Configuration service holding application settings.
 * Scope: SINGLETON - One shared instance across entire application.
 * Thread-safety: Immutable after initialization, thread-safe for reading.
 */
@Component
@Singleton
public class ConfigurationService {
    private final Map<String, String> config;

    public ConfigurationService() {
        this.config = loadConfiguration();
    }
}

Conclusion

Dependency injection enables testable, flexible, maintainable architectures:

  • Manual DI: Understand fundamental patterns (constructor injection, interfaces, factories)
  • JSR-330: Standard annotations work across frameworks (@Inject, @Qualifier, @Singleton)
  • Spring: Comprehensive DI with @Component, @Autowired, @Profile, and rich ecosystem
  • Spring Boot: Auto-configuration eliminates boilerplate (@SpringBootApplication, conditionals)
  • CDI: Jakarta EE standard with @Produces, interceptors, and application server integration

Key principles:

  • Prefer constructor injection (immutability, testability, clarity)
  • Program to interfaces (flexibility, testability)
  • Start simple (manual DI), add frameworks when complexity warrants
  • Test with mocks injected via constructors
  • Document bean scopes and lifecycle expectations

Begin with constructor injection and interfaces using standard library. Introduce Spring when managing multiple dependencies becomes cumbersome. Use JSR-330 annotations for portability across frameworks.

Related content:

Last updated