Java Dependency Injection Explained

Dependency Injection (DI) is a design pattern where objects receive their dependencies from external sources rather than creating them internally. Instead of a class instantiating its own collaborators, something else provides them. This seemingly simple change makes code easier to test, maintain, and modify.

This guide explains DI concepts, shows manual implementation, and covers how Spring handles DI automatically.

The Problem DI Solves

Consider this code:

public class OrderService {
    private EmailService emailService = new EmailService();
    private PaymentGateway paymentGateway = new StripePaymentGateway();
    private OrderRepository orderRepository = new MySqlOrderRepository();
    
    public Order processOrder(OrderRequest request) {
        // Process using the dependencies
        Order order = orderRepository.save(new Order(request));
        paymentGateway.charge(request.getPaymentInfo());
        emailService.sendConfirmation(order);
        return order;
    }
}

This works, but creates problems:

Hard to test: Unit tests can’t substitute mock implementations. Testing processOrder() requires a real database, payment gateway, and email server.

Rigid coupling: Switching from Stripe to PayPal requires changing OrderService code. The class knows too much about its dependencies.

Hidden dependencies: Looking at the class signature, you can’t tell what OrderService needs. Dependencies are buried in the implementation.

DI Solution

With dependency injection, the class declares what it needs and receives it from outside:

public class OrderService {
    private final EmailService emailService;
    private final PaymentGateway paymentGateway;
    private final OrderRepository orderRepository;
    
    // Dependencies injected through constructor
    public OrderService(EmailService emailService, 
                        PaymentGateway paymentGateway,
                        OrderRepository orderRepository) {
        this.emailService = emailService;
        this.paymentGateway = paymentGateway;
        this.orderRepository = orderRepository;
    }
    
    public Order processOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        paymentGateway.charge(request.getPaymentInfo());
        emailService.sendConfirmation(order);
        return order;
    }
}

Now the benefits are clear:

Testable: Pass mock implementations in tests. No real database or payment gateway needed.

Flexible: Switch PaymentGateway implementations without touching OrderService.

Explicit: The constructor signature shows exactly what OrderService needs.

Types of Dependency Injection

Constructor Injection (Preferred)

Dependencies passed through the constructor. The object can’t exist without its dependencies.

public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    
    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }
}

Advantages:

  • Dependencies are required and immutable (final fields)
  • Object is fully initialized after construction
  • Easy to see all dependencies at a glance
  • Works with immutable objects

Setter Injection

Dependencies passed through setter methods. Allows optional dependencies and reconfiguration.

public class ReportGenerator {
    private DataSource dataSource;
    private ReportFormatter formatter;
    
    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    public void setFormatter(ReportFormatter formatter) {
        this.formatter = formatter;
    }
}

Use when:

  • Dependencies are optional
  • Dependencies might change after construction
  • Circular dependencies exist (though these should be avoided)

Field Injection (Avoid)

Dependencies injected directly into fields, typically with annotations:

public class ProductService {
    @Autowired
    private ProductRepository productRepository;  // Not recommended
    
    @Autowired
    private PricingService pricingService;  // Not recommended
}

Problems with field injection:

  • Can’t make fields final (not truly immutable)
  • Harder to test without reflection or a DI container
  • Hides dependencies from the class signature
  • Allows too many dependencies without obvious code smell

Manual DI Implementation

You don’t need a framework for DI. Here’s manual wiring:

// Interfaces define contracts
public interface UserRepository {
    User findById(Long id);
    User save(User user);
}

public interface EmailService {
    void sendEmail(String to, String subject, String body);
}

// Implementations
public class JpaUserRepository implements UserRepository {
    private final EntityManager entityManager;
    
    public JpaUserRepository(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
    
    @Override
    public User findById(Long id) {
        return entityManager.find(User.class, id);
    }
    
    @Override
    public User save(User user) {
        entityManager.persist(user);
        return user;
    }
}

public class SmtpEmailService implements EmailService {
    private final String smtpHost;
    
    public SmtpEmailService(String smtpHost) {
        this.smtpHost = smtpHost;
    }
    
