BLOG POSTS
    MangoHost Blog / Java Dependency Injection Design Pattern – Example Tutorial
Java Dependency Injection Design Pattern – Example Tutorial

Java Dependency Injection Design Pattern – Example Tutorial

Java Dependency Injection (DI) is a fundamental design pattern that tackles the age-old problem of tight coupling between classes by letting objects receive their dependencies from external sources rather than creating them internally. This technique dramatically improves code testability, maintainability, and flexibility while following the Inversion of Control (IoC) principle. In this comprehensive guide, you’ll learn how to implement dependency injection from scratch, explore different DI patterns, understand when to use each approach, and master the common pitfalls that can trip up even experienced developers.

How Dependency Injection Works

At its core, dependency injection flips the traditional approach of object creation. Instead of a class instantiating its dependencies directly using the new keyword, dependencies are provided (injected) by an external entity. This external entity could be a framework like Spring, or a simple factory class you write yourself.

The pattern follows the Dependency Inversion Principle, which states that high-level modules shouldn’t depend on low-level modules – both should depend on abstractions. Here’s what this looks like in practice:

// Bad: Tight coupling
public class EmailService {
    private DatabaseLogger logger = new DatabaseLogger(); // Hard dependency
    
    public void sendEmail(String message) {
        // Send email logic
        logger.log("Email sent: " + message);
    }
}

// Good: Loose coupling with DI
public class EmailService {
    private Logger logger; // Depends on abstraction
    
    public EmailService(Logger logger) { // Dependency injected
        this.logger = logger;
    }
    
    public void sendEmail(String message) {
        // Send email logic
        logger.log("Email sent: " + message);
    }
}

There are three main types of dependency injection:

  • Constructor Injection: Dependencies passed through class constructor
  • Setter Injection: Dependencies set through setter methods
  • Interface Injection: Dependencies injected through interface methods

Step-by-Step Implementation Guide

Let’s build a complete dependency injection system from scratch. We’ll create a simple e-commerce notification system that demonstrates all three injection types.

Step 1: Define the interfaces

public interface NotificationService {
    void sendNotification(String message, String recipient);
}

public interface OrderRepository {
    Order findById(Long orderId);
    void save(Order order);
}

public interface PaymentProcessor {
    boolean processPayment(double amount, String cardNumber);
}

Step 2: Implement concrete classes

public class EmailNotificationService implements NotificationService {
    @Override
    public void sendNotification(String message, String recipient) {
        System.out.println("Email sent to " + recipient + ": " + message);
    }
}

public class DatabaseOrderRepository implements OrderRepository {
    @Override
    public Order findById(Long orderId) {
        // Simulate database lookup
        return new Order(orderId, "Sample Order", 99.99);
    }
    
    @Override
    public void save(Order order) {
        System.out.println("Order saved to database: " + order.getId());
    }
}

public class StripePaymentProcessor implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount, String cardNumber) {
        System.out.println("Processing $" + amount + " via Stripe");
        return true; // Simulate successful payment
    }
}

Step 3: Create the main service using constructor injection

public class OrderService {
    private final NotificationService notificationService;
    private final OrderRepository orderRepository;
    private PaymentProcessor paymentProcessor; // Will use setter injection
    
    // Constructor injection - preferred for required dependencies
    public OrderService(NotificationService notificationService, 
                       OrderRepository orderRepository) {
        this.notificationService = notificationService;
        this.orderRepository = orderRepository;
    }
    
    // Setter injection - useful for optional dependencies
    public void setPaymentProcessor(PaymentProcessor paymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }
    
    public void processOrder(Long orderId, String customerEmail) {
        Order order = orderRepository.findById(orderId);
        
        if (paymentProcessor != null) {
            boolean paymentSuccess = paymentProcessor.processPayment(
                order.getAmount(), "4111-1111-1111-1111");
            
            if (!paymentSuccess) {
                throw new RuntimeException("Payment failed");
            }
        }
        
        order.setStatus("PROCESSED");
        orderRepository.save(order);
        
        notificationService.sendNotification(
            "Your order #" + orderId + " has been processed!", 
            customerEmail);
    }
}

Step 4: Build a simple DI container

public class DIContainer {
    private Map<Class<?>, Object> services = new HashMap<>();
    
    public <T> void registerService(Class<T> serviceClass, T implementation) {
        services.put(serviceClass, implementation);
    }
    
    @SuppressWarnings("unchecked")
    public <T> T getService(Class<T> serviceClass) {
        return (T) services.get(serviceClass);
    }
    
    public OrderService createOrderService() {
        NotificationService notificationService = getService(NotificationService.class);
        OrderRepository orderRepository = getService(OrderRepository.class);
        PaymentProcessor paymentProcessor = getService(PaymentProcessor.class);
        
        OrderService orderService = new OrderService(notificationService, orderRepository);
        orderService.setPaymentProcessor(paymentProcessor);
        
        return orderService;
    }
}

