Dependency Injection: Why it matters not only for testing

Most articles highlight how Dependency Injection simplifies testing and improves modularity and maintainability. In this post I will show how DI accelerates development and boosts developer experience

Dependency Injection (DI) is a topic that many of you might have already heard about it. Mostly is discussed how it helps with testing and how it improves application modularity, in this post I will try to show how dependency injection helps with development speed developer experience (DX).

What is Dependency Injection?

Dependency Injection is a way to provide an object’s dependencies (other objects it needs to work) from the outside rather than having it construct them internally.

Without DI:

class UserService {
    private Database db;

    public UserService() {
        this.db = new MysqlDatabase(); // hardcoded dependency
    }

    public void updateBirthday(User user) {
       this.db.updateBirthday(user.id, user.birthday);
    }
}

Without DI (even worse?):

class UserService {
    public void updateBirthday(Context context, User user) {
       context.getDatabase().updateBirthday(user.id, user.birthday); // dependency on global context and its internal state
    }
}

These two examples show how the UserService class is tightly coupled to a specific Database implementation or a global context.

With DI:

class UserService {
    private Database db;

    public UserService(Database db) {
        this.db = db; // injected from outside
    }

    public void updateBirthday(User user) {
       this.db.updateBirthday(user.id, user.birthday);
    }
}

This allows for greater flexibility, as you can easily swap out the Database implementation without changing the UserService code.

How DI Helps with Testing

Dependency Injection makes unit testing easier because you can inject mocks or stubs instead of real implementations.

@Test
public void testUserService() {
    Database mockDb = mock(Database.class);
    UserService service = new UserService(mockDb);

    // run assertions on service behavior
}

No need to mock static singletons or global services. Dependencies are passed in cleanly.

To not be confused with Service Locator (and similar patterns)

Sometimes Dependency Injection is confused with the Service Locator pattern or static access patterns.

The Service Locator is a pattern that provides a way to retrieve dependencies from a central registry.

class UserService {
    private Database db;

    public UserService(ServiceLocator locator) {
        this.db = locator.get('database');
    }
}

Here, the class fetches its own dependencies. This hides the real dependencies and makes testing harder (it is necessary to mock the ServiceLocator as well).

With static access (sometimes seen as Facade pattern), it is even more problematic.

class UserService {
    private Database db;

    public UserService() {
        this.db = Context.getDatabase(); // static access, or Facade pattern
    }
}

Testing becomes difficult because you cannot easily replace the static context with a mock or stub, you are forced to rely on whatever is offered by the static context, which is often a singleton or global service.

Ordinary code suffers as well, at the point in time when Context.getDatabase() has been called, you have no guarantee that the database parameters have been set correctly (hostname, user, password, etc).

Dependency Injection assisted by modern frameworks

Many modern frameworks provide built-in support for Dependency Injection, making it easier to manage dependencies.

Without Framework support:

var udpLogger = new UdpLogger();
var logger = new Logger();
var userRepo = new UserRepository();
if (developmentMode) {
    var userService = new UserService(userRepo, logger);
} else {
    var userService = new UserService(userRepo, udpLogger);
}

As you can see, the code is not very clean, and it is hard to manage dependencies.

With Framework support:

@Component
@Profile("production")
class UdpLogger 
{
    // implementation details
}

@Component
@Profile("development")
class Logger 
{
    // implementation details
}

@Component
class UserRepository 
{
    // implementation details
}

@Component
class UserService 
{
    // implementation details
}

As you can see, we have added a few @Component annotations and the framework takes care of managing dependencies for us.

Depending on the framework and programming language, you can use annotations, XML Configurations, or other mechanisms to define how dependencies are wired together. Most programming languages have frameworks that support Dependency Injection, it does not matter if you are using Java, PHP, Python, or JavaScript.

Developer Experience: The Real Win of Dependency Injection

The biggest, and often underappreciated, benefit of Dependency Injection is developer experience.

With Dependency Injection assisted by frameworks:

  • Developers instantly know where and how dependencies are provided. There is no need to search through the codebase to find out how a class is constructed or what its dependencies are, nor there is need to read documentation.
  • Teams follow the same conventions. When everyone uses Dependency Injection, the codebase becomes more consistent and easier to navigate. There is no dependency on the taste a single developer (or a few) had.
  • Onboarding new developers is faster. New team members can quickly understand how dependencies are managed and how to add new ones without having to learn different wiring styles, mostly "recycling" the experiences from past jobs (if they have been using DI in the past).

Without DI assisted by frameworks, every developer might wire dependencies differently:

// Dev A (with DI)
var userService = new UserService(new UserRepository(new Logger()));
userService.updateBirthday(user);

// Dev B (with DI)
var db = createDb();
var userService = buildUserService(db);
userService.updateBirthday(user);

// Dev C (No DI, with Service Locator)
var locator.create('database', new MysqlDatabase());
var userService = new UserService(locator);
userService.updateBirthday(user);

// Dev D (No DI, Static method class or Facade)
Context.setDatabase(new MysqlDatabase());
var userService = new UserService();
userService.updateBirthday(user);

// Dev E (No DI, with context passed around on method calls)
context.setDatabase(new MysqlDatabase());
var userService = new UserService();
userService.updateBirthday(context, user);

In all these examples, when the UserService will need a new dependency to implement some new feature, the developer will have to figure out how to wire it up independently of having used or not Dependency Injection.

When using Dependency Injection assisted by frameworks, if the UserService needs a new dependency it can just ask it, and the framework will take care of wiring it up.

@Component
class UserService {
    private Database db;
    private PresentCalculator presentCalculator;

    public UserService(Database db, PresentCalculator presentCalculator) {
        this.db = db;
        this.presentCalculator = presentCalculator;  
    }

    public void updateBirthday(User user) {
       this.db.updateBirthday(user.id, user.birthday, presentCalculator.calculatePresent(user.birthday));
    }
}

If the PresentCalculator is already available in the framework, the developer can just use it without worrying about how to instantiate it or where it comes from.

If the PresentCalculator is a new class, the developer can create it and annotate it with @Component,

@Component
class PresentCalculator {
    // implementation details
}

Reading code (and figuring out how things work) is where developers spend most of their time (not writing code), with dependency injection assisted by frameworks, there is no need to figure out how things are wired together.

This way, the developer can focus on the business logic rather than worrying about how to make things work together.

DI to fight over-engineering

Dependency Injection also helps to fight over-engineering. When developers are not sure how to wire things together, they might end up creating complex solutions that are hard to maintain. With Dependency Injection, developers can focus on the business logic and let the framework handle the rest.

Conclusion

In this post I have tired to show how Dependency Injection is not just a testing tool, or a way to improve maintainability, but a powerful pattern that enhances developer experience and accelerates development speed.

Dependency Injection, despite having decades of history, and wide adoption in languages such as Java, PHP, .NET, seems to be still underappreciated in many communities, especially in the JavaScript world.

In the next post, I will discuss the state of Dependency Injection in JavaScript and TypeScript, and why some of the excuses to not do so are not valid anymore.

design-patterns, dependency-injection, software-development, testing, developer-experience

Want more info?