    @Override
    public void sendEmail(String to, String subject, String body) {
        // Send via SMTP
    }
}

// Service depends on interfaces, not implementations
public class UserService {
    private final UserRepository userRepository;
    private final EmailService emailService;
    
    public UserService(UserRepository userRepository, EmailService emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }
    
    public User createUser(String name, String email) {
        User user = new User(name, email);
        User saved = userRepository.save(user);
        emailService.sendEmail(email, "Welcome!", "Thanks for signing up.");
        return saved;
    }
}

// Composition root - wire everything together
public class Application {
    public static void main(String[] args) {
        // Create dependencies
        EntityManager em = createEntityManager();
        UserRepository userRepository = new JpaUserRepository(em);
        EmailService emailService = new SmtpEmailService("smtp.example.com");
        
        // Inject into service
        UserService userService = new UserService(userRepository, emailService);
        
        // Use the service
        userService.createUser("Alice", "alice@example.com");
    }
}

Testing with DI

DI makes testing straightforward. Create test implementations or mocks:

// Test doubles
public class FakeUserRepository implements UserRepository {
    private Map<Long, User> users = new HashMap<>();
    private long nextId = 1;
    
    @Override
    public User findById(Long id) {
        return users.get(id);
    }
    
    @Override
    public User save(User user) {
        user.setId(nextId++);
        users.put(user.getId(), user);
        return user;
    }
}

public class FakeEmailService implements EmailService {
    public List<String> sentEmails = new ArrayList<>();
    
    @Override
    public void sendEmail(String to, String subject, String body) {
        sentEmails.add(to + ": " + subject);
    }
}

// Unit test
class UserServiceTest {
    
    @Test
    void createUserSendsWelcomeEmail() {
        // Arrange - inject test doubles
        FakeUserRepository userRepo = new FakeUserRepository();
        FakeEmailService emailService = new FakeEmailService();
        UserService userService = new UserService(userRepo, emailService);
        
        // Act
        User user = userService.createUser("Alice", "alice@example.com");
        
        // Assert
        assertNotNull(user.getId());
        assertEquals(1, emailService.sentEmails.size());
        assertTrue(emailService.sentEmails.get(0).contains("alice@example.com"));
    }
}

With Mockito:

@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @Mock
    private EmailService emailService;
    
    private UserService userService;
    
    @BeforeEach
    void setup() {
        userService = new UserService(userRepository, emailService);
    }
    
    @Test
    void createUserSavesToRepository() {
        // Arrange
        when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
            User user = invocation.getArgument(0);
            user.setId(1L);
            return user;
        });
        
        // Act
        User user = userService.createUser("Alice", "alice@example.com");
        
        // Assert
        verify(userRepository).save(any(User.class));
        verify(emailService).sendEmail(eq("alice@example.com"), anyString(), anyString());
    }
}

Spring Dependency Injection

Spring automates DI through its IoC (Inversion of Control) container. You declare beans, Spring wires them together.

Declaring Beans

// @Component and its specializations mark classes as Spring beans
@Repository
public class JpaUserRepository implements UserRepository {
    // Spring creates and manages this instance
}

@Service
public class SmtpEmailService implements EmailService {
    // Spring creates and manages this instance
}

@Service
public class UserService {
    private final UserRepository userRepository;
    private final EmailService emailService;
    
    // Spring automatically injects dependencies
    // @Autowired is optional on single constructor (Spring 4.3+)
    public UserService(UserRepository userRepository, EmailService emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }
}

Bean Stereotypes

Annotation Purpose
@Component Generic Spring-managed component
@Service Business logic layer
@Repository Data access layer (adds exception translation)
@Controller Web layer (MVC controllers)
@RestController REST API controllers

Configuration Classes

For more control, define beans in configuration classes:

@Configuration
public class AppConfig {
    
    @Bean
    public DataSource dataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:postgresql://localhost/mydb");
        ds.setUsername("user");
        ds.setPassword("password");
        return ds;
    }
    
    @Bean
    public EmailService emailService(@Value("${smtp.host}") String smtpHost) {
        return new SmtpEmailService(smtpHost);
    }
}

Qualifier for Multiple Implementations