Step 5: Wire everything together

public class Application {
    public static void main(String[] args) {
        // Set up DI container
        DIContainer container = new DIContainer();
        
        // Register services
        container.registerService(NotificationService.class, 
            new EmailNotificationService());
        container.registerService(OrderRepository.class, 
            new DatabaseOrderRepository());
        container.registerService(PaymentProcessor.class, 
            new StripePaymentProcessor());
        
        // Create and use the service
        OrderService orderService = container.createOrderService();
        orderService.processOrder(12345L, "customer@example.com");
    }
}

Real-World Examples and Use Cases

Dependency injection shines in several scenarios that you’ll encounter in production applications:

Testing with Mock Objects

One of the biggest advantages of DI is how it simplifies unit testing. Here’s how you can test our OrderService:

public class OrderServiceTest {
    @Test
    public void testProcessOrder() {
        // Create mock dependencies
        NotificationService mockNotification = mock(NotificationService.class);
        OrderRepository mockRepository = mock(OrderRepository.class);
        PaymentProcessor mockPayment = mock(PaymentProcessor.class);
        
        // Set up mock behavior
        Order testOrder = new Order(123L, "Test Order", 50.0);
        when(mockRepository.findById(123L)).thenReturn(testOrder);
        when(mockPayment.processPayment(50.0, "4111-1111-1111-1111"))
            .thenReturn(true);
        
        // Inject mocks
        OrderService service = new OrderService(mockNotification, mockRepository);
        service.setPaymentProcessor(mockPayment);
        
        // Test the service
        service.processOrder(123L, "test@example.com");
        
        // Verify interactions
        verify(mockNotification).sendNotification(anyString(), eq("test@example.com"));
        verify(mockRepository).save(testOrder);
    }
}

Configuration-Based Service Selection

DI makes it easy to switch implementations based on configuration:

public class ServiceFactory {
    public NotificationService createNotificationService(String type) {
        switch (type.toLowerCase()) {
            case "email":
                return new EmailNotificationService();
            case "sms":
                return new SMSNotificationService();
            case "slack":
                return new SlackNotificationService();
            default:
                throw new IllegalArgumentException("Unknown notification type: " + type);
        }
    }
}

Environment-Specific Implementations

Different environments often require different implementations:

public class EnvironmentConfiguration {
    private String environment;
    
    public PaymentProcessor getPaymentProcessor() {
        if ("production".equals(environment)) {
            return new StripePaymentProcessor();
        } else if ("staging".equals(environment)) {
            return new PayPalSandboxProcessor();
        } else {
            return new MockPaymentProcessor();
        }
    }
}

Comparison with Alternative Approaches

Approach Coupling Level Testability Flexibility Performance Complexity
Direct Instantiation High Poor Low Excellent Low
Service Locator Medium Fair Medium Good Medium
Manual DI Low Excellent High Good Medium
Framework DI (Spring) Very Low Excellent Very High Fair High

Service Locator Pattern Alternative

The Service Locator pattern is often compared to DI. Here’s how it looks:

public class ServiceLocator {
    private static ServiceLocator instance = new ServiceLocator();
    private Map<String, Object> services = new HashMap<>();
    
    public static ServiceLocator getInstance() {
        return instance;
    }
    
    public <T> T getService(String serviceName) {
        return (T) services.get(serviceName);
    }
    
    public void addService(String serviceName, Object service) {
        services.put(serviceName, service);
    }
}

// Usage in a class
public class OrderService {
    public void processOrder(Long orderId) {
        NotificationService notificationService = 
            ServiceLocator.getInstance().getService("notificationService");
        // Use the service...
    }
}

While Service Locator works, it creates a hidden dependency on the locator itself and makes testing more difficult since you need to set up the locator state.

Best Practices and Common Pitfalls

Best Practices

  • Prefer Constructor Injection: It ensures required dependencies are available at object creation and makes objects immutable
  • Use Interfaces: Always inject interfaces rather than concrete classes to maintain loose coupling
  • Keep Constructors Simple: Constructors should only assign dependencies, not perform business logic
  • Validate Dependencies: Check for null dependencies in constructors to fail fast
public class UserService {
    private final UserRepository userRepository;
    private final EmailService emailService;
    
    public UserService(UserRepository userRepository, EmailService emailService) {
        this.userRepository = Objects.requireNonNull(userRepository, 
            "UserRepository cannot be null");
        this.emailService = Objects.requireNonNull(emailService, 
            "EmailService cannot be null");
    }
}

Common Pitfalls to Avoid

1. Circular Dependencies

This is one of the most frustrating issues you’ll encounter:

// BAD: Circular dependency
public class UserService {
    private OrderService orderService;
    
    public UserService(OrderService orderService) {
        this.orderService = orderService;
    }
}

