iOS App Architecture at Vivid Seats

Alex Koller
Vivid Seats Tech Blog
8 min readMay 28, 2019

Like many iOS teams, as our app matured and our teams grew in size, we faced the struggle of a lagging MVC architecture. Some of our view controllers were massive, code was a pain to test, and it was difficult to make updates given the heavy coupling of objects. With our tech debt growing out of control, we knew a deep, architectural change was required in order for our app to continue to grow for years to come.

We started our search for a new architecture with a goals-oriented approach. Our team places a high priority on quality and speed and we need to deliver testable and reusable code to achieve this. We were familiar with the more common alternatives to MVC such as MVVM and MVP, but we wanted to look beyond those before defaulting to the more common approaches. After doing some research, we came upon Riblets.

Riblets is a heavily abstracted architecture created by Uber when they decided to make a similar architecture redesign of their iOS app. It uses numerous, clearly defined layers to separate code and create a highly modular app. We found that this could potentially address our issues with MVVM and MVP as certain layers had a tendency to bloat under complex workflows. However, we were also weary of over-engineering a solution that was overcomplicated for our purposes.

Ultimately, we found VIPER which seemed to be a good blueprint for something a bit more modular than MVVM/MVP but not as complex as Riblets. Using Riblets and VIPER as guides, we decided to go with a modified VIPER design that we felt would provide the modularity, testability, and scalability we were looking to achieve.

Technical Overview

VIPER stands for (V)iew, (I)nteractor, (P)resenter, (E)ntity, (R)outer. Each layer has strictly defined responsibilities so that code can be logically spread across multiple modular components that can be interchanged as needed. To support reuse, every router, interactor, and presenter is implemented behind a protocol.

A combination of an interactor, presenters, and views makes a component and their communication is strictly defined. Data flows upward from the view layer via method calls on the presenter and the interactor. To communicate downward, a reactive pattern is used. A view or presenter binds itself to observable properties on its presenter or interactor respectively. The interactor makes use of entities or other dependencies to resolve business logic and ultimately propagate that information down to the view. When a flow is complete, the interactor makes use of its router to move on to the next component.

Router

A router is the object that controls the view structure and invokes transitions between views. Most commonly views kick off transitions themselves, but when a view becomes involved in numerous flows, this can become hard to maintain. The router primarily is involved with pushing, popping, and presenting view controllers while also combining the required dependencies to create the next screen. By extension, this is also a great place to handle deep/universal link routing.

class HomeRouter {
// deep link handling
enum Destination {
case event(id: Int)
}

private weak var rootViewController: UINavigationController?

// setup with dependencies
init(appConfiguration: AppConfiguration, services: VividServices, rootViewController: UINavigationController?) {
self.appConfiguration = appConfiguration
self.services = services

self.rootViewController = rootViewController
}

// flow kickoff
func start() {
let interactor = HomeInteractor(appConfiguration: appConfiguration, services: services, router: self)
let presenter = HomePresenter(interactor: interactor)
let controller = HomeViewController(presenter: presenter)

rootViewController?.viewControllers = [controller]
}
}

Entity

Entities are the simplest of the components as they are simply the models for data. They are used freely to pass data between routers, interactors, and presenter. Views should have no knowledge of these objects and it is the presenters job to mask them and simply provide dumb objects (like `String` or `Bool`) to drive the view.

Interactor

Interactors are the glue that binds many components together. They primarily contain the business logic to fetch data, drive transitions, or initiate actions on dependencies. Certain actions, such as loading from a network, can be propagated through the presenter to indirectly drive view state. Since this logic is encapsulated, it makes it much easier to write tests against an interactor contract.

class HomeInteractor<T: HomeRouterProtocol>: VSInteractor<T>, HomeInteractorProtocol {

let selectedRegion: Observable<Region?> = Observable(nil)
let recommendedItems: Observable<[HomeData]> = Observable([])

private let appConfiguration: AppConfiguration
private let services: VividServices

// MARK: - Initialization
init(appConfiguration: AppConfiguration, services: VividServices, router: T) {
...
}

func getLocation() {
// use internal dependencies to complete the action and set an observable
}
}

Presenter

Presenters manipulate the data provided by interactors into objects that are consumable by the view. They also take actions provided by the view and drive UI changes on their own or via reactions from the interactor layer. Presenters should effectively be able to completely rebuild a view if it were destroyed. They should be considered view agnostic and simply provide data to construct a view on any platform or layout.

