Building a Reusable E-Commerce Framework Using VIPER
Thibault Klein
Thibault Klein

Building a Reusable E-Commerce Framework Using VIPER

Thibault Klein, Senior iOS Engineer

For the past six months, I’ve had the chance to lead and be a part of one of the most challenging mobile projects I’ve ever worked on. The app itself was not complicated; it actually was a classic, well designed, e-commerce app that I already have experience working on. The challenge was because of the framework we had to build on top.

Our partner is a global luxury brands retailer. They own several brands all attached to a common headquarter company. For the past few years, they had been focusing on their most famous brand’s iOS app. They decided to redo the entire experience from scratch using the latest iOS technologies paired with a modern design and better user experience. It was a great opportunity for them to rethink their mobile strategy and include all their brands in the development process.

The goal for us was to rebuild the app from scratch and at the same time build a framework that would allow us to iterate on their other brands very quickly by reusing the same business logic, a similar design, the same backend logic and more. We formed a team of apps we called X-Men – starting with the founder: Professor X. Professor X is the first app in a team of super apps representing various partner brands. To help the X-Men succeed we needed the brain: Cerebro. Cerebro is the name of the reusable framework that every app is to start with. In the next section I’ll explain how everything works together and how the next app after Professor X will be easy to create.

Architecture

As we started the discovery process, we had to think about the best architecture to fit the requirements described above. We knew we would have to share some logic, but we were not sure exactly what. We knew our partner was working on a common platform for all the API services so the backend logic would be very similar, but what about the design? Is a product page going to be exactly the same for all the apps? We obviously needed some flexibility; for us, the classic MVC design pattern wouldn’t be a good candidate. What about MVVM? It brings an extra layer of business logic that we could share across the apps, but to us the ViewModel layer was still doing too much and we knew we would have to rewrite view models in almost every scenario. And then we found VIPER.

VIPER

I’m not gonna go through the VIPER (View Interactor Presenter Entity) definition and pros and cons as you can find very useful articles about it online. Instead I’m gonna focus here on why we found this architecture very beneficial for our use case.

Layers… Layers everywhere!

One of the main reasons why we decided to use this architectural pattern is for the separation of responsibilities defined in the VIPER layers. Having all these layers gives us the flexibility to decide what to share and what to override so we can account for every scenario. In this article we will use a product page as an example. The product VIPER stack could reuse the same business logic for selecting a color and a size across every app, but you would want to change the way you display the product on the screen. That means that the product Interactor that holds all the business logic can be shared, and the View layer would be customized per app. When building the next app, a lot of code will be reusable if the VIPER layers share the same logic. And it will be as easy to customize a feature by customizing a specific VIPER layer.

Know who is responsible for what

By using VIPER we know exactly who is responsible for what, and in a project with this scale it can make the difference. You know exactly where to go when you want to add or update a feature or fix a bug. It can seem like overkill for a single app, but when you have to iterate and maintain four different apps it becomes very clear that the VIPER architecture shines.

Cerebro

Cerebro is a separate project we created that can be installed as a pod or as a package. The framework contains for the most part protocols that each app will conform to, and a lot of small utility classes, functions and constants that help avoid reinventing the wheel.

Protocol oriented

Protocol Oriented Programming is a huge part of Cerebro. Every common feature (Category, Product, Cart, Checkout, Account…) are defined by protocols split into VIPER stacks. So in one feature, each VIPER layer will have its protocol with specific functions defined to help build the feature. In the Product VIPER stack, a simplified Interactor protocol would look like:

public protocol ProductInteractor {
    associatedtype ProductPresenterType: ProductPresenter
    associatedtype ProductDataManagerType: ProductDataManager

    var presenter: ProductPresenterType { get }
    var dataManager: ProductDataManagerType { get }

    func getProduct(productId: String)
}

Because getting a product always works the same way (by passing a product id to the API), the protocol can define how to do it once and the interface is consistent across all the projects.

