SOLID in iOS - Single Responsibility Principle?
Why should an iOS developer care
I have often noticed myself ignoring basic yet essential computer science concepts throughout my career. I hear and have said far too often that iOS development does not have the same complexity as other types of engineering.I was wrong, and if you share this opinion I would encourage you to reconsider. You should not strive to over-engineer your app, but you should do your best to apply these basic guidelines in their most simple form. You will notice a considerable difference in the codebase you're working in.
What is SOLID
SOLID is an acronym that stands for:
- S: Single responsibility principle
- O: Open–closed principle
- L: Liskov substitution principle
- I: Interface segregation principle
- D: Dependency inversion principle
We will just be covering the Single responsibility principle in this article.
The S
in SOLID
The S represents the Single Responsibility Principle. It was introduced by Robert C. Martin(aka. Uncle Bob). It means exactly what it says. Every object, module, and function you create should only have one responsibility. Do keep in mind these are guidelines, not rules.
Okay, but why…
- The more responsibility something has, the more complex it becomes to maintain by you and your team.
- It becomes less reusable. This is due to high coupling and often leads to rigid code. It also becomes easier to introduce more bugs due to an increase in side effects that scale with the increase in responsibilities.
- It makes the implementation explicit and understood by everyone
Take a look at the example below,
protocol NetworkController {
func save(user: User)
}
protocol AnalyticsController {
func logEvent()
}
struct User {
let api: NetworkController
let analytics: AnalyticsController
let name: String
let height: Int
let birthday: Date
// First responsibility
func calculateBirthday() -> Int? {
let now = Date()
let calendar = Calendar.current
let ageComponents = calendar.dateComponents([.year], from: birthday, to: now)
return ageComponents.year
}
// Second responsibility
func save() {
logUserSaveEvent()
api.save(user: self)
}
// Third responsibility
func logUserSaveEvent() {
analytics.logEvent()
}
}
If we recall the SRP principle, every class should have a single responsibility. So, let's review each responsibility the type has currently.
- Calculating birthday
- Persisting the User's information
- Logging save events
Let's look at how we could potentially simplify the User's struct. I've come up with the following.
protocol NetworkController {
func save(user: User)
}
protocol AgeCalculator {
func calculateAge() -> Int?
}
struct User: AgeCalculator {
let name: String
let height: Int
let birthday: Date
// First responsibility
func calculateAge() -> Int? {
let now = Date()
let calendar = Calendar.current
let ageComponents = calendar.dateComponents([.year], from: birthday, to: now)
return ageComponents.year
}
}
struct UserController {
private let api: NetworkController
func save(user: User) {
api.save(user: user)
}
}
Here I've created a protocol called AgeCalulator. This may seem like a small change, but you have to remember that Swift encourages composition(pieces you can put together). So, if we were to make another object called Animal, we could reuse our AgeCalulator somewhere else.
Next, I've extracted our networking dependency from our User object altogether. By creating a UserController, I've moved the responsibility from our User object to our controller object.
Last, I've removed the responsibility of our analytics event tracking. You may have noticed it's been removed entirely. It isn't the User or user controller's job to track events. You may be asking who should be responsible for it, which is exactly the question you should be asking. I'll elaborate more in another article.
A more practical example
Now I know the above example is a very simple example. So let’s take a look at a more complex example, something that you may have even done yourself in the past.
For some context, let's consider that we are building the following feature in our app:
Use Case
We want our users to be able to track events on a day-to-day basis. To start we want our users to be able to log their exercises, meals, and weight.
Requirements
- When tracking Weight we need to be able to track a number
- When tracking a meal we need to be able to track the Name, Amount, and the Unit of Measurement
- When tracking an exercise we want to be able to track the duration of the exercise as well as the type of exercise.
- We need to track every event on our backend
Below I've created our data models
struct WeightEvent: LocalTrackableEvent {
let eventType: TrackerEvent = .weight
let weight: Double
}
struct MealEvent: LocalTrackableEvent {
typealias Ingredient = (name: String, amount: Int, unitOfMeasurement: String)
let ingredient: [Ingredient]
let eventType: TrackerEvent = .meal
}
struct ExerciseEvent: LocalTrackableEvent {
let duration: TimeInterval
let exercise: String
let eventType: TrackerEvent = .exercise
}
enum TrackerEvent {
case weight
case meal
case exercise
}
Below is a not-so-uncommon example of how someone might right this class.
class TrackerViewController: UIViewController {
// MARK: - Properties
var trackerEvent: TrackerEvent
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
layoutViewController(forTrackerOfType: .exercise)
}
// MARK: - Actions
@IBAction func saveEventTapped(_ sender: Any) {
switch trackerEvent {
case .exercise:
let tenMinutes = 60.0 * 10.0
let event = ExerciseEvent(duration: tenMinutes, exercise: "jogging")
postTrackableEvents(event) { result in
// ... navigate to some confirmation screen
}
case .weight:
let event = WeightEvent(weight: 150)
postTrackableEvents(event) { result in
// ... navigate to some confirmation screen
}
case .meal:
let salad: MealEvent.Ingredient = (name: "Salad", amount: 1, unitOfMeasurement: "cup")
let steak: MealEvent.Ingredient = (name: "Steak", amount: 11, unitOfMeasurement: "oz")
let event = MealEvent(ingredient: [salad, steak])
postTrackableEvents(event) { result in
// ... navigate to some confirmation screen
}
}
}
// MARK: - UI Methods
func layoutViewController(forTrackerOfType tracker: TrackerEvent) {
switch tracker {
case .exercise: break
// ... layout UI
case .weight: break
// ... layout UI
case .meal: break
// ... layout UI
}
}
// MARK: - Networking methods
func postTrackableEvents(_ event: WeightEvent, completion: @escaping Result<WeightEvent, Error>) -> ()) {
// ... post event to server & hanlde errors
}
func postTrackableEvents(_ event: MealEvent, completion: @escaping Result<MealEvent, Error>) -> ()) {
// ... post event to server & hanlde errors
}
func postTrackableEvents(_ event: ExerciseEvent, completion: @escaping Result<ExerciseEvent, Error>) -> ()) {
// ... post event to server & hanlde errors
}
}
If we recall the SRP principle, every class should have a single responsibility. So, let's review each responsibility the TrackerViewController currently has.
- Setting up the UI
- Saving an event
- Handling networking errors
- Managing state based on the type of tracker
- Navigation to the success screen
It is not super uncommon for a view controller to ultimately end up housing a multitude of implementation details, and can often lead to view controller classes with 100s if not 1000s of lines of code.
Here is an example of how we could potentially break up the logic into something a little more manageable.
Here are a few protocols I made to make the implementation a little better.
protocol RemoteTrackableEvent {}
protocol TrackableEventFields {}
protocol LocalTrackableEvent {
var eventType: TrackerEvent {get}
}
typealias PostTrackableEventHandler = (Result<RemoteTrackableEvent, Error>) -> ()
protocol APIClient {
func postTrackableEvents(_ event: LocalTrackableEvent, completion: @escaping PostTrackableEventHandler)
}
Next, I break apart TrackerViewController into their own respective classes and then opt to subclass it.
class ExerciseViewController: TrackerViewController {
var api: APIClient?
override func layoutViewController() {
// ... layout UI
}
override func saveEvent() {
let tenMinutes = 60.0 * 10.0
let event = ExerciseEvent(duration: tenMinutes, exercise: "jogging")
api?.postTrackableEvents(event) { result in
// ... navigate to some confirmation screen * handle errors
}
}
}
class WeightTrackerViewController: TrackerViewController {
var api: APIClient?
override func layoutViewController() {
// ... layout UI
}
override func saveEvent() {
let event = WeightEvent(weight: 150)
api?.postTrackableEvents(event) { result in
// ... navigate to some confirmation screen * handle errors
}
}
}
class MealTrackerViewController: TrackerViewController {
var api: APIClient?
override func layoutViewController() {
// ... layout UI
}
override func saveEvent() {
let salad: MealEvent.Ingredient = (name: "Salad", amount: 1, unitOfMeasurement: "cup")
let steak: MealEvent.Ingredient = (name: "Steak", amount: 11, unitOfMeasurement: "oz")
let event = MealEvent(ingredient: [salad, steak])
api?.postTrackableEvents(event) { result in
// ... navigate to some confirmation screen * handle errors
}
}
}
Okay, what I've done is incredibly simple.
- I made a few protocols to allow us to have a reusable postTrackableEvents method
- I created an APIClient, giving the responsibility of networking to our APIClient instead of our TrackerViewController
- I subclassed TrackerViewController and created three new trackers view controllers. MealTrackerViewController , WeightTrackerViewController, and ExerciseViewController.
This simple change makes every view controller have a single responsibility, abstracting all knowledge of the different types of trackers there are, removing redundant networking requests, and removing having a bloated file full of UI components.
P.S. I could have created a ViewModel but we will save that for a different article.
The more you can remove responsibilities from your class, the easier it becomes to read and maintain.
Hopefully, you found this useful and that you are able to take your code to the next level by remembering the Single Responsibility Principle.