Managing Dependencies with Dependency Injection & Composition Patterns: A Practical Guide

In modern software development, managing dependencies is a critical part of building scalablemaintainable, and testable applications. By using design patterns like Dependency Injection (DI)Service Locator, and the Factory Pattern with DI, developers can achieve loose coupling and improve the overall architecture of their applications. These patterns help decouple classes from their dependencies, making it easier to manage and test code.

  • Dependency Injection (DI) — Injects dependencies rather than hardcoding them.
  • Service Locator — Retrieves services from a central registry.
  • Factory Pattern with DI — Uses factories to create instances within DI containers.

Dependency Injection (DI) — Injecting Dependencies, Not Hardcoding Them

Dependency Injection (DI) is one of the most widely used patterns for managing dependencies. It allows a class to receive its dependencies from external sources rather than creating them internally. This reduces coupling and makes the code more flexible and testable. By using DI, we can easily swap out implementations, making the system more extensible and easier to maintain.

Common Use Cases

  • Unit Testing: DI enables better testability by allowing mock or stub dependencies to be injected during testing. This promotes unit testing by making it easier to substitute real dependencies with mock objects.
  • Configurable Services: Use DI when you need to easily change or configure services without modifying the consuming classes. This improves flexibility by enabling runtime configuration of services.

SOLID Principles

  • Single Responsibility Principle (SRP): DI helps ensure classes are responsible for only one task, as they don’t have to manage their dependencies. This allows classes to focus on their primary function, delegating dependency creation to the DI container.
  • Open/Closed Principle (OCP): Classes can be extended without modification, as dependencies can be swapped out via DI. New implementations can be introduced without changing the class code, just by modifying the DI container.
  • Dependency Inversion Principle (DIP): High-level modules do not depend on low-level modules; both depend on abstractions. DI helps invert the control of dependency creation, making both high- and low-level classes depend on interfaces or abstractions.

Implementing Dependency Injection (DI)

You are developing a system where, when a customer makes a purchase or an order status changes, a notification email is sent to them. You want to use Dependency Injection (DI) to manage the services that handle sending email notifications. By doing so, you make the code more modulartestable, and maintainable. The system needs to be flexible enough to swap out the email service for a different implementation (e.g., for testing purposes, or to switch email providers), all while keeping the code decoupled from the actual email sending logic.

1️⃣Setting Up the DI Container

We configure the DI container by registering an interface IEmailService and its implementation EmailService. The BuildServiceProvider method creates a service provider that resolves these dependencies at runtime.

public class Program
{
public static void Main(string[] args)
{
// Step 1: Create the DI container
var services = new ServiceCollection();

// Registering the IEmailService and its implementation EmailService
services.AddTransient<IEmailService, EmailService>();

// Building the service provider
var serviceProvider = services.BuildServiceProvider();

// You can now resolve services from this container
}
}

This step ensures that EmailService can be injected wherever IEmailService is required, without directly instantiating it.

2️⃣Injecting Dependencies into Classes

The NotificationManager class receives an IEmailService instance through its constructor, instead of creating it internally.

public class NotificationManager
{
private readonly IEmailService _emailService;

public NotificationManager(IEmailService emailService)
{
_emailService = emailService;
}

public void SendNotification(string message)
{
_emailService.SendEmail(message);
}
}

The class is now decoupled from the concrete EmailService implementation, making it easier to test and extend.

3️⃣Resolving Dependencies at Runtime

We retrieve the NotificationManager from the service provider, which automatically resolves the dependencies (in this case, IEmailService).

var notificationManager = serviceProvider.GetService<NotificationManager>();
notificationManager.SendNotification("Hello, Dependency Injection!");

This step demonstrates how DI facilitates decoupling and makes it easy to manage dependencies in a central location.

Service Locator — Centralized Service Access

The Service Locator pattern provides a central registry to access services throughout an application. Unlike DI, where dependencies are passed to classes, the Service Locator pattern allows classes to retrieve their dependencies from a central service container. While convenient, it can introduce hidden dependencies and reduce testability, so it’s often used sparingly.

Common Use Cases

  • Legacy Systems: Use Service Locator when dealing with legacy systems that don’t have DI built-in. This simplifies access to services in existing systems without refactoring the entire codebase.
  • Global Service Access: When a service needs to be accessed globally within the application. This reduces the need for passing services around through constructors or methods.