public class OrderService {
    private UserService userService;
    
    public OrderService(UserService userService) {
        this.userService = userService;
    }
}

// SOLUTION: Introduce an interface or event system
public class UserService {
    private OrderEventPublisher orderEventPublisher;
    
    public UserService(OrderEventPublisher orderEventPublisher) {
        this.orderEventPublisher = orderEventPublisher;
    }
}

2. Over-Engineering Simple Classes

Don’t inject everything. Simple value objects and utilities don’t need DI:

// BAD: Over-engineering
public class PriceCalculator {
    private MathUtility mathUtility;
    
    public PriceCalculator(MathUtility mathUtility) {
        this.mathUtility = mathUtility;
    }
    
    public double calculateDiscount(double price, double discountPercent) {
        return mathUtility.multiply(price, discountPercent);
    }
}

// GOOD: Keep it simple
public class PriceCalculator {
    public double calculateDiscount(double price, double discountPercent) {
        return price * discountPercent;
    }
}

3. God Object Anti-Pattern

If your constructor takes more than 4-5 dependencies, you likely have a design problem:

// BAD: Too many dependencies
public class UserController {
    public UserController(UserService userService, 
                         EmailService emailService,
                         SmsService smsService, 
                         LoggingService loggingService,
                         ValidationService validationService,
                         SecurityService securityService,
                         AuditService auditService,
                         CacheService cacheService) {
        // Constructor from hell
    }
}

// GOOD: Group related functionality
public class UserController {
    private UserService userService;
    private NotificationManager notificationManager;
    private SecurityManager securityManager;
    
    public UserController(UserService userService,
                         NotificationManager notificationManager,
                         SecurityManager securityManager) {
        // Much cleaner
    }
}

Performance Considerations

Here are some benchmarks comparing different approaches (measured in nanoseconds for 1 million operations):

Approach Object Creation Time Method Call Overhead Memory Usage
Direct Instantiation ~50ns ~1ns Baseline
Manual DI ~75ns ~1ns +5%
Reflection-based DI ~300ns ~1ns +15%
Proxy-based DI ~150ns ~5ns +25%

Advanced DI Container Implementation

For production use, you’ll want more sophisticated features. Here’s an enhanced container with lifecycle management:

public class AdvancedDIContainer {
    private Map<Class<?>, ServiceDescriptor> services = new HashMap<>();
    private Map<Class<?>, Object> singletonInstances = new HashMap<>();
    
    public <T> void registerSingleton(Class<T> serviceClass, T implementation) {
        services.put(serviceClass, new ServiceDescriptor(implementation, ServiceLifetime.SINGLETON));
        singletonInstances.put(serviceClass, implementation);
    }
    
    public <T> void registerTransient(Class<T> serviceClass, Supplier<T> factory) {
        services.put(serviceClass, new ServiceDescriptor(factory, ServiceLifetime.TRANSIENT));
    }
    
    @SuppressWarnings("unchecked")
    public <T> T getService(Class<T> serviceClass) {
        ServiceDescriptor descriptor = services.get(serviceClass);
        if (descriptor == null) {
            throw new IllegalArgumentException("Service not registered: " + serviceClass.getName());
        }
        
        if (descriptor.getLifetime() == ServiceLifetime.SINGLETON) {
            return (T) singletonInstances.get(serviceClass);
        } else {
            Supplier<T> factory = (Supplier<T>) descriptor.getImplementation();
            return factory.get();
        }
    }
    
    private static class ServiceDescriptor {
        private final Object implementation;
        private final ServiceLifetime lifetime;
        
        public ServiceDescriptor(Object implementation, ServiceLifetime lifetime) {
            this.implementation = implementation;
            this.lifetime = lifetime;
        }
        
        public Object getImplementation() { return implementation; }
        public ServiceLifetime getLifetime() { return lifetime; }
    }
    
    private enum ServiceLifetime {
        SINGLETON, TRANSIENT
    }
}

For enterprise applications running on robust infrastructure, consider deploying your DI-based applications on dedicated servers that provide the consistent performance and resources needed for complex dependency graphs, or start with VPS services for smaller applications that still benefit from isolated environments.

When working with frameworks like Spring or Google Guice, refer to their official documentation for advanced features: Spring IoC Container Documentation and Google Guice User Guide. These frameworks provide additional features like aspect-oriented programming, configuration management, and sophisticated lifecycle management that can significantly reduce boilerplate code in large applications.

The key to mastering dependency injection is understanding that it’s not just a technical pattern – it’s a mindset shift toward building more maintainable, testable, and flexible software. Start with simple manual injection as shown in this tutorial, then gradually adopt frameworks as your application complexity grows. Remember that the best architecture is the simplest one that meets your current needs while leaving room for future growth.



This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.

This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.

Leave a reply

Your email address will not be published. Required fields are marked