Why choose MVVM for your app's architecture

Michał Laskowski

May 20, 2021


Anecdote - What some prominent engineers said, a few years ago

Here are a few reactions of two engineers from a very large company you for know for sure, when we presented simple projects built with MVVM and RxSwift:

And then Combine and SwiftUI happened. Devs can use it for some time already, but it is often not yet used in commercial projects.
It seems Apple is creating its own reactive framework, and its own take on MVVM architecture. SwiftUI views are a good example how view layer should look like, while view models feel very natural to be an ObservableObject.
Lessons learned with MVVM and RxSwift, will pay back with Combine and SwiftUI, if you can’t use the latest technologies yet.

So why use it?

There are multiple reasons to do so, but what will be presented in this article focuses mostly on:

What is MVVM - recap

Model View ViewModel architecture is very similar in concept to Model View Presenter. Presenter would force changes on a view layer, which implies creating an interface for the view, because Presenter needs to know its capabilities.
In MVVM, view model exposes properties that view layer can observe for changes. All the view needs to do is to follow changes of view model’s properties. This time a view model knows nothing about a view. The whole idea in both cases, and many other architectures, is that there is a layer that holds business logic, and keeps it out of a view. This makes business logic much more testable, because it is very hard to test view layer, which responsibility should be to visually display information.

Let’s get real

Let’s take a look at a real task, one that happens often and exists in many applications. A sign up screen.

Few things are happening here:

Based on that, we can already figure out how view model will look like:

struct SignUpViewModel { // why struct? Read on
    // inputs
    let emailInput = PublishSubject<String>()
    let passwordInput = PublishSubject<String>()
    let submitTap = PublishSubject<Void>()
    let signInTap = PublishSubject<Void>()
    let continueToSubscriptionTap = PublishSubject<Void>()

    // outputs
    let emailError: Observable<String?>
    let passwordError: Observable<String?>
    let signUpCompleted: Observable<String>
    let inProgress: Observable<Bool>
}

All inputs are PublishSubjects, which means they do not hold state, and do not start with a default value. View layer will be able to push events into it. It would be ideal to use AnyObserver struct, but this is ‘good enough’, without any additional boilerplate.
All outputs are Observable, which means they can be observed outside of view model, in this case by a view that implements a sign in screen.

Why using a struct for a view model? In this scenario, we are getting a constructor for all the outputs, for free, which will come in handy soon. It could be a class too, and to be honest for SwiftUI this would be a must to conform to ObservableObject. And most often our View Models are also classes. But in cases like this one, they could be structs too.

Also for SwiftUI, all your inputs would become functions, and all outputs would be @Published properties.

Step 1 - that’s it, you can open a PR

This is all you need for your first Pull Request.
Let that sink in for a moment. Maybe you are used to big Pull Requests that actually accomplish something. But if you are in a big team, there are more important things than hoarding the work just for yourself. It’s better to integrate early and often.

This is enough to start the work. As in the title, we are focusing on MVVM in big projects. This means we probably have many people working on the codebase, and we want to have as many as possible working on the same feature to finish it as early as possible for testing, acceptance, stakeholders input, etc.

Step 2 - The view

Let’s work on the view. It should be able to respond to changes in the view model, by providing inputs and observing outputs. The rest of the app is not ready yet, but it doesn’t matter and that’s one of the points of this article. You may have heard about TDD before. What we will do, Point-Free calls Witness-oriented design. They are also the authors of one of the snapshot testing libraries. And yes, we use it a lot.

With our view model struct, we can write our tests like this. This is possible because all outputs are declared, and all inputs are defined. This makes Swift compiler generate a constructor for all uninitialized properties.

 func testInitialLook() {
    let viewModel = SignUpViewModel(
        emailError: .never(),
        passwordError: .never(),
        signUpCompleted: .never(),
        inProgress: .just(false),
        screenCompleted: .never()
    )

    viewController.viewModel = viewModel

    verify(viewController.view) // or snapshot in 
                                // a UIWindow instance
}

We can simulate any state we want, by changing the view model.

func testWithErrors() {
    let viewModel = SignUpViewModel(
        emailError: .just("Some email error"),
        passwordError: .just("Some password error"),
        signUpCompleted: .never(),
        inProgress: .just(false),
        screenCompleted: .never()
    )
    viewController.viewModel = viewModel

    verify(viewController.view)
}

or:

func testInProgress() {
    let viewModel = SignUpViewModel(
        emailError: .never(),
        passwordError: .never(),
        signUpCompleted: .never(),
        inProgress: .just(true),
        screenCompleted: .never()
    )
    viewController.viewModel = viewModel

    verify(viewController.view)
}

But of course, we need a view controller that we will be able to test:

final class SignUpViewController: UIViewController, SignUpViewControlling {
    var viewModel: SignUpViewModel!

    @IBOutlet private var emailInput: UITextField!
    @IBOutlet private var passwordInput: UITextField!
    @IBOutlet private var signInButton: UIButton!
    @IBOutlet private var submitButton: UIButton!
    @IBOutlet private var emailError: UILabel!
    @IBOutlet private var passwordError: UILabel!

    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        submitButton.rx.tap.bind(to: viewModel.submitTap).disposed(by: disposeBag)
        signInButton.rx.tap.bind(to: viewModel.signInTap).disposed(by: disposeBag)

