From Hobbyist to Professional iOS Developer: Single Responsibility Principle - Part 1

So you know how to code in general, understand the object-oriented programming¹, learned Swift, and completed at least one iOS Development Course (if you’re not there yet, these links are a great place to start!).
You can write a simple app using Model-View-Controller and it works well, but is your code any good? Could it be done any better? Is it clean (and what on earth does that mean)?
Is your architecture any good? Should you use a different one? What about design patterns? These were some of the questions I’ve had when I started, and answering them helped me to step up to a professional level.
In this series of articles, I will try to layout a good software development foundations in the context of iOS development, so you can answer these questions yourself.
So let’s start with the topic that is mentioned in every job interview: SOLID principles.
SOLID
SOLID is an acronym of 5 principles made by Uncle Bob that are fundamental to writing good object-oriented code:
- Single responsibility principle
- Open/closed principle
- Liskov substitution principle
- Interface segregation principle
- Dependency inversion principle
Maybe you have already heard about those principles, or even remember them. But if I were to ask you for an example, would you be able to write a code for each principle and then explain it? That’s where most people have a problem: in practical day-to-day usage. In order to write good code, you need to understand why the bad code is bad. So to give you the whole picture I will:
- Give a simple definition of each SOLID principle
- Give an example of a bad code, which in some way breaks discussed principle
- Explain why the principle is broken in the given example
- Refactor the code, so the example complies with the discussed principle
- Explain why now it complies with it
Every example that I will present you with, will work in Swift Playground
To make the size of this article reasonable, I will split it by each SOLID principle per part. So let’s start with the first one!
Single Responsibility Principle
“A class should have only one reason to change”²
What this rule says is that given class should have only one reason to exist, and therefore there should be only one reason to change it. When creating a class, you should strictly define what’s it raison d’etre and stick to it. Make classes that have one specialized purpose and you’re in the clear.
Example 1
Let’s look at the very simple example of a class that’s supposed to send emails with attachments:
/*
This example works well in Swift Playground
For tests porpuses create a file in ~/Documents/Shared Playground Data/some_file.dat
Use exactly this file's location, otherwise Playgrounds's sandbox will prevent the files from loading (code will work as there was no attachment)
*/
import Foundation
import PlaygroundSupport
struct Email {
enum Style {
case plaintext
case html
}
var body: String
var style: Style
}
class EmailSender {
func sendEmail(from: String, to: String, email: Email, attachmentFileName: String? = nil) {
//logic to prepare file
var attachment: Data?
if let unwrappedAttachmentFileName = attachmentFileName {
let fullPath = playgroundSharedDataDirectory.appendingPathComponent(unwrappedAttachmentFileName, isDirectory: false)
attachment = try? Data(contentsOf: fullPath)
}
//logic to send email
if let unwrappedAttachment = attachment {
//logic to send email with attachment
print("\(unwrappedAttachment) of attachment send, along with message:\n\"\(email.body)\"")
} else {
//logic to send email without attchment
print("message send:\n\"\(email.body)\"")
}
}
}
let sender = EmailSender()
print("-------------------------")
let myEmailWithAttachment = Email(body: "I'm sending you some_file.dat as you've asked! :)", style: .plaintext)
sender.sendEmail(from: "johnappleseed@apple.com", to: "myfriendsemail@host.com", email: myEmailWithAttachment, attachmentFileName: "some_file.dat")
print("-------------------------")
let myEmailWithOnlyText = Email(body: "Hello, how are you?", style: .plaintext)
sender.sendEmail(from: "johnappleseed@apple.com", to: "myfriendsemail@host.com", email: myEmailWithOnlyText)
Class EmailSender
breaks the Single Responsibility Principle, because there are 2 reasons to change it:
- Any change in the logic of sending emails would need the
EmailSender
class to be changed. - Change in the logic of how we load files from our operating system would also need a change in the
EmailSender
class.
Only the first reason for change makes sense. Sending the email itself shouldn’t have anything to do with getting files from our operating system. EmailSender
should have all the data it needs to send an email, and it shouldn't care, how that data was acquired.
The example that complies to the Single Responsibility Principle would look like that:
/*
This example works well in Swift Playground
For tests porpuses create a file in ~/Documents/Shared Playground Data/some_file.dat
Use exactly this file's location, otherwise Playgrounds's sandbox will prevent the files from loading (code will work as there was no attachment)
*/
import Foundation
import PlaygroundSupport
struct Email {
enum Style {
case plaintext
case html
}
var body: String
var style: Style
}
class EmailSender {
func sendEmail(from: String, to: String, email: Email, attachment: Data? = nil) {
//logic to send email
if let unwrappedAttachment = attachment {
//logic to send email with attachment
print("\(unwrappedAttachment) of attachment send, along with message:\n\"\(email.body)\"")
} else {
//logic to send email without attchment
print("message send:\n\"\(email.body)\"")
}
}
}
class FileReader {
static func prepareDataFromFile(dataFileName: String) -> Data? {
let fullPath = playgroundSharedDataDirectory.appendingPathComponent(dataFileName, isDirectory: false)
return try? Data(contentsOf: fullPath)
}
}
let sender = EmailSender()
print("-------------------------")
let myEmailWithAttachment = Email(body: "I'm sending you some_file.dat as you've asked! :)", style: .plaintext)
sender.sendEmail(from: "johnappleseed@apple.com", to: "myfriendsemail@host.com", email: myEmailWithAttachment, attachment: FileReader.prepareDataFromFile(dataFileName: "some_file.dat"))
print("-------------------------")
let myEmailWithOnlyText = Email(body: "Hello, how are you?", style: .plaintext)
sender.sendEmail(from: "johnappleseed@apple.com", to: "myfriendsemail@host.com", email: myEmailWithOnlyText)
Now there are two classes, and each of them has only one responsibility. FileReader
handles files and EmailSender
sends emails (regardless of how the attachments are created).
Example 2
The previous example broke the SRP in a very simple way, but in real life, people tend to break it in a little bit more nuanced fashion. The following example will look correct at the very first sight, but when you’ll take a closer look, you will see that it’s actually breaking the SRP.
Imagine a Hotel Application that holds room reservations, guests’ personal information, room details (number of beds, number of bathrooms, etc.).
A typical real-life example that breaks the Single Responsibility Principle would look like this:
class Hotel {
var rooms: [Room] = []
}
class Room {
var isReserved = false //it's not reserved by default
var numberOfBeds: Int
private (set) var guestsInTheRoom: [Guest] = []
init(numberOfBeds: Int) {
self.numberOfBeds = numberOfBeds
}
func addGuestThatWillOccupyTheRoom(guestFirstName: String, guestLastName: String, age: Int) {
if guestsInTheRoom.count < numberOfBeds {
let guest = Guest(firstName: guestFirstName, lastName: guestLastName, age: age)
guestsInTheRoom.append(guest)
} else {
print("Cannot add new guest to this room! No beds left!")
//good place to put some proper error handling here instead of a simple printout, but overkill for this example
}
}
}
class Guest {
var firstName: String
var lastName: String
var age: Int
init(firstName: String, lastName: String, age: Int) {
self.firstName = firstName
self.lastName = lastName
self.age = age
}
}
let hotelCalifornia = Hotel()
let room1 = Room(numberOfBeds: 1)
let room2 = Room(numberOfBeds: 2)
hotelCalifornia.rooms = [room1, room2]
hotelCalifornia.rooms[0].addGuestThatWillOccupyTheRoom(guestFirstName: "Mark", guestLastName: "Appleseed", age: 21)
hotelCalifornia.rooms[0].addGuestThatWillOccupyTheRoom(guestFirstName: "Mark", guestLastName: "Pear", age: 22) //no room for you, sorry!
hotelCalifornia.rooms[1].addGuestThatWillOccupyTheRoom(guestFirstName: "Jane", guestLastName: "Doe", age: 45)
hotelCalifornia.rooms[1].addGuestThatWillOccupyTheRoom(guestFirstName: "John", guestLastName: "Doe", age: 46)
hotelCalifornia.rooms.forEach() { $0.guestsInTheRoom.forEach(){ print($0.firstName) } } //print all guests names in all rooms
If the hotel owner buys an air conditioner for one of the rooms, storing this information will change the implementation of the Room
class, and that's perfectly understandable.
Imagine there is a new law, which prohibits hotels to gather data about the age of customers. To implement this change we would need to change both Room
and Guest
classes. Why is removing the age of guests from our app affecting the implementation of the Room
class at all?! Class Room
breaks the Single Responsibility Principle, because there are 2 reasons to change it:
- Any change in the logic of hotel rooms would need the
Room
class to be changed. For example: adding the air condition. - Change in the logic of creating a guest would also need the change in the
Room
class inside theaddGuestThatWillOccupyTheRoom(guestFirstName:guestLastName:age:)
method.
As you can see, all it takes is just to call a constructor in a bad place, to create the class that is having more than one reason to change.
The example that complies to the Single Responsibility Principle would look like this:
class Hotel {
var rooms: [Room] = []
}
class Room {
var isReserved = false //it's not reserved by default
var numberOfBeds: Int
private (set) var guestsInTheRoom: [Guest] = []
init(numberOfBeds: Int) {
self.numberOfBeds = numberOfBeds
}
func addGuestToTheRoom(guest: Guest) {
if (guestsInTheRoom.count < numberOfBeds) {
guestsInTheRoom.append(guest)
} else {
print("Cannot add new guest to this room! No beds left!")
//good place to put some proper error handling here instead of a simple printout, but overkill for this example
}
}
}
class Guest {
var firstName: String
var lastName: String
var age: Int
init(firstName: String, lastName: String, age: Int) {
self.firstName = firstName
self.lastName = lastName
self.age = age
}
}
let hotelCalifornia = Hotel()
let room1 = Room(numberOfBeds: 1)
let room2 = Room(numberOfBeds: 2)
hotelCalifornia.rooms = [room1, room2]
let guest1 = Guest(firstName: "Mark", lastName: "Appleseed", age: 21)
let guest2 = Guest(firstName: "Mark", lastName: "Pear", age: 22)
let guest3 = Guest(firstName: "Jane", lastName: "Doe", age: 45)
let guest4 = Guest(firstName: "John", lastName: "Doe", age: 46)
hotelCalifornia.rooms[0].addGuestToTheRoom(guest: guest1)
hotelCalifornia.rooms[0].addGuestToTheRoom(guest: guest2) //no room for you, sorry!
hotelCalifornia.rooms[1].addGuestToTheRoom(guest: guest3)
hotelCalifornia.rooms[1].addGuestToTheRoom(guest: guest4)
hotelCalifornia.rooms.forEach() { $0.guestsInTheRoom.forEach(){ print($0.firstName) } } //print all guests names in all rooms
Now we have made the implementation of the addGuestToTheRoom(guest:)
independent from the Guest
implementation, by removing the Guest
's constructor call. This way addGuestToTheRoom(guest:)
method doesn't really care how the Guest
objects are created. Just pass the Guest
object (regardless of how it's initialised) to it, and it will work.
So now our change in the law can be implemented without affecting the Room
class at all, because now it has only one reason to change (the room logic itself).
How to define the reason to change?
Not every example is as black and white as our Email and Hotel apps. If a reason for a class to exist is broad enough, we can always pretend that we have only one reason to change it.
You could create a class DataManager
instead of two separate classes: InputReader
and OutputWriter
. Both input and output would be managed by the DataManager
. This class would obviously have 2 reasons to change (handling input and handling output), but with smart wording we made it seems like a one reason: data management. This is arguably still a bad design choice!
But it goes both ways: Be too specific and you end up with hundreds of ridiculously interconnected classes, that could easily be one.
Look at the String
struct. It does a lot, but all of it makes sense in one given context. We don't want to have too specialized structs/classes like StringCapitalizer
, StringUppercaser
, StringLowercasser
. That would be absurd!
So the classes and their reasons to exist (and change) should be specialised enough. The fine balancing is the key here.
Considering this, the original principle can also be ( and was!) worded like that:
“Gather together the things that change for the same reasons. Separate those things that change for different reasons.”
So that’s it!
Now you know all there is to Single Responsibility Principle and should be able to come up with examples for it on your own! In the next article, I will focus on the Open/Closed Principle.
. . .
¹Yes, I know this link is for a Java course, but it’s a very good language to learn basics. Java doesn’t do too much job for the programmer (like Python does) so you can understand how everything works, yet it does all the memory management magic, leaving you free from worrying about pointers (unlike C++ or Objective-C). And this particular Java Masterclass course has a proven record of teaching people not only Java itself but also object-oriented programming.
²It’s worth noting that sometimes the term “software module” is used instead of the “class”, to make this principle more generic.
This article was originally posted on Medium – From hobbyist to professional iOS Developer: Single Responsibility Principle