When multiple beans implement the same interface:

@Service
@Qualifier("smtp")
public class SmtpEmailService implements EmailService { }

@Service
@Qualifier("sendgrid")
public class SendGridEmailService implements EmailService { }

@Service
public class NotificationService {
    private final EmailService emailService;
    
    public NotificationService(@Qualifier("sendgrid") EmailService emailService) {
        this.emailService = emailService;
    }
}

Or use @Primary to set a default:

@Service
@Primary
public class SmtpEmailService implements EmailService { }

@Service
public class SendGridEmailService implements EmailService { }

Bean Scopes

@Service
@Scope("singleton")  // Default - one instance per container
public class UserService { }

@Component
@Scope("prototype")  // New instance each time requested
public class RequestHandler { }

@Component
@Scope("request")  // One instance per HTTP request (web apps)
public class RequestContext { }

@Component
@Scope("session")  // One instance per HTTP session (web apps)
public class UserSession { }

DI Best Practices

Prefer Constructor Injection

// Good
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    
    public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }
}

// Avoid
@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private PaymentService paymentService;
}

Depend on Interfaces

// Good - depends on interface
public class ReportService {
    private final DataExporter exporter;
    
    public ReportService(DataExporter exporter) {
        this.exporter = exporter;
    }
}

// Avoid - depends on concrete class
public class ReportService {
    private final CsvExporter exporter;
    
    public ReportService(CsvExporter exporter) {
        this.exporter = exporter;
    }
}

Keep Constructors Simple

// Good - just assignment
public UserService(UserRepository userRepository) {
    this.userRepository = userRepository;
}

// Avoid - complex logic in constructor
public UserService(UserRepository userRepository) {
    this.userRepository = userRepository;
    this.cache = loadInitialCache();  // Don't do this
    connectToExternalService();        // Or this
}

Limit Dependencies

If a class has many dependencies (more than 4-5), it may be doing too much:

// Code smell - too many dependencies
public class GodService {
    public GodService(
        UserRepository userRepo,
        OrderRepository orderRepo,
        ProductRepository productRepo,
        EmailService emailService,
        SmsService smsService,
        PaymentGateway paymentGateway,
        ShippingService shippingService,
        AnalyticsService analyticsService
    ) { }
}

// Better - split into focused classes
public class OrderProcessor {
    public OrderProcessor(
        OrderRepository orderRepo,
        PaymentGateway paymentGateway,
        ShippingService shippingService
    ) { }
}

Common Pitfalls

Circular Dependencies

// A depends on B, B depends on A - won't work with constructor injection
@Service
public class ServiceA {
    public ServiceA(ServiceB serviceB) { }
}

@Service
public class ServiceB {
    public ServiceB(ServiceA serviceA) { }
}

Solutions:

  • Refactor to eliminate the cycle (best)
  • Extract shared logic to a third class
  • Use setter injection for one dependency (workaround)
  • Use @Lazy to delay initialization (workaround)

Service Locator Anti-Pattern

// Anti-pattern - hides dependencies
public class OrderService {
    public void processOrder(Order order) {
        PaymentService paymentService = ServiceLocator.get(PaymentService.class);
        paymentService.charge(order);
    }
}

// Better - explicit dependency
public class OrderService {
    private final PaymentService paymentService;
    
    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

Summary

Dependency Injection inverts the flow of control. Instead of classes creating their dependencies, dependencies are provided from outside. This makes code more testable, flexible, and maintainable.

Use constructor injection as the default. Depend on interfaces rather than concrete classes. Let Spring manage the wiring in production, but understand that DI works without any framework.

When testing, inject mocks or fakes. When requirements change, swap implementations without modifying dependent classes. That’s the power of DI.


Prerequisites: Java Interfaces | Introduction to Object-Oriented Programming

Related: Introduction to Spring Boot | Java Design Patterns | Java Unit Testing with JUnit

Sources

  • Fowler, Martin. “Inversion of Control Containers and the Dependency Injection pattern.” martinfowler.com/articles/injection.html
  • Spring Framework. “Core Technologies.” docs.spring.io/spring-framework/reference/core.html
Scroll to Top