Marcell Ciszek Druzynski

Dependency Injection

Dependency Injection in software development

August 29, 2024

Dependency injection

It may sound like dependency injection is a complicated process, but it really isn't that complex.

The concept of dependency injection is simple: you pass a piece of code that uses another piece of code rather than directly referencing it in a function or class.

As an example, if we import a function from module a

We have created a dependency on module b for some other functionality from module b. This is common practice in software development, and we use it every day when we write code.

1import {someFunction} from "a";
2
3function myFunction() {
4 someFunction();
5}
1import {someFunction} from "a";
2
3function myFunction() {
4 someFunction();
5}

We need to think a bit further since we do not want to have too many dependencies which can make refactoring harder (classes, functions).

Instead of creating the object that we need in our class, we could provide the class in the constructor, aka inject the dependency.

While the core principles are pretty simple, since a lot of frameworks and libraries use or focus mainly on dependency injection, the topic has become more complex than it should be.

Dependency injection is loved by some developers, but avoided by others.

Dependency injection comes from a design principle called Inversion of control.

Using this pattern, you can make your code more reusable by accepting custom input that can be deferred to perform some logic, steps, etc.

For example,

1// Traditional approach without IoC
2
3function traditionalSort(arr) {
4 return arr.sort((a, b) => a - b);
5}
6
7// IoC approach
8
9function iocSort(arr, compareFn) {
10 return arr.sort(compareFn);
11}
12
13// Usage
14
15let numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5];
16
17// Traditional approach
18
19console.log("Traditional sort:", traditionalSort(numbers));
20
21// IoC approach
22
23console.log(
24 "Ascending sort:",
25 iocSort(numbers, (a, b) => a - b),
26);
27
28console.log(
29 "Descending sort:",
30 iocSort(numbers, (a, b) => b - a),
31);
32
33// IoC with custom comparison
34
35const people = [
36 {name: "Alice", age: 30},
37
38 {name: "Bob", age: 25},
39
40 {name: "Charlie", age: 35},
41];
42
43console.log(
44 "Sort by age:",
45 iocSort(people, (a, b) => a.age - b.age),
46);
47
48console.log(
49 "Sort by name:",
50 iocSort(people, (a, b) => a.name.localeCompare(b.name)),
51);
1// Traditional approach without IoC
2
3function traditionalSort(arr) {
4 return arr.sort((a, b) => a - b);
5}
6
7// IoC approach
8
9function iocSort(arr, compareFn) {
10 return arr.sort(compareFn);
11}
12
13// Usage
14
15let numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5];
16
17// Traditional approach
18
19console.log("Traditional sort:", traditionalSort(numbers));
20
21// IoC approach
22
23console.log(
24 "Ascending sort:",
25 iocSort(numbers, (a, b) => a - b),
26);
27
28console.log(
29 "Descending sort:",
30 iocSort(numbers, (a, b) => b - a),
31);
32
33// IoC with custom comparison
34
35const people = [
36 {name: "Alice", age: 30},
37
38 {name: "Bob", age: 25},
39
40 {name: "Charlie", age: 35},
41];
42
43console.log(
44 "Sort by age:",
45 iocSort(people, (a, b) => a.age - b.age),
46);
47
48console.log(
49 "Sort by name:",
50 iocSort(people, (a, b) => a.name.localeCompare(b.name)),
51);

We demonstrate IoC using a sorting function in this example:

  1. The traditionalSort function is a typical implementation where the sorting logic is hardcoded within the function.

  2. The iocSort function implements IoC by accepting a comparison function as an argument. This inverts the control of how sorting is performed from the iocSort function to the caller.

  3. We then use the IoCSort function to perform various types of sorting:

    • Ascending and descending sorts on an array of numbers

    • Sorting an array of objects by different properties

By using IoC, we've created a more flexible and reusable sorting function. The iocSort function doesn't need to know the specifics of how to compare elements; it just needs to know that it will receive a function that can perform the comparison.

This approach allows for better flexibility and extensibility. We can now sort any type of data using any comparison logic without modifying the IOCSort function itself.

The iocSort function doesn't care how the logic works if we sort by ASC or DESC, it is up to the user to call the iocSort.

This is the essence of Inversion of Control - control over how sorting is done has been inverted from the sorting function to the caller of the function.

We got a more re-usable function and a more decoupled code by not only depending on how we sort the list in a specific order. One other benefit is that the code gets easier to test, where we can pass in different sort functions and expect different behaviors.

Dependency injection using React

Let's say we have a simple React component that increments a counter. The button that gets fired to increment the count should also log what is going on.