SOLID Principles

  • Single Responsibility Principle (SRP): Service Locator centralizes the responsibility of service retrieval, so individual classes don’t have to manage it. It simplifies the management of services, focusing each class on its primary function.
  • Dependency Inversion Principle (DIP): In some cases, Service Locator can be seen as violating DIP, as high-level modules directly request dependencies from a registry. While convenient, using Service Locator may result in hidden dependencies that aren’t explicitly defined.

Implementing the Service Locator Pattern for Legacy E-Commerce Notification System

In this legacy e-commerce platform, we need to manage services like IEmailService for sending customer notifications (e.g., order confirmations). The application was not built with Dependency Injection, so using the Service Locator pattern allows us to centralize service access without a major refactor, making it easier to manage and retrieve services like email notifications across the system.

1️⃣Setting Up the Service Locator

We define a ServiceLocator class that registers and retrieves services by their type name.

public class ServiceLocator
{
private static readonly Dictionary<string, object> services = new();

public static void RegisterService<T>(T service)
{
services[typeof(T).Name] = service;
}

public static T GetService<T>()
{
return (T)services[typeof(T).Name];
}
}

This step shows how a service locator stores services and provides them globally to any class that needs them.

2️⃣Using the Service Locator

We retrieve an instance of IEmailService from the ServiceLocator.

var emailService = ServiceLocator.GetService<IEmailService>();
emailService.SendEmail("Using Service Locator!");

This step shows how the Service Locator pattern centralizes service access, reducing the need for dependency injection in every class.

The Service Locator pattern simplifies service management in a legacy e-commerce platform by centralizing access to services like IEmailService. It reduces the need to pass dependencies through constructors, making it easier to manage and retrieve services globally without extensive refactoring of the existing codebase.

Factory Pattern with DI — Creating Instances within DI Containers

The Factory Pattern with DI leverages the power of DI containers to create instances of classes while abstracting the instantiation logic. This pattern is particularly useful when the creation of objects involves complex logic or when you need to vary the creation process based on parameters.

Common Use Cases

  • Object Creation with Variations: Use Factory Pattern with DI when different configurations of an object are needed. This allows you to create different configurations of an object using the same class.
  • Complex Object Creation: When the creation process of an object requires multiple steps or dependencies. This simplifies the creation process by abstracting the logic into a factory class.

SOLID Principles

  • Open/Closed Principle (OCP): The Factory Pattern allows new types to be added without modifying existing code. You can add new types of objects to create, but the existing code remains unchanged.
  • Dependency Inversion Principle (DIP): The Factory Pattern helps adhere to DIP by delegating the responsibility of creating objects to a factory. High-level modules depend on abstractions (factories), not on concrete classes.

Implementing the Factory Pattern with Dependency Injection for E-Commerce Notifications

In this e-commerce platform, we need to send notifications (e.g., order updates) through different email providers. The Factory Pattern helps us create different email service instances based on configuration or runtime conditions, allowing flexible email handling without modifying the core logic every time a new provider is added.

1️⃣Defining a Factory

The factory class takes an IServiceProvider and uses it to resolve dependencies and create an instance of IEmailService.

public class EmailServiceFactory
{
private readonly IServiceProvider _serviceProvider;

public EmailServiceFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

public IEmailService Create()
{
return _serviceProvider.GetService<IEmailService>();
}
}

This step demonstrates how a factory abstracts the creation logic, allowing you to inject dependencies into objects that require it.

2️⃣Using the Factory to Create Instances

We use the factory to create an instance of IEmailService from the DI container.

var factory = serviceProvider.GetService<EmailServiceFactory>();
var emailService = factory.Create();
emailService.SendEmail("Using Factory with DI!");

This step shows how the factory pattern can work in conjunction with DI to create objects with complex dependencies while maintaining loose coupling.

Managing dependencies effectively is essential for building scalable and maintainable software. Dependency InjectionService Locator, and Factory Pattern with DI are all powerful tools for achieving this goal. Each pattern has its strengths and trade-offs, and the key is understanding when and how to use them appropriately. By applying these patterns, developers can create applications that are easier to testmaintain, and extend.