Battle of the iOS Architecture Patterns: View Interactor Presenter (VIP)
Motivation
Before diving into iOS app development, it’s crucial to carefully consider the project’s architecture. We need to thoughtfully plan how different pieces of code will fit together, ensuring they remain comprehensible not just today, but months or years later when we need to revisit and modify the codebase. Moreover, a well-structured project helps establish a shared technical vocabulary among team members, making collaboration more efficient.
This article kicks off an exciting series where we’ll explore different architectural approaches by building the same application using various patterns. Throughout the series, we’ll analyze practical aspects like build times and implementation complexity, weigh the pros and cons of each pattern, and most importantly, examine real, production-ready code implementations. This hands-on approach will help you make informed decisions about which architecture best suits your project needs.
Architecture Series Articles
- Model View Controller (MVC)
- Model View ViewModel (MVVM)
- Model View Presenter (MVP)
- Model View Presenter with Coordinators (MVP-C)
- View Interactor Presenter Entity Router (VIPER)
- View Interactor Presenter (VIP) — Current Article
If you’re eager to explore the implementation details directly, you can find the complete source code in our open-source repository here.
Why Your iOS App Needs a Solid Architecture Pattern
The cornerstone of any successful iOS application is maintainability. A well-architected app clearly defines boundaries — you know exactly where view logic belongs, what responsibilities each view controller has, and which components handle business logic. This clarity isn’t just for you; it’s essential for your entire development team to understand and maintain these boundaries consistently.
Here are the key benefits of implementing a robust architecture pattern:
- Maintainability: Makes code easier to update and modify over time
- Testability: Facilitates comprehensive testing of business logic through clear separation of concerns
- Team Collaboration: Creates a shared technical vocabulary and understanding among team members
- Clean Separation: Ensures each component has clear, single responsibilities
- Bug Reduction: Minimizes errors through better organization and clearer interfaces between components
Project Requirements Overview
Given a medium-sized iOS application consisting of 6–7 screens, we’ll demonstrate how to implement it using the most popular architectural patterns in the iOS ecosystem: MVC (Model-View-Controller), MVVM (Model-View-ViewModel), MVP (Model-View-Presenter), VIPER (View-Interactor-Presenter-Entity-Router), VIP (Clean Swift), and the Coordinator pattern. Each implementation will showcase the pattern’s strengths and potential challenges.
Our demo application, Football Gather, is designed to help friends organize and track their casual football matches. It’s complex enough to demonstrate real-world architectural challenges while remaining simple enough to clearly illustrate different patterns.
Core Features and Functionality
- Player Management: Add and maintain a roster of players in the application
- Team Assignment: Flexibly organize players into different teams for each match
- Player Customization: Edit player details and preferences
- Match Management: Set and control countdown timers for match duration
Screen Mockups
Backend
The application is supported by a web backend developed using the Vapor framework, a popular choice for building modern REST APIs in Swift. You can explore the details of this implementation in our article here, which covers the fundamentals of building REST APIs with Vapor and Fluent in Swift. Additionally, we have documented the transition from Vapor 3 to Vapor 4, highlighting the enhancements and new features in our article here.
Cleaning Your Code Like a VIP
VIP is not a widely adopted architecture pattern, but it offers unique advantages for clean and scalable iOS development. Invented by Raymond Law, VIP is an adaptation of Uncle Bob’s Clean Architecture principles tailored for iOS projects. For more details, visit Clean Swift.
The primary goal of VIP is to address the “Massive View Controller” problem often associated with MVC. VIP also aims to resolve challenges seen in other architecture patterns. For example, while VIPER places the Presenter at the center of the application, VIP simplifies this process by using a unidirectional flow of control, making it easier to manage method invocations across layers.
VIP organizes your app into distinct control cycles, ensuring a clear and consistent flow of data and actions.
Example Scenario of Applying VIP:
- A user taps a button to fetch a list of players, starting in the ViewController.
- The associated
IBAction
triggers a method in the Interactor. - The Interactor processes the request, executes business logic (e.g., fetching players from a server), and sends the response to the Presenter to format it for display.
- The Presenter passes the formatted data to the ViewController, which displays it to the user.
VIP Architecture Components
View/ViewController
The View/ViewController layer has two primary responsibilities: sending user actions to the Interactor and displaying data received from the Presenter.
Interactor
Known as the “new Presenter,” the Interactor serves as the core of the VIP architecture. It handles tasks like network calls, error handling, and business logic computation.
Worker
In some cases (e.g., in Football Gather), we refer to Workers as “Services.” A Worker offloads specific tasks from the Interactor, such as managing network requests or database interactions.
Presenter
The Presenter processes data from the Interactor and formats it into a ViewModel suitable for display in the View.
Router
The Router manages scene transitions, similar to its role in VIPER.
Models
The Model layer encapsulates data, much like in other architectural patterns.
Communication
Communication flows in a unidirectional manner:
- The ViewController interacts with both the Router and the Interactor.
- The Interactor processes data and communicates with the Presenter. It may also collaborate with Workers for specific tasks.
- The Presenter formats the response from the Interactor into a ViewModel and sends it to the View/ViewController.
Advantages of VIP
- Eliminates the “Massive View Controller” issue found in MVC.
- Avoids the “Massive View Model” problem that can occur with incorrect MVVM implementation.
- Solves control issues seen in VIPER by introducing the VIP cycle.
- Prevents “Massive Presenters” often encountered with improper VIPER use.
- Aligns with Clean Architecture principles, ensuring separation of concerns.
- Facilitates handling of complex business logic by delegating to Workers.
- Highly testable and compatible with TDD practices.
- Offers good modularity and easier debugging.
Disadvantages of VIP
- Introduces numerous layers, which can become tedious without code generation tools.
- Involves writing significant amounts of code, even for simple actions.
- Not well-suited for small applications due to its complexity.
- Certain components may feel redundant, depending on the app’s use case.
- Slightly increases app startup time.
VIP vs. VIPER
- In VIP, the Interactor directly interacts with the ViewController.
- The ViewController holds a reference to the Router in VIP.
- VIPER’s flexibility can lead to “Massive Presenters” if not implemented correctly.
- VIP maintains a unidirectional flow of control.
- Services in VIPER are referred to as Workers in VIP.
Applying VIP to Our Code
Transitioning an app from VIPER to VIP is not a straightforward process. The first step is to transform the Presenter into an Interactor. Afterward, extract the Router from the Presenter and integrate it into the ViewController.
The module assembly logic from VIPER can remain intact, simplifying the process.
Login
Scene
Let’s start by applying these changes to the Login scene and iterating from there.
final class LoginViewController: UIViewController, LoginViewable {
// MARK: - Properties
@IBOutlet private weak var usernameTextField: UITextField!
@IBOutlet private weak var passwordTextField: UITextField!
@IBOutlet private weak var rememberMeSwitch: UISwitch!
lazy var loadingView = LoadingView.initToView(view)
var interactor: LoginInteractorProtocol = LoginInteractor()
var router: LoginRouterProtocol = LoginRouter()
// MARK: - View life cycle
override func viewDidLoad() {
super.viewDidLoad()
loadCredentials()
}
private func loadCredentials() {
let request = Login.LoadCredentials.Request()
interactor.loadCredentials(request: request)
}
// ...
}
As you can see we no longer tell the Presenter that the view has been loaded. We now make a request to the Interactor to load the credentials.
The IBActions
have been modified as below:
final class LoginViewController: UIViewController, LoginViewable {
// ...
@IBAction private func login(_ sender: Any) {
showLoadingView()
let request = Login.Authenticate.Request(username: usernameTextField.text,
password: passwordTextField.text,
storeCredentials: rememberMeSwitch.isOn)
interactor.login(request: request)
}
@IBAction private func register(_ sender: Any) {
showLoadingView()
let request = Login.Authenticate.Request(username: usernameTextField.text,
password: passwordTextField.text,
storeCredentials: rememberMeSwitch.isOn)
interactor.register(request: request)
}
// ...
}
We start the loading view, construct the request to the Interactor containing the username, password contents of the text fields and the state of the UISwitch
for remembering the username.
Next, handling the viewDidLoad
UI updates are made through the LoginViewConfigurable
protocol:
extension LoginViewController: LoginViewConfigurable {
func displayStoredCredentials(viewModel: Login.LoadCredentials.ViewModel) {
rememberMeSwitch.isOn = viewModel.rememberMeIsOn
usernameTextField.text = viewModel.usernameText
}
}
Finally, when the logic service call has been completed we call from the Presenter the following method:
func loginCompleted(viewModel: Login.Authenticate.ViewModel) {
hideLoadingView()
if viewModel.isSuccessful {
router.showPlayerList()
} else {
handleError(title: viewModel.errorTitle!, message: viewModel.errorDescription!)
}
}
The Interactor looks the same as the one from the VIPER architecture. It has the same dependencies:
final class LoginInteractor: LoginInteractable {
var presenter: LoginPresenterProtocol
private let loginService: LoginService
private let usersService: StandardNetworkService
private let userDefaults: FootballGatherUserDefaults
private let keychain: FootbalGatherKeychain
init(presenter: LoginPresenterProtocol = LoginPresenter(),
loginService: LoginService = LoginService(),
usersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/users"),
userDefaults: FootballGatherUserDefaults = .shared,
keychain: FootbalGatherKeychain = .shared) {
self.presenter = presenter
self.loginService = loginService
self.usersService = usersService
self.userDefaults = userDefaults
self.keychain = keychain
}
}
The key thing here is that we now inject the Presenter through the initializer and it is no longer a weak variable.
Loading credentials is presented below. We first take the incoming request from the ViewController. We create a response for the presenter and call the function presentCredentials(response: response)
.
func loadCredentials(request: Login.LoadCredentials.Request) {
let rememberUsername = userDefaults.rememberUsername ?? true
let username = keychain.username
let response = Login.LoadCredentials.Response(rememberUsername: rememberUsername, username: username)
presenter.presentCredentials(response: response)
}
The login and register methods are the same, the exception being the Network service (Worker).
func login(request: Login.Authenticate.Request) {
guard let username = request.username, let password = request.password else {
let response = Login.Authenticate.Response(error: .missingCredentials)
presenter.authenticationCompleted(response: response)
return
}
let requestModel = UserRequestModel(username: username, password: password)
loginService.login(user: requestModel) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .failure(let error):
let response = Login.Authenticate.Response(error: .loginFailed(error.localizedDescription))
self?.presenter.authenticationCompleted(response: response)
case .success(_):
guard let self = self else { return }
self.updateCredentials(username: username, shouldStore: request.storeCredentials)
let response = Login.Authenticate.Response(error: nil)
self.presenter.authenticationCompleted(response: response)
}
}
}
}
private func updateCredentials(username: String, shouldStore: Bool) {
keychain.username = shouldStore ? username : nil
userDefaults.rememberUsername = shouldStore
}
The Presenter doesn’t hold references to the Router or Interactor. We just keep the dependency of the View, which has to be weak to complete the VIP cycle and not have retain cycles.
The Presenter has been greatly simplified, exposing two methods of the public API:
func presentCredentials(response: Login.LoadCredentials.Response) {
let viewModel = Login.LoadCredentials.ViewModel(rememberMeIsOn: response.rememberUsername,
usernameText: response.username)
view?.displayStoredCredentials(viewModel: viewModel)
}
func authenticationCompleted(response: Login.Authenticate.Response) {
guard response.error == nil else {
handleServiceError(response.error)
return
}
let viewModel = Login.Authenticate.ViewModel(isSuccessful: true, errorTitle: nil, errorDescription: nil)
view?.loginCompleted(viewModel: viewModel)
}
private func handleServiceError(_ error: LoginError?) {
switch error {
case .missingCredentials:
let viewModel = Login.Authenticate.ViewModel(isSuccessful: false,
errorTitle: "Error",
errorDescription: "Both fields are mandatory.")
view?.loginCompleted(viewModel: viewModel)
case .loginFailed(let message), .registerFailed(let message):
let viewModel = Login.Authenticate.ViewModel(isSuccessful: false,
errorTitle: "Error",
errorDescription: String(describing: message))
view?.loginCompleted(viewModel: viewModel)
default:
break
}
}
The Router layer remains the same.
We apply some minor updates to the Module assembly:
extension LoginModule: AppModule {
func assemble() -> UIViewController? {
presenter.view = view
interactor.presenter = presenter
view.interactor = interactor
view.router = router
return view as? UIViewController
}
}
PlayerList
scene
Next, we move to PlayerList
scene.
The ViewController
will be transformed in a similar way - the Presenter will be replaced by Interactor and we now hold a reference to the Router.
An interesting aspect in VIP is the fact we can have an array of view models inside the ViewController:
var interactor: PlayerListInteractorProtocol = PlayerListInteractor()
var router: PlayerListRouterProtocol = PlayerListRouter()
private var displayedPlayers: [PlayerList.FetchPlayers.ViewModel.DisplayedPlayer] = []
We no longer tell the Presenter that the View has been loaded. The ViewController will configure its UI elements in the initial state.
override func viewDidLoad() {
super.viewDidLoad()
setupView()
fetchPlayers()
}
private func setupView() {
configureTitle("Players")
setupBarButtonItem(title: "Select")
setBarButtonState(isEnabled: false)
setupTableView()
}
Similar to Login, the IBActions
will construct a request and will call a method within the Interactor.
// MARK: - Selectors
@objc private func selectPlayers() {
let request = PlayerList.SelectPlayers.Request()
interactor.selectPlayers(request: request)
}
@IBAction private func confirmOrAddPlayers(_ sender: Any) {
let request = PlayerList.ConfirmOrAddPlayers.Request()
interactor.confirmOrAddPlayers(request: request)
}
When the data will be fetched and ready to be displayable, the Presenter will call the method from the ViewController displayFetchedPlayers
.
The rest of the code is available in the open-source repository.
Key Metrics
Lines of code — Protocols
Lines of code — View Controllers and Views
Lines of code — Modules
Lines of code — Local Models
Lines of code — Routers
Lines of code — Presenters
Lines of code — Interactors
Unit Tests
Build Times
Tests were run on an 8-Core Intel Core i9, MacBook Pro, 2019. Xcode version: 12.5.1. macOS Big Sur.
Conclusion
We implemented VIP architecture in an application originally built with VIPER. The first noticeable improvement is the significant simplification and cleanliness of the Presenters. If the transition were made from an MVC application, the ViewControllers would have been reduced drastically due to better separation of concerns.
VIP introduces a unidirectional flow of control, making the invocation of methods across layers more straightforward and predictable. This results in a cleaner and more maintainable structure.
The average build times for VIP are comparable to those of VIPER and MVP, remaining around 10 seconds. While the addition of more unit tests increases the overall test execution time, we found it to be slightly faster than VIPER during our testing process.
One key observation is the reduction in the size of Presenters. With VIP, Presenters were streamlined to only 514 lines of code, a substantial improvement over VIPER. However, this reduction is offset by an increase in the size of Interactors, which grew by 508 lines. Essentially, what was removed from the Presenters was redistributed to the Interactors, resulting in a similar overall code footprint.
Personally, I still lean towards VIPER. While VIP offers several advantages, there are aspects of the architecture that, in my view, deviate from the principles it claims to follow, including Uncle Bob’s Clean Architecture guidelines.
For instance, the necessity of constructing a Request object, even when there is no data to attach to it, seems redundant. While this step can technically be skipped, the example repository demonstrates numerous instances of empty Request objects, which add unnecessary boilerplate code.
Another challenge is the added complexity of maintaining an array of view models within the ViewController. This approach can lead to synchronization issues with the corresponding Worker models, making the architecture harder to manage.
That said, there’s room to customize and adapt VIP to better suit specific project needs. A personalized variation of VIP could mitigate many of these concerns and improve its practicality in certain contexts.
On a positive note, the concept of VIP cycles is compelling, and the architecture is well-suited for Test-Driven Development (TDD). However, adhering strictly to the prescribed layering rules can make even minor changes more cumbersome than necessary. After all, software development should aim to be adaptable — true to the spirit of “SOFTware.”
Useful Links
- The iOS App, Football Gather — GitHub Repo Link
- The web server application made in Vapor — GitHub Repo Link
- Vapor 3 Backend APIs article link
- Migrating to Vapor 4 article link
- Model View Controller (MVC) — GitHub Repo Link and article link
- Model View ViewModel (MVVM) — GitHub Repo Link and article link
- Model View Presenter (MVP) — GitHub Repo link and article link
- Coordinator Pattern — MVP with Coordinators (MVP-C) — GitHub Repo link and article link
- View Interactor Presenter Entity Router (VIPER) — GitHub Repo link and article link
- View Interactor Presenter (VIP) — GitHub Repo link