How to Write SOLID Swift
The five SOLID principles explained in Swift
What is SOLID, and how can it help iOS developers? SOLID is a collection of five software-design principles focused on curating a maintainable and scalable codebase. Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion principles will help you plan clean, legible Swift projects and refactor monolithic view controllers.
Single Responsibility Principle
You probably have heard of MVC. And, no, I’m not talking about the Model-View-Controller pattern; I’m talking about Massive View Controller. This is when all your frontend details and functional logic are mixed in your ViewController class. How might we avoid Massive View Controllers? You could implement a design pattern (MVVM, the actual MVC, etc..), but before selecting a pattern, you should first understand why we want to separate tasks into different classes.
Enter the Single Responsibility Principle… The SRP states that classes should, you guessed it, have a single responsibility. Does this mean creating a new class for every single function? No, but we should group functions that are highly similar in purpose. Hint: High Cohesion.
Imagine a single-view-app that fetches a user’s photos from the cloud and also keeps some local copies. Instead of writing the fetch and cache logic in your ViewController code, create a class responsible for syncing local photos with your database, and another class responsible for caching and loading cached data.
Maybe the concept of separation already feels natural to you, but now you can sound substantially fancier by saying “Single Responsibility Principle”. Interview bonus points.
Open/Closed Principle
The Single Responsibility Principle teaches to write highly-focused classes. The Open/Closed Principle will help keep these classes accountable for their functionality. The OCP is the idea that classes are open for extensions yet closed for modifications. You use the ‘open’ concept every time you subclass or create an extension.
When do we need code to be ‘closed’? You need closed code when implementations should not be overridden, such as methods essential to a class’s functional integrity. Imagine writing a test case expecting a function to return some value; if a subclass that overrides the function and returns a different value, the test would fail.
How can we close off code in Swift? The “final” keyword will prevent the overriding of an implementation. In the example below, class A closes the function, foo, preventing B from overriding the implementation.
class A {
final func foo() { print("foo") }
}class B: A {
override func foo() { print("bar") }
// Error! Instance method overrides a 'final' instance method
}
Liskov Substitution Principle
Firstly, make sure that critical logic is closed, as per the Open/Closed Principle. As for the more flexible, open logic, use the Liskov Substitution Principle to enforce that subclasses are substitutable for their base classes. E.g. subclasses should pass the same requirements/tests as their parents. If you expect a parent class to act a certain way, all subclasses should also fulfill those duties.
You’ve probably heard how the classic rectangle/square analogy breaks this principle, so I won’t bore you to repeat it. Instead, let’s think of an analogy you might encounter in the Swift world. Imagine UIButton, UIImage, and their parent, UIView. If you are using a UIView to reference either a UIButton or UIImage, you can expect that every responsibility of UIView will be carried out by either subclass (animations, layouts, etc.). You can thank the Liskov Substitution Principle for that.
How can we enforce that Liskov Substitution? First, see the Open/Closed Principle for closing off critical functionality. Then, you can write some handy unit tests to ensure all subclasses maintain are substitutable for the parent.
Interface Segregation Principle
The key to writing meaningful interfaces is to write several focused protocols rather than to one large one. Think Single Responsibility Principle for protocols. Let’s refactor a sample to follow this principle. In this example, we are looking at some post interface a social media app might display in some scrollable cells.
protocol Post {
func upload()
func download()
func repost() -> Post
func like()
func unlike()
func addComment(_ comment: String)
func deleteComment(_ comment: String)
}
How might we make posts flexible and create focused protocols? We can break Post and group these functions around functionality, such as upload/download, like/unlike, etc..
protocol Syncable {
func upload()
func download()
}protocol Likable {
func like()
func unlike()
}protocol Commentable {
func addComment(_ comment: String)
func deleteComment(_ comment: String)
}protocol Sharable {
func repost()
}
How does this help us? We can now have flexible data to display with different conformances. For example, sponsored ads may not enable comments and reposts, while user posts would. Our code base might look something like this:
struct SponsoredPost: Syncable { /* … */ }
struct UserPost: Syncable, Likable, Commentable, Sharable { /* … */ }
struct ContentView { var data: [Syncable] }
Now that we’ve learned to write useful and meaningful interfaces/protocols, we can take advantage of effective dependency inversion.
Dependency Inversion Principle
The Dependency Inversion Principle, aka Dependency Injection, takes advantage of concise interfaces derived from the Interface Segregation Principle. Dependency suggests writing code that relies on abstractions instead of concrete implementations. Dependency inversion, therefore, gives our code more freedom in separating use-cases from the logic. Ultimately, this reduces the coupling of our codebase.
If you are familiar with polymorphism, you understand that an object can belong to multiple types at once. Dependency Inversion relies on this idea because we want to select the most relevant abstraction of an object. Let’s take a look at some source code that defines Animal, Dog, and Terrier:
protocol Animal {
func speak() -> String
}class Dog: Animal {
func speak() -> String { return “Woof” }
func stickToungeOut() -> String { return “:P” }
}class Terrier: Dog {
override func speak() -> String { return “Yip” }
}
In our codebase, let’s say you have a terrier party where you want all the terriers to speak. At first, it can be tempting to write a function like:
func party(_ guests: [Terrier]) {
guests.forEach { print($0.speak()) }
}
Dependency Inversion, however, tells us to use the highest possible abstraction. Because the party function only uses the speak function, the most relevant abstract type is Animal. Here is a revised version of the party function:
func party(_ guests: [Animal]) {
guests.forEach { print($0.speak()) }
}
Why does relying on abstractions help you? You may only need a terrier party for now, but your future self will thank you when you want a monkey party. Furthermore, this can future proof your UI from your logic details. Imagine you created an app backed with Firebase, and now you are moving to a new host server. The switch would be so much easier if your UI depended on some Server protocol (instead of directly depending on Firebase). You could wrap implementational details of the different APIs in some Server-conforming class and easily swap them out… without ever touching your ViewController code!
Continuing with our example, if we want to write a crazy party function where guests speak and stick their tongue out, can you imagine which parameter type we should use? The Dog class is the most abstract class that has both speak and stickTongueOut. According to Dependency Inversion, our function would look like this:
func crazyParty(_ guests: [Dog]) {
guests.forEach { print($0.speak(), $0.stickToungeOut()) }
}
Pop quiz on polymorphism: if you passed in the array [Terrier()], what would be printed to the console? “Woof :P” or “Yip :P”? Remember that the guest's parameter is a Dog array…
The answer: don’t let the parameter type fool you into thinking Dog’s speak method is called. A Terrier instance is still a Terrier instance, and the instance overrides Dog’s speak method.
Conclusion
The SOLID principles are a great start for building a manageable codebase. Keeping these principles in mind before your hand hits the keyboard will help you scale in the future.
For additional research, see Design Principles and Design Patterns, a paper where Robert C. Martin introduces these 5 principles.