Deepening Understanding of Dependency Injection

Deepening Understanding of Dependency Injection

Hey Friends, I am putting together my thoughts on dependency injection and curating my study of it as a software engineering pattern. I am assuming you, as the reader have significant experience with working with Dependency injection implementation in a real world scenario.

Software engineering is a dynamic field, with new techniques emerging very frequently. However, certain techniques have stood the test of time, and Dependency Injection (DI) is one of them. Through this blog post, I aim to shed some light on Dependency Injection, a widely-adopted software engineering technique that continues to be relevant and beneficial.

"Dependency Injection promotes the principle of 'coding to an interface, not an implementation.' It allows for interchangeable implementations and encourages modular, reusable code." - maybe Josh Bloch

What is Dependency Injection?

A real world software usually forms a system that contains interacting pieces that have networked interdependencies in a graph like fashion.

Created by author prompts to Midjourney

Dependency Injection is a technique in software engineering where a piece of software receives its dependencies from an external source rather than creating them internally. In simpler terms, Dependency Injection is a way to provide computational objects with the components they need to perform their functions. Instead of controlling creation and linking of dependency in user land, we Β hand over this control to an external piece of software, usually a framework or library. We call this outsourcing of control, inversion of control.

In an object oriented sense for example, consider a class Car. This class might have dependencies such as Engine, Wheels, and Fuel. Instead of the Car class creating these objects internally, they are passed into it from an external source like an MVC framework for example. This approach is said to make the code more modular, maintainable, and testable.

A dependency injection framework is usually responsible for creation, managing scope and lifetime and destruction of dependencies.

Thinking Pseudo Mathematically

While Dependency Injection (DI) is primarily a design pattern in software engineering and not inherently mathematical, we can use set theory to give a more formal description of what's happening when we use DI.

Let's consider three sets:

  1. C - The set of all classes or components in the system.
  2. D - The set of all dependencies that classes in C can have.
  3. I - The set of all instances of classes.

The Dependency Injection pattern deals with how elements from set D are provided to elements of set C, and how this affects the creation of elements in set I.

Before Dependency Injection:

  • Each class c in C is responsible for creating its dependencies.
  • We can describe this relationship with a function f: C -> 2^D, where f(c) gives the set of dependencies that class c creates for itself.

After applying Dependency Injection:

  • The dependencies are provided from the outside.
  • We can describe this new relationship with a function g: C x D -> I, where g(c, d) represents injecting dependency d into class c to create an instance i in I.

It's also good to note that in practice, Dependency Injection involves more complex relationships involving configuration, lifetime management, etc., which are not captured in this simple mathematical model.

Before Dependency Injection:

  • Each class 'c' in a set of classes 'C' is responsible for creating its own dependencies.
  • We can describe this relationship with a function 'f' which takes a class 'c' as input and returns the set of dependencies that class 'c' creates for itself.

After applying Dependency Injection:

  • The dependencies are provided to the class from the outside.
  • We can describe this new relationship with a function 'g', which takes a class 'c' and a dependency 'd' as inputs and represents injecting dependency 'd' into class 'c' to create an instance 'i'.

In practice, Dependency Injection allows the decoupling of object creation and dependency management from the class itself. This abstract representation just shows how the responsibilities are shifted externally when applying Dependency Injection.

Created by author prompts to Midjourney

When applying Dependency Injection in a real-world software application, you are effectively moving the responsibility of finding and creating dependencies from the individual classes in set ( C ) to an external "container" or mechanism. This allows for greater flexibility, easier testing, and better separation of concerns.

Why is Dependency Injection Evergreen?

Dependency Injection has been around for a long time, and its continued prominence is attributed to the following reasons:

1. Decoupling of Code

One of the primary benefits of Dependency Injection is the decoupling it brings to your code. By injecting dependencies, your classes are no longer tightly bound to their dependencies. This leads to a more modular design which is easier to modify, extend, and maintain. This means in practice we can swap the implementation of dependencies as long as they follow the interface/type/shape.

2. Improved Testability

Testing is a critical aspect of software development. Dependency Injection facilitates unit testing by making it easier to swap out real implementations of dependencies with mock objects. For instance, you can inject a mock database connection to ensure that your data access code is working correctly without having to interact with the real database during testing.

3. Greater Code Reusability

When dependencies are injected rather than tightly coupled, your classes become more reusable. The same class can be used in different contexts by injecting different implementations of its dependencies.

4. Easier Configuration and Maintenance

As applications grow, managing configurations can become a daunting task. Dependency Injection helps alleviate this by centralising the configuration of dependencies. When a dependency needs to be modified, you need only make the change in one place, rather than across multiple files or modules.

Dependency Injection in Pseudo Code

Let's take a quick example to illustrate Dependency Injection in pseudocode:

class Engine {
    function start() {
        print("Engine starting")
    }
}

class Car {
    Engine engine

    // Constructor Injection: Engine is passed as a parameter
    function Car(Engine engine) {
        this.engine = engine
    }

    function drive() {
        engine.start()
        print("Car is driving")
    }
}

// often called IoC (Inversion of Control) container
class DependencyContainer {
    function getEngine() {
        // In a real-world scenario, more might go on here 
        // (like configuration) or you might maintain a datastructure
        // that contains the dependencies like hash maps, bloom filters
        // etc and have a generic method to 
        // register dependencies by tokens
        return new Engine()
    }

    function getCar() {
        // Dependency is automatically resolved by the container
        Engine engine = getEngine()
        return new Car(engine)
    }
}

// Main Program
function main() {
    // Create IoC Container
    DependencyContainer container = new DependencyContainer()

    // Get a Car from the container. Dependencies are automatically resolved.
    Car myCar = container.getCar()

    // Drive the car
    myCar.drive()
}

