SOLID Principles in Swift - Dependency Inversion Principle
Background
In this series of posts we are going to be covering the SOLID principles of software development. These are a set of principles / guidelines, that when followed when developing a software system, make it more likely that the system will be easier to extend and maintain over time. Let’s take a look at the problems that they seek to solve:
- Fragility: A change may break unexpected parts, it is very difficult to detect if you don’t have a good test coverage
- Immobility: A component is difficult to reuse in another project or in multiple places of the same project because it has too many coupled dependencies
- Rigidity: A change requires a lot of effort because it affects several parts of the project
So what are the SOLID principles?
- Single Responsibility Principle - A class should have only a single responsibility / have only one reason to change
- Open-Closed Principle - Software should be open for extension but closed for modification
- Liskov Substitution Principle - Objects in a program should be replaceable with instances of their sub types without altering the correctness of the program
- Interface Segregation Principle - Many client-specific interfaces are better than one general-purpose interface
- Dependency Inversion Principle - High level modules should not depend on low level modules. Both should depend on abstractions
In this article we will focus on the Dependency Inversion Principle.
What does it mean?
This principle has 2 main components described below:
High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces) Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions
I’ve interviewed many iOS developers over the years and I struggle to recall a single person who has actually got this part of the SOLID principles 100% right. I think a large part of this comes from the fact many use simple architectures such as MVVM that don’t break applications down into smaller layers and components/modules. This isn’t a criticism, not every app needs a VIPER/Clean architecture approach with multiple layers.
Most iOS developers I speak to come to the conclusion that this principle just means using protocols instead of concrete classes and injecting dependencies to help with testing/mocking. While this principle does rely on this to work it is not the primary purpose of the principle and exposes an issue once you start to depend on abstractions used across multiple layers / modules.
Setup
Lets setup a simple example in Xcode where we have two separate modules that depend on each other in order to provide some functionality.
If we create a simple Swift UI project using Xcode, then using File -> New -> Package create 2 new swift packages and add them to the project. One called LibraryA and one called LibraryB. Be sure to select your project in the ‘Add to’ drop down when naming your libraries. You should have something that looks like below Xcode.
Let’s start by adding a protocol and some custom data structure that we are going to be using across the 2 libraries we are working. Add a file called Protocol.swift in LibraryA and add the following code below:
We have a simple one function protocol and a small struct, this is what we will be using to separate dependencies between our 2 libraries.
Next, lets create an implementation in LibraryA that has these protocols as a dependency. Create a file in LibraryA with the name ImplementationA:
This is just a simple class that has the MyProtocol protocol has a dependency. Simple enough so far!
Now let’s create a class in LibraryB that implements the protocol that is being used in our class in LibraryA. Create a class in LibraryB called ImplementationB:
We have our class in LibraryB that is implementing the protocol we previously created in LibraryA. For this reason you will notice that we have to import LibraryA in this class as well. There is one additional step we need to do before this will compile correctly, we need to define our dependency in our package file. Let’s open the LibraryB package file and edit it to add the dependency between the 2 packages:
We won’t go into all the options you need in the swift package file but hopefully by reading this you can see we have defined a dependency and added it to our LibraryB target. If you attempt to build the project it should compile successfully.
Now let’s look at the structure of these 2 libraries and their relationship to each other.
As you can see, we have LibraryA, this contains the protocols and LibraryB that implements the protocols. In order for LibraryB to implement the protocols it needs a dependency to LibraryA in order to work.
Problem 1
Now what happens if we want to use the protocols in another Library? Let’s call this LibraryC? At moment the protocols are contained in LibraryA where they are being used and implemented by Library B.
- We can’t use the protocols in another library without adding LibraryA which may contain code and other assets we don’t want.
- We could copy the protocols to LibraryC, however if you needed LibraryA and LibraryC in the same project you would get class clashes.
- We would need to edit LibraryB to add another dependency in this case. What happens if we are not the owners of LibraryB? How would we even do this?
One solution we can try is moving the protocols from LibraryA to LibraryB. This reverses the dependencies and helps to solve the problems highlighted above. Let’s go ahead and do this now.
- Copy the Protocols.swift file we created from LibraryA to LibraryB
- Remove the import of LibraryA from the implementationB.swift
- Add an import of LibraryB to the top of implementationA.swift
- Update the LibraryB package file to remove the LibraryA dependency
- Update the LibraryA package file to add the LibraryB dependency
Now let’s see a diagram of what we have done. Now if we look at our 2 libraries at a high level the dependencies look like this.
So now, our LibraryA has a dependency on LibraryB. LibraryA is using the protocols that are defined AND implemented in LibraryB! Problem solved… right… Not entirely.
If we review the problems we discussed earlier, if LibraryC wanted to make use of the protocols and implementation in LibraryB that is now possible as LibraryB has no knowledge of LibraryA or LibraryC. However this creates a new problem…
Problem 2
What if LibraryA and LibraryC want to use different implementations of the protocols from each other? What if we introduced LibraryD that wanted to implement the protocols and be used by another library such as LibraryC? In order to facilitate this we would need to create a dependency between LibraryD and LibraryB. What problems does this create?
We are introducing a dependency to a library we don’t need, in order to implement the protocols within it. In our example there isn’t much in LibraryB but imagine LibraryB had lots of other code and its own dependencies? Now we are including all of those in our project in order to have access to the protocols.
Solution
This is where Dependency Inversion comes in. What we need to do is create a separate library for the protocols and any data that passes through them. Then, we make the dependencies between our different libraries to that one, thus removing the dependencies between our different layers. Let’s do that now.
- Create a new package and add it to your project, naming it Protocols. Now move the Protocols.swift file that we created earlier into the Protocols package. Your Xcode project file explorer should look like below:
- Now lets edit the dependencies of our packages so that both LibraryA and LibraryB depend on protocols. Your package files for LibraryA and B should now look like the below:
- Now update the imports of your implementation files so that they import Protocols instead. Your implementation files should look like the below:
Let’s have a look at what we have done here. We have moved the dependencies between the layers into a separate package and are now pointing both sides at that rather than one way or the other. Let’s update our diagram to show how this helps us.
Now if we want to use LibraryA, B, C, or D it does not matter in our dependency graph. They all point to the protocols and data and can be used interchangeably without the need to modify the libraries, so they depend on each other. We also avoid importing any unnecessary classes that we don’t need in order to satisfy the dependencies.
This is what dependency inversion is. It’s separating protocols and data dependencies from the dependencies themselves and putting them into their own package. This way you completely separate the layers from each other, and they can be used together without any knowledge of each other. Awesome!