Marcell Ciszek Druzynski
← Back to blog posts

Dependency Injection: Simplifying Complex Code

Dependency injection might sound complicated, but it's actually a straightforward concept that can significantly improve your code.

What is Dependency Injection?

At its core, dependency injection means providing the things your code needs from the outside rather than having your code create or find those things itself.

Think of it this way: instead of a function reaching out to grab what it needs, you hand it everything it requires before it starts working.

The Traditional Approach

Normally, we might write code like this:

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

Here, myFunction directly depends on someFunction from module a. If we ever want to change, test, or replace someFunction, we'll need to modify myFunction too.

The Dependency Injection Approach

With dependency injection, we'd rewrite this as:

1function myFunction(dependency) {
2 dependency();
3}
4
5// Usage
6import { someFunction } from "a";
7myFunction(someFunction);
1function myFunction(dependency) {
2 dependency();
3}
4
5// Usage
6import { someFunction } from "a";
7myFunction(someFunction);

Now myFunction doesn't care where its dependency comes from - it just uses what it's given. This small change brings powerful benefits.

Why Dependency Injection Matters

Dependency injection comes from a broader design principle called Inversion of Control, where we flip who's in charge of managing dependencies.

A Practical Example: Flexible Sorting

Let's see how this works with a sorting example:

1// Traditional approach: sorting logic is hardcoded
2function traditionalSort(arr) {
3 return arr.sort((a, b) => a - b);
4}
5
6// Dependency injection approach: sorting logic is injected
7function iocSort(arr, compareFn) {
8 return arr.sort(compareFn);
9}
10
11// Usage examples
12const numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5];
13
14// Traditional approach - only one way to sort
15console.log("Traditional sort:", traditionalSort(numbers));
16
17// DI approach - many ways to sort with the same function
18console.log(
19 "Ascending:",
20 iocSort(numbers, (a, b) => a - b)
21);
22console.log(
23 "Descending:",
24 iocSort(numbers, (a, b) => b - a)
25);
26
27// Sorting complex objects
28const people = [
29 { name: "Alice", age: 30 },
30 { name: "Bob", age: 25 },
31 { name: "Charlie", age: 35 },
32];
33
34console.log(
35 "Sort by age:",
36 iocSort(people, (a, b) => a.age - b.age)
37);
38console.log(
39 "Sort by name:",
40 iocSort(people, (a, b) => a.name.localeCompare(b.name))
41);
1// Traditional approach: sorting logic is hardcoded
2function traditionalSort(arr) {
3 return arr.sort((a, b) => a - b);
4}
5
6// Dependency injection approach: sorting logic is injected
7function iocSort(arr, compareFn) {
8 return arr.sort(compareFn);
9}
10
11// Usage examples
12const numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5];
13
14// Traditional approach - only one way to sort
15console.log("Traditional sort:", traditionalSort(numbers));
16
17// DI approach - many ways to sort with the same function
18console.log(
19 "Ascending:",
20 iocSort(numbers, (a, b) => a - b)
21);
22console.log(
23 "Descending:",
24 iocSort(numbers, (a, b) => b - a)
25);
26
27// Sorting complex objects
28const people = [
29 { name: "Alice", age: 30 },
30 { name: "Bob", age: 25 },
31 { name: "Charlie", age: 35 },
32];
33
34console.log(
35 "Sort by age:",
36 iocSort(people, (a, b) => a.age - b.age)
37);
38console.log(
39 "Sort by name:",
40 iocSort(people, (a, b) => a.name.localeCompare(b.name))
41);

With the dependency injection approach (iocSort):

  1. We've created one flexible function that can handle many different sorting needs
  2. We can sort in ascending order, descending order, or by any property of complex objects
  3. Our sorting function stays simple while supporting endless possibilities

The iocSort function doesn't need to know how to compare elements - that knowledge is injected from outside. This is the essence of dependency injection.

Real-World Example: Dependency Injection in React

Let's look at how this applies to a simple React component. First, without dependency injection:

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

The problem: Our Counter component is hardwired to use a specific logging function. What if we want different logging behavior in different situations?

Now with dependency injection:

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

Now we can have multiple counters with different logging behaviors without changing the Counter component itself. We've made our component more flexible and reusable by injecting its dependencies.

The Benefits of Dependency Injection

The Trade-offs

Like any technique, dependency injection comes with some downsides:

When to Use Dependency Injection

Dependency injection shines when:

Summary

Dependency injection is a powerful technique that makes your code more flexible, testable, and maintainable by passing dependencies in from the outside rather than creating them internally.

By decoupling your components from their dependencies, you gain the freedom to swap implementations, create better tests, and build more modular systems that can evolve more easily over time.

Start small by identifying hard-coded dependencies in your code and consider whether they could be passed in as parameters instead. You'll be surprised at how this simple change can make your code significantly more robust and adaptable.