In a real world scenario, we could abstract it further and use some form of runtime reflection to automatically figure out the constructor dependencies of classes or function parameters/closures in our container and provide them.

Tradeoffs and Downsides

Created by author prompts to Midjourney

While Dependency Injection offers several benefits, like any technique, it comes with its own unique tradeoffs:

1. Complexity

One of the most notable downsides of Dependency Injection is the added complexity, especially in small projects. If you're working on a simple application with few dependencies, the setup and configuration for Dependency Injection might seem like overkill. However, for larger applications, the benefits usually outweigh this initial overhead. Dependency injection thrives in programming languages that embrace polymorphism and reflection, as it enhances testability in remarkable ways. However, in the absence of these language features, the advantages of testability diminish, presenting significant challenges in mocking. Each test would require manual construction of the entire dependency graph, making the code nearly untestable. A potential solution involves creating stubs for the required interfaces, but this approach can introduce verbosity and hinder test readability.

2. Learning Curve

For beginners or developers unfamiliar with Dependency Injection, there can be a steep learning curve. Understanding how to properly set up and configure a Dependency Injection framework or container can take time, and misuse of the pattern can lead to issues down the road.

3. Runtime Errors

With Dependency Injection, errors related to dependency configuration or wiring are often only caught at runtime, which makes them harder to debug compared to compile-time errors. For example if there is no provider or service registered in the dependency container . Or if you have a complex object graph of dependencies with many cycles which fails to resolve at runtime.

4. Over-Abstraction

Sometimes, Dependency Injection can lead to an over-abstraction problem, where too many interfaces and classes are created just for the sake of decoupling. This can make the code harder to follow and understand, especially for someone new to the codebase. Another pitfall is causing circular dependencies with many cycles in the dependency graph. This actually causes more coupling and messy abstractions leading to maintainability problems.

5. Performance

There can be a slight performance hit due to the use of Dependency Injection, particularly at the start of the application (bootstrap) where the dependencies are being created and wired together. However, in most cases, this is negligible compared to the maintainability and testability benefits.

Dependency Injection in Functional Programming

In Functional Programming (FP), Dependency Injection is often inherently applied because FP emphasises immutability and the use of pure functions. Pure functions are functions whose output is solely determined by their input and have no side effects. This means that a pure function does not rely on any external state or dependencies; everything it needs is passed to it as arguments.

In FP, you naturally tend to pass around functions and data, which makes Dependency Injection a more intrinsic pattern.

Let's take a look at an example:

Consider a function that computes the total price of items in a cart, including tax. Without Dependency Injection, the tax rate might be a global constant.

taxRate = 0.08

computeTotalPrice :: [Double] -> Double
computeTotalPrice prices = sum prices * (1 + taxRate)

In this example, computeTotalPrice depends on a global taxRate. This is not ideal because the function is not pure; its output is not only determined by its inputs but also by the external state (taxRate).

Using Dependency Injection, you would instead pass the tax rate as a parameter to the function, making it a pure function:

computeTotalPrice :: Double -> [Double] -> Double
computeTotalPrice taxRate prices = sum prices * (1 + taxRate)

Now, computeTotalPrice is a pure function. Its output is determined solely by its inputs, and it has no dependencies on external state.

In functional programming languages, higher-order functions and currying are also used to inject dependencies. For example, you could partially apply the computeTotalPrice function with a tax rate to get a new function that just takes the prices:

taxRate = 0.08
computeTotalWithTax :: [Double] -> Double
computeTotalWithTax = computeTotalPrice taxRate

In Functional Programming, Dependency Injection aligns naturally with the principles of immutability and pure functions, leading to more predictable and testable code.

Concrete Implementations to Study

There are several concrete implementations of Dependency Injection that are worth studying and understanding. Here are a few popular ones:

Spring Framework (Java/Kotlin): Spring is a widely used framework that provides comprehensive support for Dependency Injection in Java and Kotlin. It offers a powerful Inversion of Control (IoC) container that manages the lifecycle and wiring of dependencies. Spring's DI implementation is based on annotations or XML configuration and provides various features such as autowiring, component scanning, and support for different scopes.

Autofac (C#): Autofac is a mature Dependency Injection container for .NET. It offers a range of features such as constructor injection, property injection, and aspect-oriented programming (AOP) support. Autofac provides a fluent API and supports different registration techniques, including attribute-based and code-based registration.

Angular Dependency Injection (TypeScript): Angular, a popular JavaScript framework, provides its own Dependency Injection system for managing dependencies in web applications. Angular's DI is hierarchical, supports constructor injection, and provides various lifecycle hooks for managing component instances. It plays a significant role in building modular and scalable applications in the Angular ecosystem.

Pyramid (Python): Pyramid is a flexible web framework for Python that includes a built-in Dependency Injection system. It allows registering and injecting dependencies using decorators and provides features such as property injection, view predicates, and custom configuration options.

Laravel Service Container (PHP): The Laravel Service Container is a fundamental aspect of the Laravel framework, providing a powerful dependency injection (DI) and inversion of control (IoC) container. It serves as a central repository for managing and resolving class dependencies within a Laravel application.

Conclusion

Dependency Injection is an evergreen software engineering technique that has been widely adopted in the industry. It provides numerous benefits such as decoupling of code, improved testability, greater code reusability, and easier configuration. However, like any technique, it comes with tradeoffs including added complexity, a learning curve, and potential for runtime errors. When deciding to use Dependency Injection, it's important to weigh these tradeoffs against the benefits in the context of your project. When used judiciously, Dependency Injection can be a powerful tool for creating clean, maintainable, and robust software applications

If you have any thoughts or experience with dependency injection feel free to share them :)

Happy Engineering πŸ‹