class HomePresenter<T: HomeInteractorProtocol>: VSPresenter<T>, HomePresenterProtocol {

let headerPresenter: HomeHeaderCellPresenter
let recommendedSectionPresenter: Observable<ListHeaderSection<SectionHeaderCellPresenter, HomeItemContainerCellPresenter>>?> = Observable(nil)

override init(interactor: T) {
self.headerPresenter = HomeHeaderCellPresenter(locationButtonHandler: {
interactor.showLocationSelect()
}, dateButtonHandler: {
interactor.showDateFilters()
}, searchFieldHandler: {
interactor.showSearch()
})

super.init(interactor: interactor)
}
// bind ourselves to the interactor's observables
override func bind(interactor: T) {
...
}

// propagate ui action to interactor method
func didTapLocationButton() {
interactor.getLocation()
}
}

View

The view is dumb. It should have no knowledge of how or where its data is coming from. Its job is primarily to update the display based on data given by the presenter and forward user input. On iOS, a good rule of thumb is UIKit should almost exclusively be used by this layer and not presenters or interactors.

class HomeViewController<T: HomePresenterProtocol>: PresentableViewController<T> {

// bind ourselves to the presenter's observables
override func bind(presenter: T) {
...
}

// propagate actions to presenter
@IBAction private func didTapLocationButton(_ sender: UIButton) {
presenter.didTapLocationButton()
}
}

How we made the transition to VIPER

All of the above base components were combined in a sample project to provide a quick POC of our design. By testing out simple but common tasks like generating a list view from the network and navigating between screens, we refined our APIs and attempted to ensure we didn’t miss any obvious oversights. After maybe a week or two of this testing, we began to feel confident and excited in trying out our new direction.

Once the team became comfortable with the initial state of our new architecture, we created an implementation plan. While our initial design worked in a sample project, we wanted to see it in action. We could have started by migrating existing, simpler screens away from MVC, but we had just recently kicked off a redesign of our tickets flow. We decided it would be most valuable to implement a new, more complex feature like this in order to put our design under a proper stress test.

Since our tickets flow starts on its own tab of a UITabBarController, this migrated seamlessly to a new router object. On first pass, we were adding only a couple new screens, so we were required to combine both our new and old architectures. Since our routers managed transitions, we were able to isolate the architecture transition in that layer as well.

Quickly during the new implementation, we found that one presenter object was not going to be enough to power an entire screen’s views. For scalability and consistency, we decided that every custom view will have its own presenter, no matter how simple. This made simple views a little more cumbersome to spin up, but they were consistent with our design and were ready for quicker extension in the future.

The POC by no means handled all of our complex use cases required of our app, but it provided a solid foundation for us from which to build. Architectural features were added, APIs were tweaked, but the core has remained the same. We have continued forward with this new design to date, and we’ve seen great success in resolving the issues that initially plagued us prior to these changes.

Day-to-day benefits of VIPER

Here at Vivid Seats, we’ve been using this new paradigm for about a year now, and it has led to multiple benefits. No longer do we have to dig through view controllers that are thousands of lines long to find relevant code. Given a clear responsibility for each layer, it became much easier to know where to look when going through the code. Given the following scenarios, it’s easy to assume the most likely location of the issue:

  • a bug with what a button does — Interactor
  • a bug related to how data is displayed in a cell — Presenter
  • a bug with how this label is laid out — View

VIPER promotes smart code reuse

We also find it much easier to reuse code across screens by simply swapping out layers of the architecture. For example, when a user enters our app with a promo or discount code, we display a special screen that explains the special promotion and any associated fine print. This started with a single view, but later we were creating screens for a Referral Program. Thanks to our new architecture, we were able to utilize the same promo logic. While the views were entirely different, the presentation and business logic were practically identical. Under our new architecture, we were able do the following:

Now, when we change business logic for handling promo codes, it all remains in one place. At the same time, we’re free to tweak two separate view classes independently that provide an entirely different UI to the user.

VIPER allows us to write more testable code

Finally, our test suite has greatly improved. Routers, interactors, and presenters are lightweight, independently testable objects that support dependency injection in order to mock all sorts of scenarios. Since each of these components are behind a protocol, our mocks are simply dummy objects that conform to these protocols. There’s a clear distinction of what and how to test, and we make a point of unit testing all our interactor and presenter interfaces. For view testing, we defer to UI tests to confirm data is correctly propagated to the view.

Key Takeaways of VIPER

While VIPER has proven to be successful for us, the merits of its use heavily depend on the unique challenges faced by your team. There can be a lot of boilerplate with particularly simple screens, but it scales particularly well when component reuse is necessary and complex business/presenter/view logic needs to be combined.

Outside of POCs or quick startup projects, we think VIPER is a great option to consider when teams have a need to develop an app at scale. Even for existing projects, we found it viable to meld VIPER components with our existing MVC infrastructure with little issue. Whether or not you think VIPER is right for your team, we think the patterns and principles it promotes are great for any team to examine and determine how they can be applied to their own projects.

We hope sharing these details is helpful to others. Additionally, we are actively hiring for several roles on our engineering team. If you’re interested to learn more about open positions, check out our careers page at vividseats.com/careers for more information.

--

--