By Vivian Tran & Yixin Zhu
Why Uber Started Over
Uber is based on a simple concept: push a button, get a ride. What started as a way to request premium black cars now offers a range of products, coordinating millions of rides per day across hundreds of cities. We needed to redefine our mobile architecture to reflect and support that reality for 2017 and the future beyond.
But where to begin? Well, we went back to where we started in 2009: with nothing. We decided to completely rewrite and redesign our rider app. Not being held back by our extensive codebase and previous design choices gave us the freedom where we otherwise would have made compromises. The outcome is the sleek new app you see today, which implements a new mobile architecture across both iOS and Android. Read on to learn why we felt the need to create this new architecture pattern, called Riblets, and how it helps us reach our goals.
Motivations: Where To?
While connecting riders to on-demand transportation remains the driving idea behind Uber, our product has evolved into something much bigger, and our original mobile architecture just couldn’t keep up. Engineering challenges and technical debt accumulated over years as we stretched the rider app to fit new features. Additions such as uberPOOL, scheduled rides and promotional vehicle views introduced complexity. Our trip module grew large, becoming hard to test. Incorporating small changes ran the chance of breaking other parts of the app, making experimentation fraught with collateral debugging, inhibiting our pace for future growth. To maintain the high-quality Uber experience for all users, we needed a way to recapture the simplicity of where we started, while accounting for where we are today and where we want to go in the future.
The new app had to be simple for riders as well as Uber engineers, who develop improvements and features daily. To rewrite the app for these distinct groups, our two main goals became increasing the availability of our core rider experience, and allowing for radical experimentation within a set of product rails.
Reliability is Core
From the engineering side, we’re striving to make Uber reliability a reality 99.99% of the time for our core rider experience. Achieving 99.99% availability means we can have just one cumulative hour of downtime a year, one minute of downtime a week, or one failure per 10,000 runs.
To get us there, the new architecture defined and implemented a framework of core and optional code. Core code—everything needed to sign up, take, complete, or cancel a trip—must run. Changes and additions to core code go through a stringent review process. Optional code undergoes less stringent review and can be turned off without stopping Uber’s core business. This encouragement of code isolation allows us to try out new features and automatically turn them off if they aren’t working correctly, without interfering with the ride experience.
Rails for the Future
We need a platform from which a hundred different program teams and thousands of engineers can build quality features quickly and innovate on top of the rider app without compromising the core experience. So we gave our new mobile architecture cross-platform compatibility, where both iOS and Android engineers can work on a unified ground.
Historically, shipping the best app on iOS and Android involved divergent approaches to architecture, library design, and analytics. The new architecture, however, is committed to using the same best patterns and practices across both platforms. This enables us to capitalize on the learning opportunities from both platforms. Instead of making the same mistake twice because we have separate teams for each platform, the lessons from one platform can preemptively solve issues on the other. Thus, iOS and Android engineers can collaborate more easily and work on new features in parallel.
While there are instances where the platforms can and should diverge (e.g., UI implementation), both iOS and Android mobile platforms start from a consistent place. The platforms share:
- Core architecture
- Class names
- Inheritance relationships between business logic units
- How business logic is divided
- Plugin points (names, existence, structure, etc.)
- Reactive programming chains
- Unified platform components
In order to achieve this common blueprint between platforms, our new mobile architecture required clear organization and separation of business logic, view logic, data flow, and routing. Such an architecture helps defeat complexity, simplify testability, and therefore increase engineering productivity and user reliability. We innovated on other architectural patterns to achieve this.
From MVC to Riblets
With our two goals in mind, we examined where our old architecture could be improved and investigated the options for moving forward. The codebase we inherited from Uber’s beginnings followed the MVC pattern. We looked into other patterns, particularly VIPER, which we eventually used to create Riblets. The core innovation with Riblets is that routing is guided by business logic as opposed to view logic. If you’re unfamiliar with MVC and VIPER, read some articles on modern iOS architecture patterns, then come back to see the pros and cons of adopting them at Uber.
Where We Started: MVC (Model-View-Controller)
The previous rider app was created almost four years ago by a handful of engineers. While the MVC pattern made sense then, it’s unmanageable at scale. As our previous rider app and the team working on it grew to a few hundred people, we saw firsthand how MVC couldn’t grow with us. Specifically, there were two big problem areas:
First, matured MVC architectures often face the struggles of massive view controllers. For instance, the RequestViewController, which started off as 300 lines of code, is over 3,000 lines today due to handling too many responsibilities: business logic, data manipulation, data verification, networking logic, routing logic, etc. It has become hard to read and modify.
Secondly, MVC architectures have a fragile update process with a lack of testing. We experiment a lot to roll out new features to our users. These experiments boil down to if-else statements. Whenever there’s a class with many functionalities, the if-else statements build on top of each other, making it near impossible to reason about, let alone test. Additionally, as integral pieces of code like the RequestViewController and TripViewController grew huge, making updates to the app became a fragile process. Imagine making a change and testing every possible combination of nested if-else experiments. Since we need experiments to continue to add new features and grow Uber’s business, this kind of architecture isn’t scalable.
Along the Way: VIPER
When considering alternatives to MVC, we were inspired by the way VIPER could be used as an application of Clean Architecture to iOS apps. VIPER offers a few key advantages to MVC. First, it offers more abstraction. The Presenter contains presentation logic that glues business logic with view logic. The Interactor handles purely data manipulation and verification. This includes making service calls to the backend to manipulate state, such as sign in and request a trip. And finally, the Router initiates transitions, such as taking the user from home to confirmation screen. Secondly, with the VIPER approach, the Presenter and Interactor are plain old objects, so we can use simple unit tests.
But we also found some downsides with VIPER. Its iOS-specific construct meant we’d have to make tradeoffs for Android. Its View-driven application logic means the application states are driven by views, since the entire application is anchored on the view tree. The business logic performed by the Interactor that’s supposed to manipulate application states always has to go through the Presenter, therefore, leaking business logic. And finally, with the tightly coupled view tree and business tree, it’s difficult to implement a node that contains only business logic or only view logic.
While VIPER offers significant improvements to the MVC pattern we were using, it doesn’t fully meet Uber’s needs and goals for a scalable platform with clear modularity. So we went back to the drawing board to see how we could develop an architecture pattern that grabs the benefits of VIPER, while accommodating for the cons as well. Our result was Riblets.
Riblets: Uber’s Rider App Architecture
In our new architecture pattern, the logic is similarly broken into small, independently testable pieces that each have a single purpose, following the single-responsibility principle. We use Riblets as these modular pieces, and the entire application is structured as a tree of Riblets.
Riblets and Their Components
With Riblets, we delegated responsibilities to six different components to further abstract business and view logic:
What distinguishes Riblets from VIPER and MVC? Routing is guided by business logic rather than view logic. This means the application is driven by the flow of information and decisions being made, rather than the presentation. At Uber, not every piece of business logic is related to a view that the user sees. Instead of lumping the business logic into a ViewController in MVC or manipulating application states through the Presenter in VIPER, we can have distinct Riblets for each piece of business logic, giving us logical groupings that make sense and are easy to reason about. We also designed the Riblet pattern to be platform-agnostic to unify Android and iOS development going forward.
Each Riblet is made up of one Router, Interactor, and Builder with its Component (hence the name), and optional Presenters and Views. The Router and Interactor handle the business logic, while the Presenter and View handle the view logic.
Let’s start by establishing what each of these Riblet units is responsible for, using the Product Selection Riblet as an example.
The Builder instantiates all primary Riblet units and defines dependencies. In the Product Selection Riblet, this unit defines the city stream (a data stream for a particular city) dependency.
The Component obtains and instantiates a Riblet’s dependencies. This includes services, data streams, and everything else that isn’t a primary Riblet unit. The Product Selection Component obtains and instantiates the city stream dependency, hooks it up to the appropriate network events, and injects it into the Interactor.
Routers form the application tree by attaching and detaching child Riblets. These decisions are passed by the Interactor. Routers also drive the Interactor lifecycle by activating and deactivating them at certain state switches. Routers contain two pieces of business logic:
- Helper methods for attaching and detaching Routers
- State-switching logic for determining states between multiple children
The Product Selection Riblet doesn’t have any child Riblets. The Router of its parent Riblet, the Confirmation Riblet, is responsible for attaching the Product Selection’s Router and adding its View to the View hierarchy. Then, once a product has been selected, the Product Selection Router deactivates its Interactor.
Interactors perform business logic. This includes, for example:
- Making service calls to initiate actions, like requesting a ride
- Making service calls to fetch data
- Determining what state to transition to next. For instance, if the root Interactor notices that a user’s authentication token is missing, it sends a request to its Router to switch to the “Welcome” state.
The Product Selection Interactor takes a city stream containing data including service offerings in that city, pricing information, estimated travel time, and vehicle views. It passes this information to the Presenter. If the user clicks from uberPOOL to uberX, the Interactor receives this information from the Presenter. It then collects the relevant data to pass back to the View so it can display uberX vehicles and estimated pick up time. In short, the Interactor performs all the business logic that is then presented in the View.
Views build and update the UI, including instantiating and laying out UI components, handling user interaction, filling UI components with data, and animations. The view for the Product Selection Riblet displays the objects it receives from the Presenter (product options, pricing, ETAs, vehicle views on the map) and passes back user actions (i.e., the product selection).
Presenters manage communication between Interactors and Views. From Interactors to Views, the Presenter translates business models into objects that the View can display. For Product Selection, this includes pricing data and vehicle views. From Views to Interactors, Presenters translate user interaction events, such as tapping a button to select a product, into appropriate actions in Interactors.
Putting the Pieces Together
Riblets only have a single Router and Interactor pair, but they can have multiple view parts. Riblets that only handle business logic and don’t have user interface elements don’t have the view part. Riblets can thus be single-view (one Presenter and one View), multi-view (either one Presenter and multiple Views, or multiple Presenters and Views), or viewless (no Presenter and no View). This allows the business logic tree structure and depth to be different from the view tree, which will have a flatter hierarchy. This helps simplify screen transitions.
For example, the Ride Riblet is a viewless Riblet that checks whether a user has an active trip. If the rider does, it attaches the Trip Riblet, which will show the trip on a map. If not, it attaches the Request Riblet, which will show the screen to allow users to request a trip. Such Riblets like the Ride Riblet without view logic serve an important function by breaking up the business logic that drives our applications, upholding the modular aspect of this new architecture.
How Riblets Build the Application
Riblets make up the application tree and often need to communicate in order to update information or take users to the next stage in getting a ride. Before we go into how they communicate, let’s first understand how data flows within one Riblet.
Data flow within a Riblet
Interactors own the state for their scope and the business logic that drives the application. This unit makes service calls to fetch data. In the new architecture, data flows in one direction. It goes from service to model stream and then from model stream to Interactor. Interactors, schedulers, and push-notifications from the network can ask services to make changes to the model stream. The model stream produces immutable models. This enforces the requirement that the Interactor classes must use the service layer to make changes to the application’s state.
- From a backend service to the View: A service call, like status, fetches data from the backend. This places the data on an immutable model stream. An Interactor listening to this stream notices the new data and passes it to the Presenter. The Presenter formats the data and sends it to the View.
- From the View to the backend: The user taps on a button, like sign-in, and the View passes the interaction to the Presenter. The Presenter calls a sign-in method on the Interactor that results in a service call to actually sign-in. The token that’s returned is published on a stream by the service. An Interactor listening to the stream switches to the Home Riblet.
Communication between Riblets
When an Interactor makes a business logic decision, it may need to inform another Riblet of events (e.g., completion) and send data. To achieve this, the Interactor making the business logic decision invokes an interface that’s conformed to by the Interactor of another Riblet.
Typically, if communication is going up the Riblet tree to a parent Riblet’s Interactor, the interface is defined as a listener. The listener is almost always implemented by the parent Riblet’s Interactor. If communication is downward to a child Riblet, the interface should be defined as a delegate, and implemented by the child Riblet’s Interactor. Delegates are only meant for synchronous direct communications between Riblet units, such as a parent Interactor to a child Interactor.
Specifically for downward communication, the parent Riblet can choose to expose an observable data stream to the child Riblet’s Interactor. The parent Riblet’s Interactor can then send data to the child Riblet Interactor via this stream, as an alternative to the delegate approach. In most downward communication for sending data, this should be the preferred method of communication.
For example, when a hypothetical ProductSelectionInteractor determines a product has been selected, it invokes its listener to pass the selected vehicle view ID. The listener is implemented by a ConfirmationInteractor. The ConfirmationInteractor then stores the vehicle view ID so it can be sent in a service request, invokes its Router to detach, and dismisses the ProductSelection Riblet.
By structuring data flow within and between Riblets in this way, we ensure that the right data comes at the right time on the right screen. Because Riblets form the application tree based on business logic, we can route communication through business logic (rather than view logic). This makes sense for our business and also ultimately helps encourage code isolation, keeping app development from growing too complex.
Back to the Starting Point
When we set out to start over on the rider app from scratch, we wanted to refocus on the core rider experience by increasing the app’s reliability and establish the right set of rails for future app development. Creating the new architecture was essential to achieving these two goals.
How did we increase availability for the core rider experience?
Riblets have clear separation of responsibilities, so testing is more straightforward. Each Riblet is independently testable. With better testing, we can be more confident in the reliability of our rider app when we roll out updates. Since each Riblet serves a single responsibility, it was easy to separate Riblets and their dependencies into core (directly needed to sign up and take an uberPOOL or uberX ride) and optional code. By demanding more stringent review for core code, we can be more confident in the availability of our core flows.
We also enabled global roll-back of core flows to a guaranteed working state. All optional code is under master feature flags that can be turned off if parts of it are buggy. In the worst case scenario, we can turn off every piece of optional code and default to just the core flow. Since we have such a high bar on core code, we ensure that our core flows are always working.
How did we establish the right set of rails for future rider app development?
Riblets help us narrow and decouple functionality as much as possible. This clarity in the separation of business and view logic will help prevent our codebase from growing overly complex and keep it easy to work out of. Since the new architecture is platform agnostic, iOS and Android engineers can easily understand how the other is developing, learn from one another’s mistakes, and work together to push Uber forward. Experimentation will be less prone to collaterally affect the core experience since Riblets help us separate optional code from core code. We’ll be able to try out new features, developed as plugins in the Riblet architecture, without worrying that they might accidentally put the uberX and uberPOOL experiences at risk of bugs.
Since Riblets have heightened abstraction and separation of responsibilities, and with a clearly defined path for data flow and communication, continuing development is easy—this architecture will serve us for years to come.
Ready to Move Forward
Our new architecture positions us for the road(s) ahead. This latest rewrite meant completely redoing the rider app’s codebase, reimplementing what previously existed, performing user research, case studies, A/B tests, and writing new features like the feed. On top of this, we wanted to do a global rollout to put the new app in the hands of our users faster, so we accounted for variations around the world from design, features, localization, devices, and testing perspectives. Though the launch is over, the work under our new architecture is just beginning.
There’s a plethora of possibilities to develop under this new architecture—improving the new rider feed, expanding this architecture to the driver and UberEATS apps, and even building for the builders. In fact, we spent a couple months building prototypes to make sure we were making the right tradeoffs. Now we could feel confident we got the architecture right for plenty of building ahead. If this kind of work excites you, come be part of this story and improve the Uber experience for everyone through Android and iOS engineering!
Vivian Tran wrote this article with Yixin Zhu, the Uber Engineering technical program manager who oversaw the release of the new rider app.