        emailInput.rx.text.bind(to: viewModel.emailInput).disposed(by: disposeBag)
        passwordInput.rx.text.bind(to: viewModel.passwordInput).disposed(by: disposeBag)

        viewModel.emailError.bind(to: emailError.rx.text).disposed(by: disposeBag)
        viewModel.passwordError.bind(to: passwordError.rx.text).disposed(by: disposeBag)

        // TODO: disable button or show spinner, by binding to viewModel.inProgress
    }

    private func showSubscriptionCompletedAlert() {
        // TODO: show alert and call viewModel.continueToSubscriptionTap on button tap
        viewModel.continueToSubscriptionTap.onNext(())
    }
}

All that happens here, is a simple binding between UI elements, and view models inputs and outputs. There is no mapping, no transformation, all values that change are provided by the view model directly. And that makes the view easy to test.

This should be a second pull request, with however many tests you deem necessary to start. You can always add more complexity and edge cases handling later.

Step 3 - View Model

We already have a pure struct of view model from the first PR. 
Let’s leave the definition like that. We will extend it with logic, by creating an additional constructor in an extension. By doing so, we keep the auto-generated one, that we used for snapshot tests.

Let’s treat the new constructor as piping, a way to provide input/output logic, and the piping creates logic to put into our outputs - Observables.