Note that we are using associated types in our protocols to give the developer the flexibility to dynamically override the type of the object. It is necessary to do that if you want to use an object that contains custom logic internal to the app. In the class/struct that will conform to the protocol, you will tell the compiler what type this property actually is, and you’ll get access to the internal features you worked on.

Default Implementations

After defining shared needs in your protocols, you can start identifying where the logic will be the same across apps, and provide default implementations to your protocols. Let’s take our Product example and write its default behavior:

public extension ProductInteractor {
    func getProduct(productId: String) {
        dataManager.getProduct(productId) { result in
            if let product = result.value {
                self.presenter.productFound(product)
            } else {
                self.presenter.errorInProduct(result.error)
            }
        }
    }
}

Because we know that the product Interactor will always get the product the same way by using the data manager, we are able to provide a default implementation in Cerebro that every app will get for free. It is a very powerful way to create code that will be shared across several apps with basically no effort. Protocol extensions are what will make the next app’s development much easier, because you’ll only need to develop what is specific to the app, and the common features will be already available for you to use. This will save a lot of development time and prevent bugs as this code was tested and approved before.

For more details about Protocol Oriented Programming I highly recommend watching the WWDC 2015 video from Apple. I hope that Cerebro will make Crusty happy!

Builders

After doing some research about VIPER we noticed that the recommended way to create a VIPER stack is from the Router layer. You are supposed to instantiate all the VIPER layers, connect them together and then navigate to it. This solution wasn’t ideal for us because it was making the VIPER stacks tied to each other and less reusable. We needed to create an interface that would be responsible for creating the VIPER stack, handle the dependencies and return a shared interface object that would be usable from anywhere. That’s why we used the B-VIPER variation.

B-VIPER adds another layer, the Builder, that is responsible for all of that. The Builder has an entry point with the parameters you need in order to create the VIPER stack. It will create all the required layers, connect them and return a shared interface object. We decided to use UIViewController as the shared interface because it is default in iOS and that’s all you need to navigate to the next VIPER stack. A typical Builder protocol would look like the following:

public protocol ProductBuilder {
    func build(productId: String) -> UIViewController
}

You pass a product id to the build function and it returns you a commonly used object that any Router can use to navigate. It is particularly useful for VIPER stacks that can be presented from anywhere in your app such as Cart or Account. It makes it very easy to navigate through your features, and thanks to the Builder you can now consider the VIPER stack as a module that is completely separated from the rest of your code because your Router doesn’t have dependencies on any VIPER stacks. This gives a lot more reusability and consistency through all the apps.

Note that the entry point parameters in the Builder should be raw data types as much as possible to avoid dependencies between a model object and the Builder.

For more details about B-VIPER, I recommend reading this article that gives a full insight on the architecture and its benefits.

Dependency injection

As you’re reading this article, you’re probably wondering how all these VIPER stacks and builders can interact with each other. You may also start to wonder how to organize all these features so it doesn’t get impossible to understand the code. Once again remember that the goal is to provide a framework that makes the developer’s life easier to quickly create the next app; the goal is not to create ravioli code that gets impossible to understand and maintain.

That’s why we made great use of dependency injection. By defining a central dependency manager, we provide a unique place where all the reusable code lives. Our dependency manager contains all the required builders and shared managers that the app needs in order to operate. Let’s take a simplified example of the Cerebro dependency manager protocol:

public protocol DependencyManager {
    func productBuilder() -> ProductBuilder
    func categoryBuilder() -> CategoryBuilder
    // ...
}

The dependency manager contains all your app’s builders, so when you need to instantiate a VIPER stack, all you have to do is call the appropriate builder function and call the build function. Every VIPER Router is getting passed the dependency manager through dependency injection to be able to call the appropriate function:

protocol ProductRouter {
    init(dependencyManager: DependencyManager)
}

