Demystifying Dependency Injection and Inversion of Control: Building Maintainable and Testable Code

Discover the power of Dependency Injection and Inversion of Control. Learn how these essential principles elevate code quality, making it maintainable and testable.

Introduction

Two very common principles you come across quite often in software development are dependency injection and inversion of control. It took me a while to grasp the concepts of dependency injection and inversion of control, their true meanings, and the issues they address.

Especially in the beginning, I often found myself using these terms synonymously. I also assumed that both are two different terms for the same thing.

However, eventually it clicked and I understood that those two principles are in fact two different things. It is just that they are used together quite often. In this article I try to explore this.

A basic example

Disclaimer: All the sample code in this article is written in C# since that is the programming language I’m currently most comfortable with. I've aimed to keep the examples simple, so even if C# isn't your primary language, you should be able to follow along and apply these concepts to other programming languages.

Let’s look at the following example: Let’s imagine an application with a simple login page.

Using the model view view model - mvvm - pattern, we want to implement a view model to add the login functionality to the page:

public class LoginPageViewModel
{
	Task LoginAsync(string username, string password)
	{
		// (...)
	}
}
If you aren’t familiar with the C# programming language: Don’t get confused by the method name LoginAsync(...). It is just a language convention in C# to add the Async keyword to any method name that does some kind of asynchronous operation.

Inside the LoginAsync(...) method we want to authenticate the user using it’s username and password.

Let’s assume authenticating the user involves sending an http request. The simplest approach here would be to create an http client in the constructor and then use it inside the Login method to send the login request:

public class LoginPageViewModel
{
	private readonly HttpClient _httpClient;

	public LoginPageViewModel()
	{
		_httpClient = new HttpClient("https://your_web_api");
	}

	Task LoginAsync(string username, string password)
	{
		await _httpClient.Post("Login", username, password);
	}
}

Now, first of all, this works totally fine. And I would like to use this opportunity to point out that this might be totally fine for your application. Please do not assume that you have to use the principles we explore in this article all the time. As you will see, even though they produce code that is better maintainable and testable, they also add a fair bit of complexity. So you should always balance on whether it is actually worth to add this additional amount complexity and if the benefits you get outweigh the added complexity.

But let’s look at the problems that might occur with this implementation. By directly creating the HttpClient inside the view model class, the LoginPageViewModel now has a direct dependency on the HttpClient class.

Also, the LoginPageViewModel has to know how to set up the http client that is necessary to send the login request, e.g. it has to know the url of the web api and if any additional headers are needed. If you want to target a different url (e.g. change between a production and a staging environment), you would have to adjust the LoginPageViewModel.

Let’s see how dependency injection can help to improve the situation here.

Using dependency injection

As stated above, our goal at this point is to avoid that the LoginPageViewModel has to have the knowledge on how to set up the HttpClient.

We can achieve this by not instantiating the HttpClient inside the view model, but by injecting it as a dependency via the constructor:

public class LoginPageViewModel
{
	private readonly HttpClient httpClient;	

	public LoginPageViewModel(HttpClient httpClient)
	{
		httpClient = httpClient;
	}

	void Login(string username, string password)
	{
		httpClient.Post(...)
	}
}

At a first glance, this may doesn’t seem to look like a big change at all. But as the LoginPageViewModel now get’s a ready-to-use http client injected via its constructor, it does not have to know anything about how to setup the http client, e.g. the target url or any special headers that maybe have to be added to communicate with the web api.

So using constructor injection helps you to take responsibility from a class and thus helps you to respect the Single Responsibility Principle.

Now let’s explore how we can improve the code a bit further.

Even though the view model now does not have to know how to create the http client, it still knows that the login requires sending an http request. I would consider this a detail the view model shouldn’t have to know about. Or in other words, the LoginPageViewModel should not be concerned about how the login technically works. This is where the Separation of Concerns principle can help.

Wrapping the login logic in a LoginService

First of all, let’s create a new class called LoginService:

public class LoginService : ILoginService
{
	// (...)

	public LoginPageViewModel(HttpClient httpClient)
	{
		httpClient = httpClient;
	}

	public Task LoginAsync(string username, string password)
	{
		// Doing the login here
	}
}

As you can see, this class gets injected inject an HttpClient. It’s purpose is to encapsulate the login functionality.

Instead of using the HttpClient inside the LoginPageViewModel directly, we now inject the LoginService as a dependency:

public class LoginPageViewModel
{
	private readonly LoginService _loginService;	

	public LoginPageViewModel(LoginService loginService)
	{
		_loginService = loginService;
	}

	Task LoginAsync(string username, string password)
	{
		await loginService.Login(username, password);
	}
}

As you can see, the LoginPageViewModel now does not know any details on how the login actually works. It simply calls into the LoginService to do it. By encapsulating how the login actually works inside the LoginService, we are able to keep this implementation detail from the LoginPageViewModel.

So again, another improvement. Now let’s look at the last optimisation we want to do. There is still one thing that could be improved here: The LoginPageViewModel currently has a dependency on a concrete implementation of the LoginService.

What if we want to change the LoginService implementation that is used inside the LoginPageViewModel? We might want to inject a mock variant of the LoginService into the LoginPageViewModel during unit tests. It would require us to adjust the view model itself, which is not optimal.

As long as the LoginPageViewModel has a dependency on a concrete LoginService implementation, it is pretty hard to swap it against a mock variant, e.g. during a unit test (at least with a statically typed language like C#).

Introducing an ILoginService interface

Introducing an interface for the LoginService enables us to resolve this problem:

public interface ILoginService
{
	Task LoginAsync(string username, string password);
}

This is also revered to as the “inversion of control” principle, since by introducing an interface, we invert the dependency between the LoginViewModel and the LoginService:

The LoginService we built earlier, implements this interface:

public class LoginService : ILoginService
{
	public Task LoginAsync(string username, string password)
	{
		// Doing the login here
	}
}

Instead of using the LoginService in the LoginPageViewModel directly, we now inject an ILoginService into it:

public class LoginPageViewModel
{
	private readonly ILoginService loginService;	

	public LoginPageViewModel(ILoginService loginService)
	{
		this.loginService = loginService;
	}

	void Login(string username, string password)
	{
		// (...)
		loginService.Login(username, password);
	}
}

Conclusion

In this article, I explained how both the “dependency injection” principle and the “inversion of control” principle work and how they distinguish each other.

With a given sample app, I demonstrated which advantages you get when you use them in tandem and how it improves the maintainability as well as the testability of your code.

Thanks for reading this article! If you liked it and it helped you in any way getting a grasp on a new topic, the best way to support this blog is just by sharing it with others. However, if you would like to support me and my work more directly you can just buy me a cup of coffee (which as we know will eventually be converted into code):