DEV Community

Cover image for Dependency Injection: The Key to Writing Testable and Flexible Code
Larbi Jirari
Larbi Jirari

Posted on

Dependency Injection: The Key to Writing Testable and Flexible Code

Introduction

Web development demands scalable and maintainable code, and Dependency Injection is a crucial pattern that can help you achieve this goal.
In this article, we'll delve into dependency injection, common bad practices to avoid, how to use it, its benefits for unit testing, and when to apply or skip it.

The code examples presented above are written in TypeScript. Whether you're a front-end or back-end developer, you'll find these examples easy to grasp 😉

Dependency Injection (DI) vs Dependency Inversion (IOC)

Image description
Dependency Injection (DI) is one of the techniques that's used to implement Dependency Inversion also called inversion of control (IOC), which represents the D ofSOLID principles. focuses on decoupling high-level and low-level components by depending on abstractions rather than concrete implementations. DI, specifically, is the method of injecting dependencies into a component rather than having the component create them. In simpler terms, DI is how we achieve Dependency Inversion in practice, making our code more modular and flexible

Common Bad Practices (problem)

1. Hard-Coding Dependencies

function getOrderService() {
    const paymentService = getPaymentService();
    // ... Order logic
}
Enter fullscreen mode Exit fullscreen mode

Hard-coding dependencies, like the direct invocation of getPaymentService(), tightly couples your code, making it challenging to switch implementations or mock dependencies for testing.

2. Direct dependency calls

import { fetchData } from './api';

async function fetchProductData() {
    return await fetchData('/api/products');
}
Enter fullscreen mode Exit fullscreen mode

This code directly calls fetchData, making it difficult to substitute with a mock for testing purposes.

Using Dependency Injection (solution)

To apply dependency injection, define Interfaces Types or even JsDoc to abstract your dependencies:

// Interfaces
interface PaymentService {
    processPayment(amount: number): Promise<void>;
}

interface OrderService {
    placeOrder(): Promise<void>;
}

// Implementations
function createPaymentService(): PaymentService {
    async function processPayment(amount: number): Promise<void> {
        // Payment logic here
    }

    return { processPayment };
}

function createOrderService(paymentService: PaymentService): OrderService {
    async function placeOrder(): Promise<void> {
        // Order logic here
        await paymentService.processPayment(100);
    }

    return { placeOrder };
}
Enter fullscreen mode Exit fullscreen mode

This approach allows you to easily switch out implementations:

const fakePaymentService: PaymentService = {
    processPayment: async (amount) => {
        // Fake payment logic for testing
    },
};

const orderService = createOrderService(fakePaymentService);
Enter fullscreen mode Exit fullscreen mode

you can also use a container to manage the dependencies with tools like inversify (if you would like an article about it, let me know in the comments) which makes the experience even better, but it's more conventional tu use it with classes than functions

Benefits of Dependency Injection

1. Flexibility and Maintainability

Dependency Injection provides flexibility when swapping implementations. Here's how it maintains code flexibility:

// Interfaces for various data sources
interface DataSource {
    fetchData(): Promise<any>;
}

// Concrete implementations for different data sources
const apiDataSource: DataSource = {
    fetchData: async () => {
        // Fetch data from an API
    },
};

const localDataSource: DataSource = {
    fetchData: async () => {
        // Fetch data locally
    },
};

// Function that depends on a data source
async function fetchDataFromSource(dataSource: DataSource): Promise<void> {
    const data = await dataSource.fetchData();
    // Process the data
}

// Switching data sources without changing the calling code
await fetchDataFromSource(apiDataSource); // Fetch from API
await fetchDataFromSource(localDataSource); // Fetch locally
Enter fullscreen mode Exit fullscreen mode

By using the following pattern, we can easily switch between data sources without modifying the fetchDataFromSource function.

2. Enhanced Unit Testing

Dependency Injection makes unit testing easier by allowing you to swap out real implementations with mocks or fakes. Let's see this in action

//--payment.contract.ts
  // Interface PaymentService
interface PaymentService {
    processPayment(amount: number): Promise<void>;
}
//--

//--payment.fake.ts
const fakePaymentService: PaymentService = {
    processPayment: async (amount) => {
        // Fake payment logic for testing
    },
};
//--

//--processOrder.ts
  // Function that depends on PaymentService
async function processOrder(paymentService: PaymentService, amount: number): Promise<void> {
    await paymentService.processPayment(amount);
    // Additional order processing logic
}
//--

//--processOrder.test.ts
import {fakePaymentService} from "./payment.fake.ts"
  // Unit test using the fake PaymentService
it('should process an order', async () => {
    await processOrder(fakePaymentService, 100);
    // Assert the desired behavior
});
//--
Enter fullscreen mode Exit fullscreen mode

In this example, we can easily replace the real PaymentService with the fakePaymentService for testing purposes.

When to Use Dependency Injection

1. Complex Systems

Dependency Injection excels in complex systems with various external dependencies, helping maintain code organisation and manageability like DDD based architecture

2. Testing Priority

If comprehensive unit testing is a priority, dependency injection is a valuable technique. It streamlines test setup and promotes testability (and believe me, you don't want to skip tests, you will regret it)

When Not to Use Dependency injection

1. Simpler Projects

For smaller, straightforward projects with minimal external dependencies, the overhead of implementing dependency injection may not be justified.

2. Third-Party Libraries

When working with third-party libraries or APIs that can't be modified, applying dependency injection might not be feasible. In such cases, adapt your code to the library's constraints.

Conclusion

Dependency injection is a powerful technique . By decoupling implementation details and external dependencies, you can enhance code maintainability and testability. However, consider the complexity of your project before deciding to use it. When applied judiciously, dependency injection can significantly improve code quality.

Top comments (0)