SOLID Principles in Swift: Single Responsibility Principle
Ibrahima Ciss
Posted on January 11, 2021
This week, let’s revise the S.O.L.I.D. principles and have an in-depth look at the first and probably most well-known principle: the Single Responsibility or SRP.
This principle states: A class should have one, and only one reason to change. I think this definition can be a little bit abstract for some. Think of it this way: An object should only have a single reason to change (this doesn’t help either 😭) or ** A class should exactly have just one and only one job** (much better 😁) or ultimately A class should only have a single responsibility.
Violating this principle causes classes to become more complex and harder to test and maintain. However, the challenging part is to see whether a class has multiple reasons to change or if it has many responsibilities.
Committing the sin:
I'm going to give an example in the iOS world where we see a view controller having more than one responsibility. It's a mistake many young developers make in their first days as an iOS Developer. And generally in a MVC architecture, the controller is the place where we throw a lot of unrelated things because I guess it's easier and more convenient to stay at one place and see all the code associated with that particular controller.
final class LoginViewController: UIViewController {
private var emailTextField: UITextField!
private var passwordTextField: UITextField!
private var submitButton: UIButton!
// initializers...
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
private func setupView() {
// ... other view related code here
submitButton.addTarget(self, action: #selector(submitButtonTapped), for: .touchUpInside)
}
@objc private func submitButtonTapped() {
signinUser(email: emailTextField.text ?? "", password: passwordTextField.text ?? "")
}
}
This is straight forward, a simple login screen with two text fields: an email and password fields and a submit button. When the button is tapped, we try to log the user in. This seems to be perfectly okay until we add the remaining methods.
We're going to put them in an extension like this:
extension LoginViewController {
// 1
private func signinUser(email: String, password: String) {
let url = URL(string: "https://my-api.com")!
let json = ["email": email, "password": password]
let jsonData = try! JSONSerialization.data(withJSONObject: json, options: [])
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = jsonData
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error {
DispatchQueue.main.async {
self.showErrorAlert(message: error.localizedDescription)
}
}
guard let data = data else {
self.showErrorAlert(message: "sorry, could not log in, try later")
return
}
// 2
let user = try! JSONDecoder().decode(User.self, from: data)
self.log(user: user)
DispatchQueue.main.async {
self.showWelcomeMessage(user: user)
}
}
task.resume()
}
private func showErrorAlert(message: String) {
// logic to show an error alert
}
private func showWelcomeMessage(user: User) {
// logic to show a welcome message
}
// 3
private func log(user: User) {
// log user logic
}
}
We can see the controller violates the SRP because we write some methods that are responsible for very different things.
- The
signinUser
method is responsible for making a network call and tries to log the user in - Still in the
signinUser
, we try to convert the data we get from the API call to a domain object, aUser
in our example - We have a
log
method that'll probably log the user's information to a remote service.
By putting the code for these methods and actions into the LoginViewController
class, we have coupled each of these actors to the others, and we can now see the controller has more than one responsibility. If we refer to Apple documentation, a view controller's main responsibilities include the following:
- Updating the contents of the views, usually in response to changes to the underlying data.
- Responding to user interactions with views.
- Resizing views and managing the layout of the overall interface.
- Coordinating with other objects—including other view controllers—in your app. Now let’s see how we can improve things and respect the SRP.
Refactoring: the cure
When we want our classes to respect the SRP, this generally means we must create additional objects that'll have a single responsibility and use different techniques to make them communicate with each other. Let's see how we can apply this in our example.
The first thing to do might be to extract the logic for performing an API request call to a separate object.
struct APIClient {
func load(from request: URLRequest, completionHandler: @escaping (Result<Data, Error>) -> ()) {
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error {
return completionHandler(.failure(error))
}
completionHandler(.success(data ?? Data()))
}
task.resume()
}
}
The advantage of doing this is we now have a dedicated object that is only responsible for performing an API call and nothing else. Sweet, let's add another object for decoding the data from the API to a domain object.
struct Decoder<A> where A: Decodable {
func decode(from data: Data) throws -> A {
do {
let object = try JSONDecoder().decode(A.self, from: data)
return object
} catch {
fatalError(error.localizedDescription)
}
}
}
Again, we have a very simple object with a single responsibility: decoding data to a domain object.
Now let's add the final object responsible for logging the user.
protocol Loggable {
var infos: String { get }
}
struct Logger<A> where A: Loggable {
func log(object: A) {
print("doing some logging stuff with \(object.infos)")
}
}
You see in this example, all the objects have a single method. Of course, they could have many, but the point is, it's totally fine to have a class or struct with a single method and if you add more, ask yourself if the method you're about to add does belong to the class.
Now that we have our different object responsible for specific tasks, the next question is, how would we connect them? We have several solutions here, but the two commons ones in the iOS world are probably:
- Injecting all these objects to the
LoginViewController
via constructor injection - Create a ViewModel class that holds instances of the
APlClient
,Decoder
andLogger
objects then inject the ViewModel viaLoginViewController
constructor. I find the last solution a better option, so the view controller is not aware of network calls, decoding, and other stuff, and with the arrival of SwiftUI, we tend to use this pattern a lot. We'll have something like this:
class LoginViewModel {
private var logger: Logger<User>
private var apiClient: APIClient
private var decoder: Decoder<User>
init(logger: Logger<User>, apiClient: APIClient, decoder: Decoder<User>) {
self.logger = logger
self.apiClient = apiClient
self.decoder = decoder
}
func signin(email: String, password: String, completionHandler: @escaping (Result<User, Error>)->()) {
let url = URL(string: "https://my-api.com")!
let json = ["email": email, "password": password]
let jsonData = try! JSONSerialization.data(withJSONObject: json, options: [])
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = jsonData
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")
apiClient.load(from: request) { response in
switch response {
case .success(let data):
let user = try! self.decoder.decode(from: data)
self.logger.log(object: user)
completionHandler(.success(user))
case .failure(let error):
completionHandler(.failure(error))
}
}
}
}
Now we can use this view model in the LoginViewController
class and let him handle the user sign-in:
final class LoginViewController: UIViewController {
private var emailTextField: UITextField!
private var passwordTextField: UITextField!
private var submitButton: UIButton!
private let viewModel: LoginViewModel
init(viewModel: LoginViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
private func setupView() {
// ... other view related code here
submitButton.addTarget(self, action: #selector(submitButtonTapped), for: .touchUpInside)
}
@objc private func submitButtonTapped() {
viewModel.signin(email: emailTextField.text ?? "",
password: passwordTextField.text ?? "") { response in
switch response {
case .success(let user): self.showWelcomeMessage(user: user)
case .failure(let error): self.showErrorAlert(message: error.localizedDescription)
}
}
}
private func showErrorAlert(message: String) {
DispatchQueue.main.async {
// logic to show an error alert
}
}
private func showWelcomeMessage(user: User) {
DispatchQueue.main.async {
// logic to show a welcome message
}
}
}
I don't know about you, but I think this is a much cleaner code. The controller is not aware of anything; he just handles user inputs and responds to them. We have a loosely coupled, more maintainable, and testable code now. That's all about the S.O.L.I.D principles, and as a bonus, our view controller is no more bloated (we have a joke in iOS and it's said MVC stands for Massive View Controller 😅).
Conclusion
The core of the SRP is each class should have its own responsibility, or in other words, it should have exactly one reason to change. If you start identifying multiple consumers and multiple reasons for a class to change, chances are you need to extract some of that logic into their dedicated classes. But as we said above, the most challenging part of this principle is knowing the object boundaries or identifying when an object begins to have more than one responsibility or reason to change. This comes with practice and constant reflection; we should always ask ourselves the right questions in order to move in the right direction. Next week, we'll go through the Open-Closed Principle. Until then, have a nice week, and may the force be with you 👊.
Posted on January 11, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.