Identifying Side Effects in Swift

Identifying Side Effects in Swift

This is a hard problem in any language. When we are interacting with APIs, we are constantly making requests on objects. Let’s say we have an object x. If we say x.doSomething(), this will create a new state on this object. We can only assume what parts of x is changed as a result of doSomething function, by looking at the method name or documentation. And this assumption may fail at any time, for example, with a newer version of the API. This means, with every move, we are creating side effects in the system and we just don’t care. #YOLO

I will now try to overcome this problem by refactoring an imaginary iOS app. Say we have a profile screen in which we show information about the current user. I will first model the data in the screen.

enum AccountStatus {
  case basic, pro
}

protocol ProfileViewModel {
  var name: String? { get }
  var dateOfBirth: Date? { get }
  var email: String? { get }
  var accountStatus: AccountStatus? { get }
  func fetchUser(_ completion: () -> Void)
  func refreshAccountStatus(_ completion: () -> Void)
}

If we look at this API, obviously name, dateOfBirth, email and accountStatus fields are initially empty, and we should use fetchUser and refreshAccountStatus functions to get this data from server.

Let’s see it in action:

viewModel.fetchUser {
  // Update name, date of birth and email labels.
}
viewModel.refreshAccountStatus {
  // Update account status label.
}

Do you see the problem now?

We just hopedfetchUser call to update name, dateOfBirth, email; and refreshAccountStatus call to update accountStatus. Then synced UI according to this assumption.

These function calls are just our way of saying "please do something" to viewModel object, and we can’t possibly know what changes these calls make internally. For example, refreshAccountStatus call might also update name, dateOfBirth and email, we just can’t know it for sure as an outsider.

So the point is, every time you make an assumption about what an API does, your UI might show an incorrect state. And this is really bad. We should be able to identify every side effect and take action with no exceptions.

Let’s move things around.

enum AccountStatus {
  case basic, pro
}

enum ProfileStateChange {
  case name, dateOfBirth, email, accountStatus, callsInProgress
}

protocol ProfileState {
  var name: String? { get }
  var dateOfBirth: Date? { get }
  var email: String? { get }
  var accountStatus: AccountStatus? { get }
  var callsInProgress: UInt { get }
  var onChange: ((ProfileStateChange) -> Void)?
}

protocol ProfileViewModel {
  var state: ProfileState { get }
  func fetchUser()
  func refreshAccountStatus()
}
  • Definition of data is moved out of view model. First, it’s cleaner. Second, it’s always nice to put state in a different object (preferably a struct) because you can get a snapshot of the system by copying this object with no effort. For example, this might be really useful if you want to keep history to implement rollback functionality.
  • Introduced a property named onChange to identify the changes in state. This block is called whenever a property changes in state, with its unique identifier. No side effects can escape.
  • Completion blocks are removed from view model. Because they are useless. We just say "please fetch user" and then onChange block notifies us if anything changes anyway.

Let’s see it in action:

viewModel.state.onChange = { change in
  switch change {
  case .name:
    // Update name label.
  case .dateOfBirth:
    // Update date of birth label.
  case .email:
    // Update email label.
  case .accountStatus:
    // Update account status label.
  case .callsInProgress:
    // Update loading view.
}

When we first define this, it means we’re ready for any change that can happen on the state. So at this point, we just make requests to the view model and don’t care about any callbacks.

override func viewDidLoad() {
  super.viewDidLoad()
  viewModel.fetchUser()
}
func redeemPurchasesTapped() {
  viewModel.refreshAccountStatus()
}

How about testability? We can simulate any action and see the state changes in order they happen.

let viewModel = ProfileViewModel()
let user = User(
  name: "Göksel Köksal",
  dateOfBirth: Date(),
  email: "gokselkoksal@gmail.com",
  accountStatus: .pro
)
viewModel.service = MockedUserService(user: user)
// We mocked the service that `fetchUser` call is using.
// It will return the user object we passed w/o making
// the actual network call.
var changes: [(change: ProfileStateChange, snapshot: ProfileState)] = []
viewModel.state.onChange { [unowned viewModel] change in
  changes.append((change, viewModel.state))
}
viewModel.fetchUser()

XCTAssert(changes.count == 6)
XCTAssert(changes[0].change == .callsInProgress)
XCTAssert(changes[0].snapshot.callsInProgress == 1)
XCTAssert(changes[1].change == .name)
XCTAssert(changes[1].snapshot.name == user.name)
XCTAssert(changes[2].change == .dateOfBirth)
XCTAssert(changes[2].snapshot.dateOfBirth == user.dateOfBirth)
XCTAssert(changes[3].change == .email)
XCTAssert(changes[3].snapshot.email == user.email)
XCTAssert(changes[4].change == .accountStatus)
XCTAssert(changes[4].snapshot.accountStatus == user.accountStatus)
XCTAssert(changes[5].change == .callsInProgress)
XCTAssert(changes[5].snapshot.callsInProgress == 0)

Bottom Line

We should not let untraceable bugs into our system. So simply:

  • Be aware of the side effects you are creating.
  • Handle state changes properly.
  • Write tests.

For full implementation, you can checkout this sample project: Movies. I often update this repo to reflect my latest architectural decisions.


Hope you liked it! Please let me know what you think. All suggestions and questions are welcome.


Image Credit: Photo by Raphael Lovaski on Unsplash