Architecture With Nico

Software Architecture and Design for iOS.

Published on 29 January 2023

Model-View-ViewModel with SwiftUI

Model-View-ViewModel (MVVM) is a software architecture pattern that separates the user interface from the business logic. It takes a reactive approach to data binding and allows for a clean separation of concerns. It's a popular choice for building iOS apps, but now that SwiftUI is a thing, it becomes even more relevant, given the declarative nature of SwiftUI.

How does MVVM work?

With this pattern, the UI has a direct reference to the view model, which is responsible for exposing the data to the view.

struct ProductsListView: View {
    @ObservedObject var viewModel: ProductsListViewModel

    var body: some View {
        VStack {
            Text(viewModel.title)
            List(viewModel.products) { product in
                Text(product.name)
            }
        }
    }
}

class ProductsListViewModel: ObservableObject {
    @Published var products: [ProductItemViewModel] = []
    @Published var title: String = "Loading"

    func onProductsFetched(_ products: [Product]) {
        title = "\(products.count) Products"
        self.products = products.map {
            ProductItemViewModel(product: $0)
        }
    }
}

A quick Model-View-Presenter comparison

MVVM is similar to Model-View-Presenter (MVP) in that it separates the user interface from the business logic. However, in MVP, the UI is updated manually by the presenter, by calling methods to be implemented by the view.

What are ViewModels responsible for?

In a clean and modular architecture, the view model is responsible for exposing the data to the view, in a format that enables the view to display as is.

It should not contain any business logic. It should not know where the data comes form. It should not be aware of the view.

It is also important to mention that the View should not call methods on the ViewModel, only bind to it's exposed data properties.

Where is the business logic?

The business logic is usually implemented in a separate layer, by different Use Cases. This layer also defines what data will be needed, but not including the implementation details of how to fetch it.

struct Product {
    let id: String
    let name: String
}

protocol ProductsRepository {
    func getAll() async -> [Product]
}

struct GetAllProductsUseCase {
    let productsRepository: ProductsRepository
    let onProductsFetched: ([Product]) -> Void

    func execute() async {
        let products = await productsRepository.getAll()
        onProductsFetched(products)
    }
}

Implementations of the ProductsRepository protocol can be injected into the GetAllProductsUseCase to fetch the data from different sources, such as a local database, a remote API, or a mock.

How do we connect the dots?

Introducing the Composition Root Layer. This layer is responsible for creating the dependencies and connecting them together.

This layer lives close to the app's entry point, such as the AppDelegate or the SceneDelegate, and it can take different forms, depending on the app's architecture, such as Factories, Builders, or DI Containers.

class ProductsListViewFactory {
    func make() -> ProductsListView {
        let viewModel = ProductsListViewModel()
        let useCase = GetAllProductsUseCase(
            productsRepository: ApiProductsRepository(),
            onProductsFetched: viewModel.onProductsFetched
        )
        return ProductsListView(viewModel: viewModel)
            .onAppear {
                Task { await useCase.execute() }
            }
    }
}

Here we can see the wiring of the dependencies. The onAppear modifier is used to trigger the execution of the use case. The ViewModel's onProductsFetched method is passed as a callback to the use case, so that it can be called when the data is fetched.

A bit more

As we start composing different events with different functionalities, we can start to see the benefits of this pattern, but we'll start to see the need to extract logic awaiy from the factory, as it should only be responsible for creating the dependencies and connecting them together.

class ProductsListViewFactory {
    func make() -> ProductsListView {
        let viewModel = ProductsListViewModel()
        let logger = ProductsLogger()
        let useCase = GetAllProductsUseCase(
            productsRepository: ApiProductsRepository(),
            onProductsFetched: { products in 
                viewModel.onProductsFetched(products)
                logger.log(products)
            }
        )
        return ProductsListView(viewModel: viewModel)
            .onAppear {
                logger.log("showing Products screen")
                Task { await useCase.execute() }
            }
    }
}

We can refactor it by adding adapters or decorators class, leavign the factory doing what's good at.

struct ProductsListAdapter {
    let viewModel: ProductsListViewModel
    let logger: ProductsLogger
    let useCase: GetAllProductsUseCase

    func onProductsFetched(_ products: [Product]) {
        viewModel.onProductsFetched(products)
        logger.log(products)
    }

    func onScreenLoaded() {
        logger.log("showing Products screen")
        Task { await useCase.execute() }
    }
}

class ProductsListViewFactory {
    func make() -> ProductsListView {
        let viewModel = ProductsListViewModel()
        let adapter = ProductsListAdapter(
            viewModel: viewModel,
            logger: ProductsLogger(),
            useCase: GetAllProductsUseCase(
                productsRepository: ApiProductsRepository(),
                onProductsFetched: adapter.onProductsFetched
            )
        )
        return ProductsListView(viewModel: viewModel)
            .onAppear(perform: adapter.onScreenLoaded)
    }
}

Using this pattern, all the different components in turn respect the Single Responsibility Principle, and the code is easier to maintain and test.

Leveraging Polymorphism with protocols

The GetAllProductsUseCase can depend on an Output protocol, instead of a closure, so that it we can compose it a bit more easily.

protocol GetAllProductsUseCaseOutput {
    func onProductsFetched(_ products: [Product])
    func onErrorFetchingProducts(_ error: Error)
}

class ProductsListViewModel: ObservableObject, GetAllProductsUseCaseOutput {
    // ...
    func onProductsFetched(_ products: [Product]) {
        // show products in the view
    }
}

class ProductsLoggerDecorator: GetAllProductsUseCaseOutput {
    let decoratee: GetAllProductsUseCaseOutput

    func onProductsFetched(_ products: [Product]) {
        Logger.log("Products fetched: \(products)")
        decoratee.onProductsFetched(products)
    }
}

class ProductsListViewFactory {
    func make() -> ProductsListView {
        let viewModel = ProductsListViewModel()
        let adapter = ProductsListAdapter(
            output: ProductsLoggerDecorator(decoratee: viewModel),
            useCase: GetAllProductsUseCase(
                productsRepository: ApiProductsRepository(),
                output: ProductsLoggerDecorator(decoratee: viewModel)
            )
        )
        return ProductsListView(viewModel: viewModel)
            .onAppear { Task { await useCase.execute() } }
    }
}

Closing thoughts

This is a very simplified example, but it shows how we can leverage the power of composition to create a modular architecture that is easy to maintain and test.

Thanks for reading!