That way you can use the dependency manager to build a VIPER stack and navigate to it, and the Router doesn’t know anything about the other VIPER stack.

The dependency manager is a crucial part of our architecture as it keeps everything clean and centralized. Apple suggested using dependency injection for the first time in a WWDC 2016 video, which is a positive sign that Apple wants to push the developers adopting this programmatic concept.

Theme

In order to make the design of each app more reusable, we defined some protocols to help theme the app. We created 3 protocols in Cerebro that each app has to conform to to provide the values specific to the brand:

public protocol Theme {
    var colorTheme: ColorTheme { get }
    var fontTheme: FontTheme { get }
}

public protocol ColorTheme {
    var primaryColor: UIColor { get }
    var secondaryColor: UIColor { get }
    var tertiaryColor: UIColor { get }
    // ...
}

public protocol FontTheme {
    var title: Dictionary
    var subtitle: Dictionary
    var heading1: Dictionary
    var heading2: Dictionary
    // ...

    func titleText(text: String) -> NSAttributedString
    func subtitleText(text: String) -> NSAttributedString
    func heading1Text(text: String) -> NSAttributedString
    func heading2Text(text: String) -> NSAttributedString
    // ...
}

The theme object is passed through dependency injection as well, so by changing the theme implementation to a new app, a new custom design will automatically reflect with no effort. An update to the theme will also propagate everywhere. The only thing to remember is that no design logic should happen in Storyboard or Xib files, everything should be handled in code (although you could write an extension on UILabel and use IBInspectable properties that allows you to apply your theme from these files).

Our theme protocols were designed along the style guide that the design team worked on, so it also gets very simple to conceptually coordinate design and development. We are using Sketch for the design, and every label in Sketch is associated with a style name (title, heading1, heading2…) – so developers can instantly know what theme implementation to use in the UI.

Testing

Single responsibility layers in VIPER stacks make unit testing much easier. And with the builders, it is very convenient to mock all the layers when testing. VIPER was designed to produce testable code; we found it very easy to write public unit tests in Cerebro to make sure our default implementations were working for all apps, and write internal unit tests more specific to the custom behaviors implemented in individual apps.

Conclusion

Let’s summarize what we learned:

  • VIPER architecture gives us both the code reusability and the space for customization, allowing us to provide a clean architecture that is scalable and maintainable.
  • Adding builders to the VIPER design pattern provides a cleaner solution to separate the features into modules and removes all the dependencies between modules.
  • Dependency injection is a very important part of our workflow as it allows us to control what is passed across features. It is also provides a very easy way to propagate an update instantaneously across the app.

Now obviously not everything is perfect and here are some cons to the architecture we encountered during the discovery and development phases:

  • By using VIPER and by creating Cerebro, we added a LOT of files to the projects. It is obviously a good thing for the single responsibility behavior to have files doing one thing at a time, but it also makes any update to the code more complicated as you need to modify several files at the same time. It can become a problem especially if you entirely rely on a code review process like us. Every pull request becomes bigger and takes more time to review; that’s something you need to account for in your roadmap.
  • Documentation is more crucial in this kind of architecture, and Xcode does not help by not inheriting the documentation from a protocol automatically. It means that if you document your protocol functions, you’ll also need to document their implementation and default behaviors. It creates a lot of duplication, extra lines of code and potential mistakes.
  • It is very hard to know quickly if the protocol provides a default implementation of a function, so unless you specify it in the documentation, the developer has to jump to the protocol file, could miss it and create another implementation instead.

I hope that all the ideas discussed will inspire you to refactor your existing code base or help you think about the architecture of a new project you are starting. Even if this multi-application architecture might not fit your needs, some decisions we made such as VIPER and dependency injection can be very valuable for your single app. We did our best to provide as much reusable code as possible and give enough freedom to future developers to also customize the logic, and we learned so much on the way through. I recommend trying these principles in your application as it might make your day-to-day work much more interesting and valuable.