Marcell CD

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:

import {someFunction} from "a"

function myFunction() {
  someFunction()
}

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:

function myFunction(dependency) {
  dependency()
}

// Usage
import {someFunction} from "a"
myFunction(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:

// Traditional approach: sorting logic is hardcoded
function traditionalSort(arr) {
  return arr.sort((a, b) => a - b)
}

// Dependency injection approach: sorting logic is injected
function iocSort(arr, compareFn) {
  return arr.sort(compareFn)
}

// Usage examples
const numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]

// Traditional approach - only one way to sort
console.log("Traditional sort:", traditionalSort(numbers))

// DI approach - many ways to sort with the same function
console.log(
  "Ascending:",
  iocSort(numbers, (a, b) => a - b)
)
console.log(
  "Descending:",
  iocSort(numbers, (a, b) => b - a)
)

// Sorting complex objects
const people = [
  {name: "Alice", age: 30},
  {name: "Bob", age: 25},
  {name: "Charlie", age: 35},
]

console.log(
  "Sort by age:",
  iocSort(people, (a, b) => a.age - b.age)
)
console.log(
  "Sort by name:",
  iocSort(people, (a, b) => a.name.localeCompare(b.name))
)

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:

function logger(count) {
  console.log(count)
}

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <>
      <h1>{count}</h1>
      <button
        onClick={() => {
          setCount(count + 1)
          logger(count)
        }}
      >
        Increment
      </button>
      <button
        onClick={() => {
          setCount(count - 1)
          logger(count)
        }}
      >
        Decrement
      </button>
    </>
  )
}

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:

function Counter({logger}) {
  const [count, setCount] = useState(0)

  return (
    <>
      <h1>{count}</h1>
      <button
        onClick={() => {
          setCount(count + 1)
          logger(count)
        }}
      >
        Increment
      </button>
      <button
        onClick={() => {
          setCount(count - 1)
          logger(count)
        }}
      >
        Decrement
      </button>
    </>
  )
}

function App() {
  return (
    <>
      <Counter
        logger={(count) => {
          console.log(`Count: ${count}`)
        }}
      />

      <Counter
        logger={(count) => {
          console.log(`Some other message: ${count}`)
        }}
      />
    </>
  )
}

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.