Understanding Dependency Injection

David Rufai - Aug 18 - - Dev Community

Dependency injection image

Imagine you're working on an app that requires various components to interact seamlessly. You’ve written a class to handle user authentication, but it directly creates instances of several dependencies network services, data storage, and logging utilities. It works well at first, but as the project grows, testing becomes a nightmare. Every time you make a change, you must modify multiple classes, and mocking these dependencies for unit tests feels like a battle. You start to realize that your tightly coupled code is dragging down the entire project.

This is where Dependency Injection comes to the rescue.


Dependency Injection? What the heck is that?

Dependency Injection (DI) is a software engineering technique often used in object-oriented software development to handle object relationships. Rather than having an object create or manage its required components (dependencies), DI involves providing these components from an external source. This approach is like giving an object the tools it needs instead of letting it gather them. By implementing DI, developers can create systems where different parts are less tightly interconnected. This results in easier to modify, test, and maintain applications over time. It also allows seamless flexibility in swapping out components without affecting the overall system structure.

Here's a very basic example in Kotlin.

 class GenericEngine() {
   fun start() {
     print("engine started")
   }
 }


class Car() {   
   val engine = GenericEngine()

   fun drive() {
     engine.start()
   } 
}

Enter fullscreen mode Exit fullscreen mode

Here, the Car is responsible for creating its engine, which seems pretty normal for now, right? But what happens when we want to build a car with a different type of engine? Do we create a different car class and then define our custom engine in it, or just change the GenericEngine instance to a different engine instance? This approach has some issues: the former being having to write more code for just a slightly different behavior, and the latter being having to modify a field which could cause breaking changes. This could go south quickly in large-scale applications. Fortunately for us, we have a magic trick up our sleeves... drumroll 🥁 ...Dependency Injection!!! 🙌

We could avoid all of that unnecessary modification by just requiring the engine to be passed as a dependency. This allows us to easily modify the engine type on our class object (talk about an upgrade 😉). Enough talk, here's how we do that.


class Car(private val engine: Engine) {  // Engine is injected via the constructor
    fun drive() {
        engine.start()
        println("Car is driving")
    }
}

fun main() {
    val engine = Engine()  // engine dependency is created outside the Car class
    val car = Car(engine)  // dependency is injected into the Car class
    car.drive()
}
Enter fullscreen mode Exit fullscreen mode

We could take this a step further by defining an Engine interface. This provides more flexibility, enabling us to swap out different implementations of the dependency without changing the dependent class.

First, we define our interface and also two contracts (start & stop) that all engines must follow.

interface Engine {
    fun start()
    fun stop()
}

Enter fullscreen mode Exit fullscreen mode

now let's create two implementations of the Engine interface.

class PetrolEngine() : Engine {
    override fun start() {
        println("engine started")
    }

    override fun stop() {
        println("engine stopped")
    }
}

class ElectricEngine() : Engine {
    override fun start() {
        println("Electric engine started, battery on 12% please find a charging station soon")
    }

    override fun stop() {
        println("electric engine stopped")
    }
}
Enter fullscreen mode Exit fullscreen mode

now our Car class would depend on the Engine interface and not a specific implementation.

class Car(private val engine: Engine) {
    fun drive() {
        engine.start()
        println("Car is moving")
    }

    fun stop() {
        println("Car stopped turning off engine")
        engine.stop()
    }
}
Enter fullscreen mode Exit fullscreen mode

finally here's how we can use both implementations.

    val petrolEngine: Engine = PetrolEngine()
    val electricEngine: Engine = ElectricEngine()

    val carWithPetrolEngine = Car(petrolEngine)
    carWithPetrolEngine.drive()

    val carWithElectricEngine = Car(electricEngine)
    carWithElectricEngine.drive()
Enter fullscreen mode Exit fullscreen mode

This approach using an interface is more scalable and is a very common practice in software design, especially when building apps that need to be easily modified.

I guess now we could easily solve the world's energy crisis by implementing an Engine that runs on water 😅.

. .
Terabox Video Player