1function logger(count: number) {
2 console.log(count);
3}
4
5function Counter() {
6 const [count, setCount] = useState(0);
7
8 return (
9 <>
10 <h1>{count}</h1>
11
12 <button
13 onClick={() => {
14 setCount(count + 1);
15
16 logger(count);
17 }}
18 >
19 Increment
20 </button>
21
22 <button
23 onClick={() => {
24 setCount(count - 1);
25
26 logger(count);
27 }}
28 >
29 Decrement
30 </button>
31 </>
32 );
33}
1function logger(count: number) {
2 console.log(count);
3}
4
5function Counter() {
6 const [count, setCount] = useState(0);
7
8 return (
9 <>
10 <h1>{count}</h1>
11
12 <button
13 onClick={() => {
14 setCount(count + 1);
15
16 logger(count);
17 }}
18 >
19 Increment
20 </button>
21
22 <button
23 onClick={() => {
24 setCount(count - 1);
25
26 logger(count);
27 }}
28 >
29 Decrement
30 </button>
31 </>
32 );
33}

Here we will log every time we increment or decrement the country to the console and it works as expected.

But let's say we want some different behavior for the log function where we perhaps want to have some more complex logging when we are in production and the Counter component is an erasable component.

The problem right now is that the Counter component has a dependency on the logger function.

To take the advantage of dependency injection here we can simply pass the logger function as a props and then pass in whatever logging function we need.

1function App() {
2 return (
3 <>
4 <Counter
5 logger={(count: number) => {
6 console.log(`Count: ${count}`);
7 }}
8 />
9
10 <Counter
11 logger={(count: number) => {
12 console.log(`Some other message : ${count}`);
13 }}
14 />
15 </>
16 );
17}
18
19function Counter({logger}: {logger: (count: number) => void}) {
20 let [count, setCount] = useState(0);
21 return (
22 <>
23 <h1>{count}</h1>
24 <button
25 onClick={() => {
26 setCount(count + 1);
27 logger(count);
28 }}
29 >
30 Increment
31 </button>
32
33 <button
34 onClick={() => {
35 setCount(count - 1);
36 logger(count);
37 }}
38 >
39 Decrement
40 </button>
41 </>
42 );
43}
1function App() {
2 return (
3 <>
4 <Counter
5 logger={(count: number) => {
6 console.log(`Count: ${count}`);
7 }}
8 />
9
10 <Counter
11 logger={(count: number) => {
12 console.log(`Some other message : ${count}`);
13 }}
14 />
15 </>
16 );
17}
18
19function Counter({logger}: {logger: (count: number) => void}) {
20 let [count, setCount] = useState(0);
21 return (
22 <>
23 <h1>{count}</h1>
24 <button
25 onClick={() => {
26 setCount(count + 1);
27 logger(count);
28 }}
29 >
30 Increment
31 </button>
32
33 <button
34 onClick={() => {
35 setCount(count - 1);
36 logger(count);
37 }}
38 >
39 Decrement
40 </button>
41 </>
42 );
43}

Now we can pass in, inject different logger functions that will print different messages. Where the first Count component will log Count: ${count} and the second Count component will log Some other message : ${count}. This is just a very simple and basic example just to demonstrate the main purpose of dependency injection and why it is useful. In a real application, you can have more complex logic and functions that you want to inject into your components. For example, you can inject services, API calls, etc.

Benefits

  • Improved testability: You can easily mock dependencies for unit tests.

  • Flexibility: You can swap implementations without changing the component code.

  • Separation of concerns: Components don't need to know how to create their dependencies.

By using dependency injection, you make your React components more modular, easier to test, and more flexible to changes in the future.

Downsides

  • Learning curve: Dependency injection introduces an additional layer of abstraction that can be challenging for developers new to the concept.

  • Boilerplate code: Implementing dependency injection often requires additional setup code, which can make the codebase more verbose.

  • Runtime errors: Many compile-time errors are pushed to runtime when using dependency injection, which can make debugging more difficult.

  • Performance overhead: Dependency injection frameworks often use reflection or dynamic programming, which can introduce a slight performance overhead.

Summary

Dependency Injection (DI) is a design pattern and programming technique used in software development to achieve Inversion of Control (IoC) between classes, functions and their dependencies. The main idea behind DI is to make a class independent of its dependencies by separating the usage of an object from its creation.

  • Decoupling: DI helps in reducing the coupling between functions and classes, making the code more modular and easier to maintain.

  • Testability: It improves testability by allowing dependencies to be easily mocked or stubbed in unit tests.

  • Flexibility: DI makes it easier to switch between different implementations of a dependency without changing the dependent function or class.

By employing dependency injection, developers can create more flexible, scalable, and testable applications. We make our code more decoupled and bring benefits like refactoring in the future and extending logic.