extension SignUpViewModel {
    // dependency injection with a default argument
    init(signUpProvider: SignUpProviding = SignUpProvider()) {
        let isValidEmail = emailInput.map { $0.isValidAsEmail }

        emailError = isValidEmail.map { isValid -> String? in
            if isValid {
                return nil
            } else {
                return ""  // TODO: localized error
            }
        }

        // use action to ensure once-at-a-time sign up, and easier error handling
        let action = Action<(String, String), AuthSubscriber>(enabledIf: isValidEmail) { 
            (email, password) in
            return signUpProvider.signUp(email: email, password: password)
        }

        // TODO: map errors from action or from input field validation

        signUpCompleted = submitTap.flatMap {
            action.execute().materialize() // catch error to not end the stream
        } // TODO: filter out errors, handle them separately for view to show
        ...

We have used a few additional techniques here. One of them is dependency injection, and we used Action. Action is a very useful library that supplements RxSwift, originally designed and included in ReactiveSwift.

As we aim to maintain the code over a long time in our big team, we also want to write unit tests.

final class SignUpViewModelTests: XCTestCase {

    private var signUpProvider: SignUpProviderMock!
    private var viewModel: SignUpViewModel!

    override func setUp() {
        super.setUp()
        signUpProvider = SignUpProviderMock()
        viewModel = SignUpViewModel(signUpProvider: signUpProvider)
    }
}

private final class SignUpProviderMock: SignUpProviding {
    var calls: [(String, String)] = []
    var result: Single<Bool>!
    func signUp(email: String, password: String) -> Single<Bool> {
        calls.append((email, password))
        return result
    }
}

By mocking SignUpProvider, we can control the execution of tests and create different scenarios.

func testSendsParametersOnce() {
    let testEmail = "email@email.com"
    let testPassword = "password"
    viewModel.emailInput.onNext(testEmail)
    viewModel.passwordInput.onNext(testPassword)

    signUpProvider.result = .just(true)
    XCTAssertEqual(signUpProvider.calls.count, 0)
    viewModel.submitTap.onNext(())
    XCTAssertEqual(signUpProvider.calls.count, 1)
    // TODO: check if proper email and password sent
}
func testValidatesEmail() {
    let testEmail = "email!email.com"
    let testPassword = "password"
    viewModel.emailInput.onNext(testEmail)
    viewModel.passwordInput.onNext(testPassword)

    var emailError: String!
    viewModel.emailError.observe { error in
        emailError = error
    }

    XCTAssertEqual(signUpProvider.calls.count, 0)
    viewModel.submitTap.onNext(()) // assuming validates email on tap, 
                                   // could be different
    XCTAssertEqual(signUpProvider.calls.count, 0)
    XCTAssertEqual(emailError, "Some error that should be here")
}

Create view model logic, unit test it, open a Pull Request. 
One or more. As many as you need. 
Still no need to run the app.

Step 4 - Profit? Not yet

It seems that we have all the layers we need in Model View ViewModel architecture. SignUpProvider is defined (it would be part of repository layer in architectures that makes the distinction between model and tools that provide models), we have view layer and tests for it, and same for view model.

There is, however, one small detail that MVVM does not solve. Navigation.
There is nothing in the architecture that would tell us how to go from one view controller to another. Apple has created segues, but they need to be triggered from UIViewController, which means that our view layer takes on the responsibility for navigating between app’s screens. This may be fine for simple navigation, but it may become much less trivial when you can navigate to different view controllers based on user input, feature toggles, or even A/B tests run in your application.
It is pretty clear then, that you are inserting business logic into a view layer, that was promised to be one thing without it.

There is however a way to deal with it, and is known under many names:

One of the first articles to mention coordinators was from Soroush Khanlou. Later it was followed by others, like Krzysztof Zablocki.

Like always, there are different flavors of coordinators/flow controllers, but in ViacomCBS we follow a pattern similar to the one Zablocki presented. Our apps have flow controllers in a structure like:

For some we even have two different implementations. With dependency injection, we can choose which one is used in our apps.

Let’s see how it would look like in our example:

protocol AuthenticationFlowControlling {
    func startFlow(with completion: @escaping ((AuthSubscriber) -> Void))
}

final class AuthenticationFlowController: AuthenticationFlowControlling {

    private let screenProvider: AuthenticationScreenProviding
    private let screenPresenter: ScreenPresenting

    private let disposeBag = DisposeBag()

    init(screenProvider: AuthenticationScreenProviding = AuthenticationScreenProvider(),
         screenPresenter: ScreenPresenting) {
        self.screenProvider = screenProvider
        self.screenPresenter = screenPresenter
    }
    ...

protocol AuthenticationScreenProviding {
    func signUpScreen() -> SignUpViewControlling
    func signInScreen() -> SignInViewControlling
    func resetPasswordScreen() -> ResetPasswordViewControlling
}

final class AuthenticationScreenProvider: AuthenticationScreenProviding {
	func signUpScreen() -> SignUpViewControlling {
	  	let viewController = SignUpScreenViewController()
	  	viewController.viewModel = SignUpViewModel()
	  	return viewController
	}
	...
}

 func startFlow(with completion: @escaping ((AuthSubscriber) -> Void)) {
    let signUpScreen = screenProvider.signUpScreen()

    signUpScreen.screenFinished.subscribe(onNext: { (result) in
        switch result {
        case .signInRequested:
            self.showSignIn(with: completion)
        case .accountCreated(let isUserAuthorized):
            completion(isUserAuthorized)
        }
    }).disposed(by: disposeBag)

    screenPresenter.pushViewController(signUpScreen.viewController, animated: true)
}

private func showSignIn(with completion: @escaping ((AuthSubscriber) -> Void)) {
    let signInScreen = screenProvider.signInScreen()

    signUpScreen.screenFinished.subscribe(onNext: { authSubscriber in
        completion(authSubscriber)
    }).disposed(by: disposeBag)
}

ScreenProviders are simple classes that are hiding the fact that view models exist so that it will also be easy to test flow controllers. ScreenProviders’ methods create a screen, and inject a view model into it. Also, closures are used to give back results back to flow controllers. This way, if one of your screens wants to use a different architecture, for example MVP, or plain MVC, you do not need to refactor your flow controllers. They are agnostic about how views are constructed.

ScreenProviders’ methods could take parameters into methods if needed, and also you could inject different implementations of screen providers if needed - for example to A/B test two different sets of screens for your application.

We need to make a few adjustments to our view model to expose when screen is done with the work:

enum SignUpFinishResult {
    case signInRequested
    case accountCreated(user: AuthSubscriber)
}

struct SignUpViewModel {
    // inputs
    ...
    // outputs
    ...
    // output for flow controller
    let screenCompleted: Observable<SignUpFinishResult>
}

extension SignUpViewModel {
    init(signUpProvider: SignUpProviding = UIApplication.dependencyContainer.requiredResolve()) {
        ...
        screenCompleted = Observable.merge(
            signInTap.map { SignUpFinishResult.signInRequested },
            continueToSubscriptionTap.withLatest(from: action.elements)
                .map { (_, authSubscriber) in 
                    SignUpFinishResult.accountCreated(user: authSubscriber) 
                }
        )
    }
}

and view controller:

protocol SignUpViewControlling: AnyObject {
    // flow controller sets viewModel on a view controller instance, before presenting
    var viewModel: SignUpViewModelProtocol! { get set }

    // just return self for view controller that implements this protocol
    // because protocol can't ihnerit UIViewController
    var viewController: UIViewController { get }

    // flow controller will observe that to know when screen is done and with what result
    // will need to observe view model for that and call the callback
    var screenFinished: ((SignUpFinishResult) -> Void)? { get set }
}

Can we also unit test the flow controller? Yes, of course. And that is also the whole point of extracting that logic out of UIViewController. Again, as with other business logic, extracting it, and creating a protocol for a screen that flow controller operates on, we are able to test navigation logic by injecting mocked screen providers and/or checking mocked screen presenters.

3rd Pull Request is routing in flow controller, 
if needed for the screen. 
Can also be tested for navigation logic, which depends on each screen’s output, 
and app state.

All that is left
when you have basic pull requests merged,
is to finally run the app 
and fix any quirks that may come up.

You could also start with stubbed values inside view model, and add more logic across sprints.

The same goes for UI of course, you can add elements in UI over time.

Sum Up

As with all architectures, MVVM is only one of the tools you can use to achieve your goals. It is not always the best choice, but may be a good one if you have a long lived project maintained by a large number of developers.

Pros:

Cons/Risks: