Dependency Injection Pattern Use Cases and Examples in TypeScript

Explore the practical use cases and examples of implementing the Dependency Injection pattern in TypeScript to enhance testability and flexibility.

4.7.3 Use Cases and Examples

In this section, we delve into the practical applications of the Dependency Injection (DI) pattern in TypeScript. We’ll explore how DI enhances testability and flexibility in software design, providing you with real-world examples and scenarios. By the end of this guide, you’ll understand how to leverage DI to improve your application’s architecture, making it more maintainable and adaptable to change.

Introduction to Dependency Injection

Dependency Injection is a design pattern that allows for the decoupling of components by injecting dependencies into a class, rather than having the class instantiate them directly. This pattern is crucial for creating flexible and testable code. By abstracting the creation of dependencies, you can easily swap out implementations, making your codebase more adaptable to change.

Benefits of Dependency Injection

Before diving into specific use cases, let’s briefly discuss the benefits of using Dependency Injection:

  • Testability: DI allows for the injection of mock dependencies, making unit testing more straightforward.
  • Flexibility: Easily swap out components without altering the dependent class.
  • Maintainability: Simplifies the addition of new features and reduces code duplication.
  • Separation of Concerns: Encourages a clean separation between the creation and use of dependencies.

Use Case 1: Swapping Out Data Repositories

One of the most common use cases for Dependency Injection is swapping out data repositories in a service. This is particularly useful in testing scenarios where you might want to replace a real database connection with a mock or in-memory database.

Example: Data Repository Injection

Let’s consider a simple example where we have a UserService that depends on a UserRepository to fetch user data.

 1// Define the UserRepository interface
 2interface UserRepository {
 3    getUserById(id: string): Promise<User>;
 4}
 5
 6// Implement a concrete UserRepository
 7class DatabaseUserRepository implements UserRepository {
 8    async getUserById(id: string): Promise<User> {
 9        // Logic to fetch user from a database
10    }
11}
12
13// Implement a mock UserRepository for testing
14class MockUserRepository implements UserRepository {
15    async getUserById(id: string): Promise<User> {
16        // Return a mock user
17        return { id, name: "Mock User" };
18    }
19}
20
21// UserService depends on UserRepository
22class UserService {
23    constructor(private userRepository: UserRepository) {}
24
25    async getUser(id: string): Promise<User> {
26        return this.userRepository.getUserById(id);
27    }
28}
29
30// Usage
31const userRepository = new DatabaseUserRepository();
32const userService = new UserService(userRepository);
33
34// For testing
35const mockRepository = new MockUserRepository();
36const testUserService = new UserService(mockRepository);

In this example, the UserService class depends on the UserRepository interface. By injecting the dependency, we can easily swap out the DatabaseUserRepository with a MockUserRepository during testing, allowing us to test the UserService in isolation.

Use Case 2: Injecting Different Logging Mechanisms

Another practical application of Dependency Injection is injecting different logging mechanisms based on the environment. This allows for more flexible logging strategies, such as using a console logger in development and a file or remote logger in production.

Example: Logging Mechanism Injection

Consider a scenario where we have a Logger interface and different implementations for development and production environments.

 1// Define the Logger interface
 2interface Logger {
 3    log(message: string): void;
 4}
 5
 6// Implement a console logger for development
 7class ConsoleLogger implements Logger {
 8    log(message: string): void {
 9        console.log(`ConsoleLogger: ${message}`);
10    }
11}
12
13// Implement a file logger for production
14class FileLogger implements Logger {
15    log(message: string): void {
16        // Logic to write the message to a file
17    }
18}
19
20// Application class that uses Logger
21class Application {
22    constructor(private logger: Logger) {}
23
24    run(): void {
25        this.logger.log("Application is running");
26    }
27}
28
29// Usage
30const logger: Logger = process.env.NODE_ENV === 'production' ? new FileLogger() : new ConsoleLogger();
31const app = new Application(logger);
32app.run();

In this example, the Application class depends on the Logger interface. By injecting the appropriate logger implementation based on the environment, we can ensure that the application logs messages correctly in both development and production.

Use Case 3: Implementing Plugins or Modular Architectures

Dependency Injection is also instrumental in implementing plugins or modular architectures where components can be easily exchanged. This approach is common in applications that require extensibility and modularity.

Example: Plugin Architecture

Let’s explore a scenario where we have a PaymentProcessor interface and different implementations for various payment gateways.

 1// Define the PaymentProcessor interface
 2interface PaymentProcessor {
 3    processPayment(amount: number): void;
 4}
 5
 6// Implement a PayPal payment processor
 7class PayPalProcessor implements PaymentProcessor {
 8    processPayment(amount: number): void {
 9        console.log(`Processing payment of $${amount} through PayPal`);
10    }
11}
12
13// Implement a Stripe payment processor
14class StripeProcessor implements PaymentProcessor {
15    processPayment(amount: number): void {
16        console.log(`Processing payment of $${amount} through Stripe`);
17    }
18}
19
20// E-commerce application class
21class ECommerceApp {
22    constructor(private paymentProcessor: PaymentProcessor) {}
23
24    checkout(amount: number): void {
25        this.paymentProcessor.processPayment(amount);
26    }
27}
28
29// Usage
30const paymentProcessor: PaymentProcessor = new PayPalProcessor();
31const app = new ECommerceApp(paymentProcessor);
32app.checkout(100);
33
34// Switching to Stripe
35const stripeProcessor: PaymentProcessor = new StripeProcessor();
36const stripeApp = new ECommerceApp(stripeProcessor);
37stripeApp.checkout(200);

In this example, the ECommerceApp class depends on the PaymentProcessor interface. By injecting different payment processor implementations, we can easily switch between PayPal and Stripe without altering the application’s core logic.

How DI Enhances Unit Testing

One of the most significant advantages of Dependency Injection is its impact on unit testing. By allowing for the injection of mock dependencies, DI makes it easier to test classes in isolation.

Example: Unit Testing with Mock Dependencies

Consider the UserService example from earlier. We can write unit tests for the UserService by injecting a mock UserRepository.

 1import { expect } from 'chai';
 2import { describe, it } from 'mocha';
 3
 4// Mock implementation of UserRepository
 5class MockUserRepository implements UserRepository {
 6    async getUserById(id: string): Promise<User> {
 7        return { id, name: "Mock User" };
 8    }
 9}
10
11describe('UserService', () => {
12    it('should return a user', async () => {
13        const mockRepository = new MockUserRepository();
14        const userService = new UserService(mockRepository);
15
16        const user = await userService.getUser('123');
17        expect(user.name).to.equal('Mock User');
18    });
19});

In this test, we inject a MockUserRepository into the UserService, allowing us to verify the behavior of the getUser method without relying on a real database.

Impact on Code Maintainability

Dependency Injection significantly improves code maintainability by promoting a clean separation of concerns. By decoupling the creation and use of dependencies, DI makes it easier to modify and extend the application without affecting existing components.

Example: Adding New Features

Let’s revisit the ECommerceApp example. Suppose we want to add a new payment processor, such as SquareProcessor. With DI, this is as simple as implementing the PaymentProcessor interface and injecting the new implementation.

 1// Implement a Square payment processor
 2class SquareProcessor implements PaymentProcessor {
 3    processPayment(amount: number): void {
 4        console.log(`Processing payment of $${amount} through Square`);
 5    }
 6}
 7
 8// Usage
 9const squareProcessor: PaymentProcessor = new SquareProcessor();
10const squareApp = new ECommerceApp(squareProcessor);
11squareApp.checkout(300);

By adhering to the DI pattern, we can add new features like the SquareProcessor without modifying the existing ECommerceApp logic, thus enhancing maintainability.

Encouraging DI Adoption

Adopting Dependency Injection principles can significantly improve the design and flexibility of your applications. Here are some tips to encourage DI adoption:

  • Start Small: Begin by applying DI to a small part of your application, such as a single service or component.
  • Use Interfaces: Define interfaces for your dependencies to facilitate easy swapping and testing.
  • Leverage DI Frameworks: Consider using DI frameworks like InversifyJS to manage dependencies more efficiently.
  • Educate Your Team: Share the benefits of DI with your team and encourage them to adopt DI practices in their code.

Try It Yourself

To solidify your understanding of Dependency Injection, try modifying the examples provided:

  • Experiment with Different Repositories: Create additional repository implementations and inject them into the UserService.
  • Implement Additional Loggers: Add new logging mechanisms, such as a remote logger, and inject them into the Application.
  • Extend the Plugin Architecture: Implement additional payment processors and inject them into the ECommerceApp.

Visualizing Dependency Injection

To better understand how Dependency Injection works, let’s visualize the process using a class diagram.

    classDiagram
	    class UserService {
	        -UserRepository userRepository
	        +getUser(id: string): User
	    }
	
	    class UserRepository {
	        <<interface>>
	        +getUserById(id: string): User
	    }
	
	    class DatabaseUserRepository {
	        +getUserById(id: string): User
	    }
	
	    class MockUserRepository {
	        +getUserById(id: string): User
	    }
	
	    UserService --> UserRepository
	    DatabaseUserRepository ..|> UserRepository
	    MockUserRepository ..|> UserRepository

This diagram illustrates the relationship between the UserService, UserRepository, and its implementations. The UserService depends on the UserRepository interface, allowing for different implementations to be injected.

Conclusion

Dependency Injection is a powerful design pattern that enhances testability, flexibility, and maintainability in TypeScript applications. By decoupling the creation and use of dependencies, DI allows for easy swapping of components, making your codebase more adaptable to change. As you continue to explore and implement DI in your projects, you’ll discover its potential to improve your application’s architecture and design.

Quiz Time!

Loading quiz…
Revised on Thursday, April 23, 2026