Swift/UnwrapDiscover about Swift, iOS and architecture patterns

Dependency injection principles

October 27, 2020

Using dependency inside an app is a nice technique to decouple dependencies and maintain them over time.

But to decouple our code no need for a dependency injection framework! Actually introducing one before decoupling might even have the opposite desired effect...

So today let's have a look to dependency injection principles with no framework and how we can structure our code to ease its use.

Principles

Dependency injection principle is simple: you do dependency injection when giving objects than others rely on either through init or properties. This specific part is also called Inversion of Control: instances are not defined by the class but given to it (but very often Inversion of Control and dependency injection naming are used interchangeably)

Here is an simple dependency injection example where we inject URLSession to our class MovieRepository:

class MovieRepository {
  let urlSession: URLSession

  init(urlSession: URLSession) {
      self.urlSession = urlSession
  }
}

let instance = MovieRepository(urlSession: URLSession())

On the other hand this code is what we would call hardcoded dependencies:

class MovieRepository {
  let urlSession: URLSession

  init() {
      self.urlSession = URLSession()
  }
}

let instance = MovieRepository()

Put it another way dependency injection is merely just that: not hardcoding instances 😳.

To efficiently work a dependency injection framework will actually also rely on another pattern, ServiceLocator.

Opening classes

To be compliant and ready for injection the first thing we have to start with is getting rid of hardcoded dependencies. Depending on your codebase removing them right away might be cumbersome or even error prone. In this case best advice is to take a step smaller and make injection optional:

class MovieRepository {
  let urlSession: URLSession

  init(urlSession: URLSession = URLSession.shared) {
      self.urlSession = urlSession
  }
}

let instance = MovieRepository()

Here we provide a default value for urlSession. It allow us to open our class to injection while still maintaining backward compatibility by providing a default (hardcoded) dependency. Doing so we will be able to migrate little by little our classes without breaking the whole project before moving to a dependency injection framework.

Fixing relationships

Opening our classes for injection is however not always that easy. Let's take this code for instance:

class MovieViewModel {
  let repository: Repository

  init(urlSession: URLSession, jsonDecoder: JSONDecoder) {
    self.repository = Repository(urlSession: urlSession, jsonDecoder: jsonDecoder)
  }
}

class Repository {
  let network: Network

  init(urlSession: URLSession, jsonDecoder: JSONDecoder) {
    self.network = Network(urlSession: urlSession, jsonDecoder: jsonDecoder)
  }
}

class Network {
  init(urlSession: URLSession, jsonDecoder: JSONDecoder) {
    self.urlSession = urlSession
    self.jsonDecoder = jsonDecoder
  }
}

let viewModel = MovieVidewModel(urlSession: URLSession(), jsonDecoder: JSONDecoder())

Let's see what going on here. Each object has its own set of dependencies:

  • Network depend on URLSession and JSONDecoder
  • Repository depend on Network
  • Finally, MovieViewModel depend on Repository

Because MovieViewModel depend on Repository it also implicitly depend on Repository dependencies. These are called transitive dependencies.

If we look to our code we can see that one object dependencies (Network) is carried over the others (MovieViewModel, Repository). As such MovieViewModel and Repository will (strongly) depend not only on their own dependencies but also on their transitive ones.

This approach come with multiple downside:

  • 🐫 Redundancy: you will pass same object instance at multiple places
  • 🏗️ Complex refactoring: multiple classes will be impacted when changing one class dependencies. This is a cascade effect.
  • 🔊 Noise: a class like MovieViewModel does not use URLSession nor JsonDecoder yet we still have to pass them at init.

Good luck to use a dependency injection framework on top of that 🤼.

To fix that all we need to do is clean our init methods and only give direct dependencies to our classes. In other word: MovieViewModel and Repository must not reference their transitive dependencies but only their own dependencies. Once we've done that we can then apply default value technique we saw before.

class MovieViewModel {
  let repository: Repository

  init(repository: Repository) {
    self.repository = repository
  }
}

class Repository {
  let network: Network

  init(network: Network) {
    self.network = network
  }
}

class Network {
  init(urlSession: URLSession = .share, jsonDecoder: JSONDecoder = JSONDecoder()) {
    self.urlSession = urlSession
    self.jsonDecoder = jsonDecoder
  }
}


let viewModel = MovieVidewModel(
    repository: Repository(
        network: Network()
    )
)

Our code relationships are now clean and we are now ready to use a dependency injection framework.

Conclusion

Even if you don't intend to make dependency injection in your app a goal, respecting your object relationships/dependencies help reducing coupling and as such code complexity.

It will also increase your code readability by explicitly stating objects relationships. On the other hand it will probably make instance creation a little bit more verbose. That's where dependency injection frameworks might come in handy.