DEV Community

loading...

SOLID Principles in Swift: Single Responsibility Principle

Ibrahima Ciss
Avid learner, passionate about . Writing apps for iOS/macOS. Software Engineer.
・6 min read

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 ?? "")
  }

}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

We can see the controller violates the SRP because we write some methods that are responsible for very different things.

  1. The signinUser method is responsible for making a network call and tries to log the user in
  2. Still in the signinUser, we try to convert the data we get from the API call to a domain object, a User in our example
  3. 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()
  }

}
Enter fullscreen mode Exit fullscreen mode

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)
    }
  }

}
Enter fullscreen mode Exit fullscreen mode

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)")
  }

}
Enter fullscreen mode Exit fullscreen mode

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 and Logger objects then inject the ViewModel via LoginViewController 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))
      }
    }

  }

}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }

}
Enter fullscreen mode Exit fullscreen mode

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 👊.

Discussion (2)

Collapse
hyunssu profile image
gustn3965 • Edited

You are so professional!! I like your storytelling by representing real circumstance like iOS developer. I'm waiting for another principle example you will write.
By the way, I just wanna ask you that " Will be ViewController MVVC after refactoring ? "
And I think that it is prefer to extract making URLRequest by considering SRP. How about you?

Collapse
bionik6 profile image
Ibrahima Ciss Author • Edited

Thank you for your feedback, really appreciated :)
"Will be ViewController MVVC after refactoring ?", am not sure if I understand the question.
For the last question, I've extracted the networking to a separated struct (APIClient) and then I inject it to the LoginViewModel, so we have a loosely coupled code.

Forem Open with the Forem app