SOLID Principles for Becoming a Better iOS/Swift Developer
While SOLID might be old (at least in internet years), they're one of those evergreen pieces of advice that go beyond language or platform specifics. SOLID is an acronym that stands for five guiding principles for OOP programming.
- Single Responsibility Principle
- Open/closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
It's not just good advice for OOP, but programming in general.
Single Responsibility Principle (SRP)
The single responsibility sounds deceivingly simple: One class should only have one responsibility. A UserFetcher
class that fetches new users. A Payment
class that processes purchases in your app. A LocationTracker
app that tracks your users location. These are all examples of classes with a single responsibility.
Because they do one thing, these classes are very simple. Just by reading their names, you know exactly what they do. They're also easy to write: knowing exactly what the class is for means you know which methods you need and which of them need to be exposed as public methods.
They're also much easier to maintain. The code of large classes with lots of things to do often gets intertwined like earbuds that have been in your pocket for too long. In order to change or fix something, you first have to meticulously untangle each of the responsibilities just to start thinking about where the problem is.
We often violate SRP because it's easy. The biggest problem is the phenomenon of one little feature. It's a trap we constantly keep falling into: I'll just add this one little feature to the class. If you add that one little feature five times, suddenly it's five little features. With each feature you are exponentially adding more and more complexity. Before you know, you created a huge, tangled mess.
iOS developers are one of the worst classes of programmers when it comes to following SRP. Case and point: our good old friend, the view controller.
What does a UIViewController
do? Well, it assembles the views on the screen. It also prepares table view cells. Oh, it also navigates to another screen. Sometimes it also calls network requests. It also, also, keeps track of the state of our screen. A typical UIViewController
is about 12 responsibilities removed from SRP. (This problem is often referred to as Massive View Controller.)
This is why view controllers often become those classes that everyone is afraid to touch, and nobody understands what's going on. There are just too many of those one little features.
What can I do?
First off, stop adding little features. Shift your brain to thinking about modules, components and APIs. Get out of the mindset of patching and hacking, and into the mindset of creating libraries. Write small classes that do one thing. If you have a large problem, break it down. Write classes for each subset of the problem, and then write another one that will use those classes.
Identify the responsibilities of your view controller, and split them up. The easiest candidates are table view delegates and data sources. Make a separate class that does that thing. The data source can be any class, not just the view controller.
And always pay attention to the size of your classes. The number of lines can be a very good warning sign that some things should probably be split up.
Open/closed Principle
While SRP is deceivingly simple, the open/closed principle sounds more complex than it really is. It goes as follows: software entities should be open for extension, but closed for modification. Sounds pretty fancy, right?
What that sentence is trying to say is that it should be easy to add new features to your class. Earlier, I mentioned an example of a UserFetcher
class as an example of SRP. Let's say that class has one method, fetchUsers
, which fetches a JSON of all your users from a database, parses it, and returns it.
class UserFetcher {
func fetchUsers(onComplete: @escaping ([User])-> Void) {
let session = URLSession.shared
let url = URL(string: "")!
session.dataTask(with: url) { (data, _, error) in
guard let data = data else {
print(error!)
onComplete([])
return
}
let decoder = JSONDecoder()
let decoded = try? decoder.decode([User].self, from: data)
onComplete(decoded ?? [])
}
}
}
But hold on! A super-intelligent race of beings from Mars has invaded earth and taken you hostage! They want you to make the app work with Martian
objects, instead of User
objects. And they want it fast!
With our UserFetcher
class, we need to change the whole implementation, even to the point of renaming the class, for it to work with Martians. If we took a different approach, this change would be a lot easier. What if instead of a UserFetcher
, we wrote a generic Fetcher
, for any Decodable
list of things?
class Fetcher<T: Decodable> {
func fetch(onComplete: @escaping ([T])-> Void) {
let session = URLSession.shared
let url = URL(string: "")!
session.dataTask(with: url) { (data, _, error) in
guard let data = data else {
print(error!)
onComplete([])
return
}
let decoder = JSONDecoder()
let decoded = try? decoder.decode([T].self, from: data)
onComplete(decoded ?? [])
}
}
}
typealias UserFetcher = Fetcher<User>
The implementation is almost the same, except we changed where we mention User
to a generic T
, which conforms to decodable. With this approach, we only need to change that single line at the bottom to support Martians.
typealias MartianFetcher = ListFetcher<Martian>
As you can see, following the open/closed principle might just save your life one day!
Okay, the example with the Mars attack might be a little over the top. But if there's one thing that's constant about requirements, it's that they change. You need to help your future self and allow for easy ways to respond to design changes in the future.
Liskov Substitution Principle
Here's one principle that sounds very mathy and academic. If you think that name is bad, it has another one: substitutability. As in, the ability to be substituted with something else. And that's exactly what this principle means.
LSP says that any function working with a class should work also with any of those class' subclasses. This principle is a note of how you override methods. The user of that method shouldn't be able to see a difference between your version and the base class' method.
Let's go back to our MartianFetcher
. The Martians are happy with how the code works on Earth, but they don't like how it works when they go back to Mars: there's no internet there, so they don't see any data. They want offline mode.
So, you go ahead and make a subclass of the Fetcher
, which fetches data from a file on the device which contains cached Martians. This is a good idea since creating a subclass means you don't have to change code that works with Fetcher
, since the API stays the same.
You're also really scared, so you take your keyboard and you quickly hack that feature into the app.
class FileFetcher<T: Decodable>: ListFetcher<T> {
override func fetch(onComplete: @escaping ([T])-> Void) {
let json = try? String(contentsOfFile: "martians.json")
guard let data = json?.data(using: .utf8) else {
return
}
let decoder = JSONDecoder()
let decoded = try? decoder.decode([T].self, from: data)
onComplete(decoded ?? [])
}
}
In your rush, you made a mistake. Here's how the base class' method works: if there is an error, the method calls the completion handler with an empty array. Your version works a bit differently. If there is an error, nothing happens. This means your UI won't update if there is an error. The Martians are angry now.
There are two ways you can fix it, and one of them is wrong. You can go into your view controller and check whether your Fetcher
is actually a FileFetcher
and update the UI.
fetcher.fetch { martians in
self.martians = martians
self.tableView.reloadData()
}
if fetcher is FileFetcher {
tableView.reloadData()
}
This is wrong. It's wrong because the whole point of a subclass is that you don't have to change the user of the class, but only the subclass. If for every subclass we had to have a different way of using it, then inheritance would be completely pointless. (Same goes for protocols and composition!)
The correct way to fix this would be to align the overridden method to work like the base class' method, or create a whole new class.
Whenever you're checking if an instance is of a specific type, it's probably code smell, and a violation of LSP. And most likely there's an easier way to do things.
Interface Segregation Principle
The Martians are generally happy with your app but they want more features. They want to see the details of a Martian when they click on it. Being the protocol-oriented programmer you are, you create a protocol to deal with this problem.
protocol MartianFetcher {
func getMartians(onComplete: ([Martian])-> Void)
func getMartian(id: String, ([Martian])-> Void)
}
You create a class that implements this protocol, and hook it up to your app. But here's the problem: The list screen doesn't need getMartian
, and the details screen doesn't need getMartians
.
Having methods available that you don't need adds clutter and noise, making it harder to work with the API. It also leads to all the problems discussed in the Single Responsibility Principle section.
You can make a very easy fix that solves this problem. Just make two different protocols.
protocol MartiansFetcher {
func getMartians(onComplete: ([Martian])-> Void)
}
protocol MartiansFetcher {
func getMartian(id: String, onComplete: ([Martian])-> Void)
}
You don't actually have to change your implementation, the same class could implement both of these protocols. But in your list view controller you will use an instance of MartiansFetcher
, without extra clutter. This lets you add functionality to the class that fetches Martians without complicating things for the users of the class.
This is the reason why Swift has Decodable
, Encodable
and Codable
. Not everyone can conform to all, and not everyone needs all functionality. Apply SRP to your protocols as well as classes.
Dependency Inversion Principle
Here's another principle with a scary name. This principle says that high level things in your app, like your view controller, should not depend directly on low level things, like a networking component. Instead, it should depend on an abstraction of that component. In practice, this means that the view controller should not use a Fetcher
class, but a Fetchable
protocol.
The reason is to reduce coupling. String coupling occurs when your class depends heavily on the implementation of another class. It might be calling a lot of methods, making assumptions about the inner working of that class, or use variable names that tie it to that specific class.
Strong coupling is bad because it makes it harder to change the code base. If you're using a CoreDataService
, and suddenly want to switch to a RealmService
, you best hope your view controller doesn't rely heavily on the former.
The way to go around this issue is to use a DatabaseService
protocol, that the CoreDataService
will implement.
protocol DatabaseService {
func getUsers()-> [User]
}
class CoreDataService: DatabaseService {
// ...
}
In your view controller, you pretend that the class doesn't exist, and just use an instance of that protocol.
let databaseService: DatabaseService = CoreDataService()
You do this because protocols are less specific than classes. A class has a specific name and specific methods you can use. On the other hand, protocols are, by definition, abstract. More than one class can implement a protocol, making them perfect for reducing coupling.
To change to Realm, all you need to do is make a new class that conforms to the same protocol. Because you didn't rely on a specific implementation, you don't need to change code in your view controller. It's a huge time saver!
If you think about coupling from the beginning it will save your butt in the long run.
More What You'd Call Guidelines
We went through all the SOLID letters!
One thing to remember is that these principles, while very useful, are not rules. They're tools. As their creator, Robert C. Martin puts it, "They are statements on the order of 'An apple a day keeps the doctor away.'". So keep them in mind, but allow for compromises.
Now you know a few principles to make you a better coder, as well as what to do in the event of an attack from Mars. Happy coding!